diff --git a/portprotonqt/dialogs.py b/portprotonqt/dialogs.py index 8476aeb..4a1f058 100644 --- a/portprotonqt/dialogs.py +++ b/portprotonqt/dialogs.py @@ -5,9 +5,8 @@ from typing import cast, TYPE_CHECKING from PySide6.QtGui import QPixmap, QIcon, QTextCursor, QColor from PySide6.QtWidgets import ( QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller, - QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget + QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget, QComboBox ) - from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment from icoextract import IconExtractor, IconExtractorError from PIL import Image @@ -1674,27 +1673,32 @@ class WinetricksDialog(QDialog): if self.input_manager: self.input_manager.disable_winetricks_mode() super().reject() - class ExeSettingsDialog(QDialog): def __init__(self, parent=None, theme=None, exe_path=None): super().__init__(parent) self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) self.exe_path = exe_path if not self.exe_path: - logger.error("Exe path not provided") return self.portproton_path = get_portproton_location() if self.portproton_path is None: logger.error("PortProton location not found") return base_path = os.path.join(self.portproton_path, "data") - self.start_sh = os.path.join(base_path, "scripts", "start.sh") - self.ppdb_path = self.exe_path + ".ppdb" if not self.exe_path.endswith('.ppdb') else self.exe_path + self.start_sh = [os.path.join(base_path, "scripts", "start.sh")] + self.current_settings = {} self.value_widgets = {} self.original_values = {} + self.advanced_widgets = {} + self.original_display_values = {} self.available_keys = set() self.blocked_keys = set() + self.numa_nodes = {} + self.is_amd = False + self.locale_options = [] + self.logical_core_options = [] + self.amd_vulkan_drivers = [] self.branch_name = _("Unknown") self.setWindowTitle(_("Exe Settings")) @@ -1717,25 +1721,16 @@ class ExeSettingsDialog(QDialog): self.current_theme_name = read_theme_from_config() - # Create hints widget using common function - self.hints_widget, self.hints_labels = create_dialog_hints_widget( - self.theme, self.main_window, self.input_manager, context='winetricks' - ) - self.main_layout.addWidget(self.hints_widget) - - # Connect signals - if self.input_manager: - self.input_manager.button_event.connect( - lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) - ) - self.input_manager.dpad_moved.connect( - lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) - ) - update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) - # Load current settings (includes list-db) self.load_current_settings() + def _get_process_args(self, subcommand_args): + """Get the full arguments for QProcess.start, handling flatpak separator.""" + if self.start_sh[0] == "flatpak": + return self.start_sh[1:] + ["--"] + subcommand_args + else: + return self.start_sh + subcommand_args + def init_toggle_settings(self): """Initialize predefined toggle settings with descriptions.""" self.toggle_settings = { @@ -1786,10 +1781,15 @@ class ExeSettingsDialog(QDialog): self.main_layout.setContentsMargins(10, 10, 10, 10) self.main_layout.setSpacing(10) - # Метка с текущей веткой (STABLE / DEVEL) - self.branch_label = QLabel(_("Detected branch: Unknown")) - self.branch_label.setStyleSheet("font-weight: bold;") - self.main_layout.addWidget(self.branch_label) + # Tab widget + self.tab_widget = QTabWidget() + self.main_tab = QWidget() + self.main_tab_layout = QVBoxLayout(self.main_tab) + self.advanced_tab = QWidget() + self.advanced_tab_layout = QVBoxLayout(self.advanced_tab) + + self.tab_widget.addTab(self.main_tab, _("Main")) + self.tab_widget.addTab(self.advanced_tab, _("Advanced")) # Таблица настроек self.settings_table = QTableWidget() @@ -1806,12 +1806,31 @@ class ExeSettingsDialog(QDialog): self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone) self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE) - self.main_layout.addWidget(self.settings_table) + self.main_tab_layout.addWidget(self.settings_table) + + # Таблица Advanced + self.advanced_table = QTableWidget() + self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.advanced_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.advanced_table.setColumnCount(3) + self.advanced_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")]) + self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) + self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.advanced_table.horizontalHeader().resizeSection(1, 200) + self.advanced_table.setWordWrap(True) + self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone) + self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE) + self.advanced_tab_layout.addWidget(self.advanced_table) + + self.main_layout.addWidget(self.tab_widget) # Кнопки button_layout = QHBoxLayout() - self.apply_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply")) - self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel")) + self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply")) + self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel")) self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) button_layout.addWidget(self.apply_button) @@ -1821,69 +1840,107 @@ class ExeSettingsDialog(QDialog): self.apply_button.clicked.connect(self.apply_changes) self.cancel_button.clicked.connect(self.reject) + def load_current_settings(self): """Load available toggles first, then current settings.""" process = QProcess(self) process.finished.connect(self.on_list_db_finished) - process.start(self.start_sh, ["cli", "--list-db"]) + process.start(self.start_sh[0], ["cli", "--list-db"]) + def on_list_db_finished(self, exit_code, exit_status): - """Handle --list-db output and extract available keys.""" + """Handle --list-db output and extract available keys and system info.""" process = cast(QProcess, self.sender()) self.available_keys = set() self.blocked_keys = set() if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit: output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore') - for line in output.splitlines(): - if "Branch in used:" in line: - self.branch_name = line.split(":", 1)[1].strip() - self.branch_label.setText(_("Detected branch: ") + self.branch_name) + lines = output.splitlines() + self.numa_nodes = {} + self.is_amd = False + self.logical_core_options = [] + self.locale_options = [] + self.amd_vulkan_drivers = [] + for line in lines: + line_stripped = line.strip() + if not line_stripped: continue - stripped_line = line.strip() - if stripped_line.startswith("PW_"): - parts = stripped_line.split(maxsplit=1) + if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'): + # System info + k, v = line_stripped.split('=', 1) + if k.startswith('NUMA_NODE_'): + node_id = k[10:] + self.numa_nodes[node_id] = v + elif k == 'IS_AMD': + self.is_amd = v.lower() == 'true' + elif k == 'LOGICAL_CORE_OPTIONS': + self.logical_core_options = v.split('!') if v else [] + elif k == 'LOCALE_LIST': + self.locale_options = v.split('!') if v else [] + elif k == 'AMD_VULKAN_DRIVER_LIST': + self.amd_vulkan_drivers = v.split('!') if v else [] + continue + if line_stripped.startswith('PW_'): + parts = line_stripped.split(maxsplit=1) key = parts[0] self.available_keys.add(key) - if len(parts) > 1 and parts[1] == "blocked": + if len(parts) > 1 and 'blocked' in parts[1]: self.blocked_keys.add(key) # Показываем только пересечение self.available_keys &= set(self.toggle_settings.keys()) - logger.debug(f"Filtered available keys (intersection): {self.available_keys}") - else: - logger.warning("Failed to get --list-db output; showing all toggles") - self.available_keys = set(self.toggle_settings.keys()) # Загружаем текущие настройки process = QProcess(self) process.finished.connect(self.on_show_ppdb_finished) - process.start(self.start_sh, ["cli", "--show-ppdb", self.ppdb_path]) + process.start(self.start_sh[0], ["cli", "--show-ppdb", f"{self.exe_path}.ppdb"]) def on_show_ppdb_finished(self, exit_code, exit_status): """Handle --show-ppdb output.""" process = cast(QProcess, self.sender()) if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit: - logger.warning("Failed to load settings, using defaults") + # Fallback to defaults if load fails + for key in self.toggle_settings: + self.current_settings[key] = '0' + for adv_key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS', + 'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE', + 'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT', + 'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE', + 'PW_CPU_NUMA_NODE_INDEX']: + self.current_settings[adv_key] = 'disabled' if 'TOPOLOGY' in adv_key or 'SELECT' in adv_key or 'MODE' in adv_key or 'LEVEL' in adv_key or 'GL_VERSION' in adv_key or 'NUMA' in adv_key else '' else: output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore').strip() self.current_settings = {} for line in output.split('\n'): - if '=' in line and line.strip().startswith('PW_'): - key, val = line.split('=', 1) - self.current_settings[key.strip()] = val.strip() - logger.debug(f"Loaded current settings: {self.current_settings}") + line_stripped = line.strip() + if '=' in line_stripped: + # Parse all KEY=VALUE lines, not just specific prefixes, to catch more + try: + key, val = line_stripped.split('=', 1) + if key in self.toggle_settings or key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS', + 'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE', + 'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT', + 'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE', + 'PW_CPU_NUMA_NODE_INDEX', 'PW_TASKSET_SLR']: + self.current_settings[key] = val + except ValueError: + continue # Force blocked settings to '0' for key in self.blocked_keys: self.current_settings[key] = '0' + self.original_values = self.current_settings.copy() + for key in set(self.toggle_settings.keys()): + self.original_values.setdefault(key, '0') + self.populate_table() + self.populate_advanced() def populate_table(self): """Populate the table with settings that are available in both lists.""" self.settings_table.setRowCount(0) self.value_widgets.clear() - self.original_values.clear() self.settings_table.verticalHeader().setVisible(False) visible_keys = sorted(self.available_keys) if self.available_keys else sorted(self.toggle_settings.keys()) @@ -1922,16 +1979,187 @@ class ExeSettingsDialog(QDialog): self.settings_table.setItem(row, 0, name_item) self.value_widgets[(row, 1)] = checkbox - self.original_values[toggle] = current_val self.settings_table.resizeRowsToContents() if self.settings_table.rowCount() > 0: self.settings_table.setCurrentCell(0, 0) self.settings_table.setFocus(Qt.FocusReason.OtherFocusReason) + def populate_advanced(self): + """Populate the advanced tab with table format.""" + self.advanced_table.setRowCount(0) + self.advanced_widgets.clear() + self.original_display_values = {} + self.advanced_table.verticalHeader().setVisible(False) + + current = self.current_settings + disabled_text = _('disabled') + + # Define advanced settings configuration + advanced_settings = [] + + # 1. Windows version + advanced_settings.append({ + 'key': 'PW_WINDOWS_VER', + 'name': _("Windows version"), + 'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"), + 'type': 'combo', + 'options': ['11', '10', '7', 'XP'], + 'default': '10' + }) + + # 2. Forced to use/disable libraries + advanced_settings.append({ + 'key': 'WINEDLLOVERRIDES', + 'name': _("DLL Overrides"), + 'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"), + 'type': 'text', + 'default': '' + }) + + # 3. Launch arguments + advanced_settings.append({ + 'key': 'LAUNCH_PARAMETERS', + 'name': _("Launch Arguments"), + 'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"), + 'type': 'text', + 'default': '' + }) + + # 4. CPU cores limit + advanced_settings.append({ + 'key': 'PW_WINE_CPU_TOPOLOGY', + 'name': _("CPU Cores Limit"), + 'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"), + 'type': 'combo', + 'options': [disabled_text] + self.logical_core_options, + 'default': disabled_text + }) + + # 5. OpenGL version + advanced_settings.append({ + 'key': 'PW_MESA_GL_VERSION_OVERRIDE', + 'name': _("OpenGL Version"), + 'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."), + 'type': 'combo', + 'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'], + 'default': disabled_text + }) + + # 6. VKD3D feature level + advanced_settings.append({ + 'key': 'PW_VKD3D_FEATURE_LEVEL', + 'name': _("VKD3D Feature Level"), + 'description': _("You can set a forced feature level VKD3D for games on DirectX12"), + 'type': 'combo', + 'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'], + 'default': disabled_text + }) + + # 7. Locale + advanced_settings.append({ + 'key': 'PW_LOCALE_SELECT', + 'name': _("Locale"), + 'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"), + 'type': 'combo', + 'options': [disabled_text] + self.locale_options, + 'default': disabled_text + }) + + # 8. Present mode + advanced_settings.append({ + 'key': 'PW_MESA_VK_WSI_PRESENT_MODE', + 'name': _("Window Mode"), + 'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."), + 'type': 'combo', + 'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'], + 'default': disabled_text + }) + + # 9. AMD Vulkan (always show, block if not applicable) + amd_options = [disabled_text] + self.amd_vulkan_drivers if self.is_amd and self.amd_vulkan_drivers else [disabled_text] + advanced_settings.append({ + 'key': 'PW_AMD_VULKAN_USE', + 'name': _("AMD Vulkan Driver"), + 'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"), + 'type': 'combo', + 'options': amd_options, + 'default': disabled_text + }) + + # 10. NUMA node (always show if numa_nodes exist, block if <=1) + numa_ids = sorted(self.numa_nodes.keys()) + numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text] + advanced_settings.append({ + 'key': 'PW_CPU_NUMA_NODE_INDEX', + 'name': _("NUMA Node"), + 'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."), + 'type': 'combo', + 'options': numa_options, + 'default': disabled_text + }) + + # Populate table + for setting in advanced_settings: + row = self.advanced_table.rowCount() + self.advanced_table.insertRow(row) + + # Name column + name_item = QTableWidgetItem(setting['name']) + name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled) + self.advanced_table.setItem(row, 0, name_item) + + # Value column (widget) + if setting['type'] == 'combo': + combo = QComboBox() + combo.addItems(setting['options']) + + # Get current value + current_raw = current.get(setting['key'], setting['default']) + if setting['key'] == 'PW_WINE_CPU_TOPOLOGY': + current_val = disabled_text if current_raw == 'disabled' else (current_raw.split(':')[0] if isinstance(current_raw, str) and ':' in current_raw else current_raw) + elif setting['key'] == 'PW_AMD_VULKAN_USE': + current_val = disabled_text if not current_raw or current_raw == '' else current_raw + else: + current_val = disabled_text if current_raw == 'disabled' else current_raw + + if current_val not in setting['options']: + combo.addItem(current_val) + combo.setCurrentText(current_val) + + # Block if only disabled option + if len(setting['options']) == 1: + combo.setEnabled(False) + + self.advanced_table.setCellWidget(row, 1, combo) + self.advanced_widgets[setting['key']] = combo + self.original_display_values[setting['key']] = current_val + + elif setting['type'] == 'text': + text_edit = QTextEdit() + current_val = current.get(setting['key'], setting['default']) + text_edit.setPlainText(current_val) + + self.advanced_table.setCellWidget(row, 1, text_edit) + self.advanced_widgets[setting['key']] = text_edit + self.original_display_values[setting['key']] = current_val + + # Description column + desc_item = QTableWidgetItem(setting['description']) + desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled) + desc_item.setToolTip(setting['description']) + desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self.advanced_table.setItem(row, 2, desc_item) + + self.advanced_table.resizeRowsToContents() + if self.advanced_table.rowCount() > 0: + self.advanced_table.setCurrentCell(0, 0) + def apply_changes(self): - """Apply changes by collecting diffs and running --edit-db.""" + """Apply changes by collecting diffs from both main and advanced tabs.""" changes = [] + + # --- 1. Обычные (toggle) настройки --- for key, orig_val in self.original_values.items(): if key in self.blocked_keys: continue # Skip blocked keys @@ -1952,14 +2180,32 @@ class ExeSettingsDialog(QDialog): if new_val != orig_val: changes.append(f"{key}={new_val}") + # --- 2. Advanced настройки --- + for key, widget in self.advanced_widgets.items(): + orig_val = self.original_display_values.get(key, '') + if isinstance(widget, QComboBox): + new_val = widget.currentText() + # приведение disabled к 'disabled' + if new_val.lower() == _('disabled').lower(): + new_val = 'disabled' + elif isinstance(widget, QTextEdit): + new_val = widget.toPlainText().strip() + else: + continue + + if new_val != orig_val: + changes.append(f"{key}={new_val}") + + # --- 3. Проверка на изменения --- if not changes: QMessageBox.information(self, _("Info"), _("No changes to apply.")) return + # --- 4. Запуск процесса сохранения --- process = QProcess(self) process.finished.connect(self.on_edit_db_finished) args = ["cli", "--edit-db", self.exe_path] + changes - process.start(self.start_sh, args) + process.start(self.start_sh[0], args) self.apply_button.setEnabled(False) def on_edit_db_finished(self, exit_code, exit_status):