From 5442100f646ae1737b44243c6cdc23be9259d945 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Sun, 12 Oct 2025 13:56:18 +0500 Subject: [PATCH] feat: use GameCard on autonstall tab Signed-off-by: Boris Yumankulov --- portprotonqt/input_manager.py | 139 ++++++++++++++++++++ portprotonqt/main_window.py | 224 +++++++++++++++++++-------------- portprotonqt/portproton_api.py | 68 +++++----- 3 files changed, 295 insertions(+), 136 deletions(-) diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index 280165e..b07a9c7 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -1022,6 +1022,130 @@ class InputManager(QObject): elif current_row_idx == 0: self._parent.tabButtons[0].setFocus(Qt.FocusReason.OtherFocusReason) + # Autoinstall tab navigation(index 1) + if self._parent.stackedWidget.currentIndex() == 1 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): + focused = QApplication.focusWidget() + game_cards = self._parent.autoInstallContainer.findChildren(GameCard) + if not game_cards: + return + + scroll_area = self._parent.autoInstallContainer.parentWidget() + while scroll_area and not isinstance(scroll_area, QScrollArea): + scroll_area = scroll_area.parentWidget() + + # If no focused widget or not a GameCard, focus the first card + if not isinstance(focused, GameCard) or focused not in game_cards: + game_cards[0].setFocus() + if scroll_area: + scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) + return + + cards = self._parent.autoInstallContainer.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively) + if not cards: + return + # Group cards by rows with tolerance for y-position + rows = {} + y_tolerance = 10 # Allow slight variations in y-position + for card in cards: + y = card.pos().y() + matched = False + for row_y in rows: + if abs(y - row_y) <= y_tolerance: + rows[row_y].append(card) + matched = True + break + if not matched: + rows[y] = [card] + sorted_rows = sorted(rows.items(), key=lambda x: x[0]) + if not sorted_rows: + return + current_row_idx = None + current_col_idx = None + for row_idx, (_y, row_cards) in enumerate(sorted_rows): + for idx, card in enumerate(row_cards): + if card == focused: + current_row_idx = row_idx + current_col_idx = idx + break + if current_row_idx is not None: + break + + # Fallback: if focused card not found, select closest row by y-position + if current_row_idx is None: + if not sorted_rows: # Additional safety check + return + focused_y = focused.pos().y() + current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y)) + if current_row_idx >= len(sorted_rows): # Safety check + return + current_row = sorted_rows[current_row_idx][1] + focused_x = focused.pos().x() + focused.width() / 2 + current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore + + # Add null checks before using current_row_idx and current_col_idx + if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows): + return + + current_row = sorted_rows[current_row_idx][1] + if code == ecodes.ABS_HAT0X and value != 0: + if value < 0: # Left + if current_col_idx > 0: + next_card = current_row[current_col_idx - 1] + next_card.setFocus(Qt.FocusReason.OtherFocusReason) + if scroll_area: + scroll_area.ensureWidgetVisible(next_card, 50, 50) + else: + if current_row_idx > 0: + prev_row = sorted_rows[current_row_idx - 1][1] + next_card = prev_row[-1] if prev_row else None + if next_card: + next_card.setFocus(Qt.FocusReason.OtherFocusReason) + if scroll_area: + scroll_area.ensureWidgetVisible(next_card, 50, 50) + elif value > 0: # Right + if current_col_idx < len(current_row) - 1: + next_card = current_row[current_col_idx + 1] + next_card.setFocus(Qt.FocusReason.OtherFocusReason) + if scroll_area: + scroll_area.ensureWidgetVisible(next_card, 50, 50) + else: + if current_row_idx < len(sorted_rows) - 1: + next_row = sorted_rows[current_row_idx + 1][1] + next_card = next_row[0] if next_row else None + if next_card: + next_card.setFocus(Qt.FocusReason.OtherFocusReason) + if scroll_area: + scroll_area.ensureWidgetVisible(next_card, 50, 50) + elif code == ecodes.ABS_HAT0Y and value != 0: + if value > 0: # Down + if current_row_idx < len(sorted_rows) - 1: + next_row = sorted_rows[current_row_idx + 1][1] + current_x = focused.pos().x() + focused.width() / 2 + next_card = min( + next_row, + key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), + default=None + ) + if next_card: + next_card.setFocus(Qt.FocusReason.OtherFocusReason) + if scroll_area: + scroll_area.ensureWidgetVisible(next_card, 50, 50) + elif value < 0: # Up + if current_row_idx > 0: + prev_row = sorted_rows[current_row_idx - 1][1] + current_x = focused.pos().x() + focused.width() / 2 + next_card = min( + prev_row, + key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), + default=None + ) + if next_card: + next_card.setFocus(Qt.FocusReason.OtherFocusReason) + if scroll_area: + scroll_area.ensureWidgetVisible(next_card, 50, 50) + elif current_row_idx == 0: + self._parent.tabButtons[0].setFocus(Qt.FocusReason.OtherFocusReason) + # Vertical navigation in other tabs elif code == ecodes.ABS_HAT0Y and value != 0: focused = QApplication.focusWidget() @@ -1088,6 +1212,21 @@ class InputManager(QObject): keyboard.on_backspace_pressed() elif value == 0: keyboard.stop_backspace_repeat() + elif button_code in BUTTONS['search']: # Кнопка Y/Square - поиск (tab-specific) + if value == 1: + current_index = self._parent.stackedWidget.currentIndex() + search_field = None + if current_index == 0: # Library tab + search_field = self._parent.searchLineEdit + elif current_index == 1: # Auto Install tab + search_field = self._parent.autoInstallSearchLineEdit + else: + return + + if search_field: + search_field.setFocus() + keyboard.current_input_widget = search_field + keyboard.show() def eventFilter(self, obj: QObject, event: QEvent) -> bool: app = QApplication.instance() diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 9fe1aa7..3d2ce08 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -11,7 +11,7 @@ from portprotonqt.logger import get_logger from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog from portprotonqt.game_card import GameCard from portprotonqt.animations import DetailPageAnimations -from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel +from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.input_manager import InputManager from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit @@ -518,8 +518,6 @@ class MainWindow(QMainWindow): self.progress_bar.setValue(100) if exit_code == 0: self.update_status_message.emit(_("Installation completed successfully."), 5000) - # Reload library after delay - QTimer.singleShot(3000, self.loadGames) else: self.update_status_message.emit(_("Installation failed."), 5000) QMessageBox.warning(self, _("Error"), f"Installation failed (code: {exit_code}).") @@ -1061,113 +1059,150 @@ class MainWindow(QMainWindow): get_steam_game_info_async(final_name, exec_line, on_steam_info) def createAutoInstallTab(self): - """Create the Auto Install tab with flow layout of simple game cards (cover, name, install button).""" - from portprotonqt.localization import _ - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(20) - # Header label - header = QLabel(_("Auto Install Games")) - header.setStyleSheet(self.theme.DETAIL_PAGE_TITLE_STYLE) - layout.addWidget(header) + autoInstallPage = QWidget() + autoInstallPage.setStyleSheet(self.theme.MAIN_WINDOW_STYLE) + autoInstallLayout = QVBoxLayout(autoInstallPage) + autoInstallLayout.setContentsMargins(0, 0, 0, 0) + autoInstallLayout.setSpacing(0) - # Scroll area for games - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll_widget = QWidget() - from portprotonqt.custom_widgets import FlowLayout - self.auto_install_flow_layout = FlowLayout(scroll_widget) # Store reference for potential updates - self.auto_install_flow_layout.setSpacing(15) - self.auto_install_flow_layout.setContentsMargins(0, 0, 0, 0) + # Верхняя панель с заголовком и поиском + headerWidget = QWidget() + headerLayout = QHBoxLayout(headerWidget) + headerLayout.setContentsMargins(20, 10, 20, 10) + headerLayout.setSpacing(10) - # Load games asynchronously (though now sync inside, but callback for consistency) + # Заголовок + titleLabel = QLabel(_("Auto Install Games")) + titleLabel.setStyleSheet(self.theme.TAB_TITLE_STYLE) + titleLabel.setAlignment(Qt.AlignVCenter | Qt.AlignLeft) + headerLayout.addWidget(titleLabel) + + headerLayout.addStretch() + + # Поисковая строка + self.autoInstallSearchLineEdit = CustomLineEdit(self, theme=self.theme) + icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search")) + action_pos = cast(int, CustomLineEdit.ActionPosition.LeadingPosition) + self.search_action = self.autoInstallSearchLineEdit.addAction(icon, action_pos) + self.autoInstallSearchLineEdit.setMaximumWidth(200) + self.autoInstallSearchLineEdit.setPlaceholderText(_("Find Games ...")) + self.autoInstallSearchLineEdit.setClearButtonEnabled(True) + self.autoInstallSearchLineEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE) + self.autoInstallSearchLineEdit.textChanged.connect(self.filterAutoInstallGames) + headerLayout.addWidget(self.autoInstallSearchLineEdit) + + autoInstallLayout.addWidget(headerWidget) + + # Прогресс-бар + self.autoInstallProgress = QProgressBar() + self.autoInstallProgress.setStyleSheet(self.theme.PROGRESS_BAR_STYLE) + self.autoInstallProgress.setVisible(False) + autoInstallLayout.addWidget(self.autoInstallProgress) + + # Скролл + self.autoInstallScrollArea = QScrollArea() + self.autoInstallScrollArea.setWidgetResizable(True) + self.autoInstallScrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.autoInstallScrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.autoInstallScrollArea.setStyleSheet("QScrollArea { border: none; background: transparent; }") + + self.autoInstallContainer = QWidget() + self.autoInstallContainerLayout = FlowLayout(self.autoInstallContainer) + self.autoInstallContainer.setLayout(self.autoInstallContainerLayout) + self.autoInstallScrollArea.setWidget(self.autoInstallContainer) + + autoInstallLayout.addWidget(self.autoInstallScrollArea) + + # Хранение карточек + self.autoInstallGameCards = {} + self.allAutoInstallCards = [] + + # Обновление обложки + def on_autoinstall_cover_updated(exe_name, local_path): + if exe_name in self.autoInstallGameCards and local_path: + card = self.autoInstallGameCards[exe_name] + card.cover_path = local_path + load_pixmap_async(local_path, self.card_width, int(self.card_width * 1.5), card.on_cover_loaded) + + # Загрузка игр def on_autoinstall_games_loaded(games: list[tuple]): - # Clear existing widgets - while self.auto_install_flow_layout.count(): - child = self.auto_install_flow_layout.takeAt(0) - if child.widget(): + self.autoInstallProgress.setVisible(False) + + # Очистка + while self.autoInstallContainerLayout.count(): + child = self.autoInstallContainerLayout.takeAt(0) + if child: child.widget().deleteLater() - for game in games: - name = game[0] - description = game[1] - cover_path = game[2] - exec_line = game[4] - script_name = exec_line.split("autoinstall:")[1] if exec_line.startswith("autoinstall:") else "" + self.autoInstallGameCards.clear() + self.allAutoInstallCards.clear() - # Create simple card frame - card_frame = QFrame() - card_frame.setFixedWidth(self.card_width) - card_frame.setStyleSheet(self.theme.GAME_CARD_STYLE if hasattr(self.theme, 'GAME_CARD_STYLE') else "") - card_layout = QVBoxLayout(card_frame) - card_layout.setContentsMargins(10, 10, 10, 10) - card_layout.setSpacing(10) + if not games: + return - # Cover image - cover_label = QLabel() - cover_label.setFixedHeight(120) - cover_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - pixmap = QPixmap() - if cover_path and os.path.exists(cover_path) and pixmap.load(cover_path): - scaled_pix = pixmap.scaled(self.card_width - 40, 120, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - cover_label.setPixmap(scaled_pix) - else: - # Placeholder - placeholder_icon = self.theme_manager.get_theme_image("placeholder", self.current_theme_name) - if placeholder_icon: - pixmap.load(str(placeholder_icon)) - scaled_pix = pixmap.scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - cover_label.setPixmap(scaled_pix) - card_layout.addWidget(cover_label) + # Callback для запуска установки + def select_callback(name, description, cover_path, appid, exec_line, controller_support, *_): + if not exec_line or not exec_line.startswith("autoinstall:"): + logger.warning(f"Invalid exec_line for autoinstall: {exec_line}") + return + script_name = exec_line[11:].lstrip(':').strip() + self.launch_autoinstall(script_name) - # Name label - name_label = QLabel(name) - name_label.setWordWrap(True) - name_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - name_label.setStyleSheet(self.theme.CARD_TITLE_STYLE if hasattr(self.theme, 'CARD_TITLE_STYLE') else "") - card_layout.addWidget(name_label) + # Создаём карточки + for game_tuple in games: + name, description, cover_path, appid, controller_support, exec_line, *_ , game_source, exe_name = game_tuple - # Optional short description - if description: - desc_label = QLabel(description[:100] + "..." if len(description) > 100 else description) - desc_label.setWordWrap(True) - desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - desc_label.setStyleSheet(self.theme.CARD_DESC_STYLE if hasattr(self.theme, 'CARD_DESC_STYLE') else "") - card_layout.addWidget(desc_label) + card = GameCard( + name, description, cover_path, appid, controller_support, + exec_line, None, None, None, + None, None, None, game_source, + select_callback=select_callback, + theme=self.theme, + card_width=self.card_width, + parent=self.autoInstallContainer, + ) - # Install button - install_btn = AutoSizeButton(_("Install")) - install_btn.setStyleSheet(self.theme.PLAY_BUTTON_STYLE) - install_btn.clicked.connect(lambda checked, s=script_name: self.launch_autoinstall(s)) - card_layout.addWidget(install_btn) + self.autoInstallGameCards[exe_name] = card + self.allAutoInstallCards.append(card) + self.autoInstallContainerLayout.addWidget(card) - card_layout.addStretch() + # Загружаем недостающие обложки + for game_tuple in games: + name, _, cover_path, *_ , game_source, exe_name = game_tuple + if not cover_path: + self.portproton_api.download_autoinstall_cover_async( + exe_name, timeout=5, + callback=lambda path, ex=exe_name: on_autoinstall_cover_updated(ex, path) + ) - # Add to flow layout - self.auto_install_flow_layout.addWidget(card_frame) + self.autoInstallContainer.updateGeometry() + self.autoInstallScrollArea.updateGeometry() + self.filterAutoInstallGames() - scroll.setWidget(scroll_widget) - layout.addWidget(scroll) - - # Trigger load + # Показываем прогресс + self.autoInstallProgress.setVisible(True) + self.autoInstallProgress.setRange(0, 0) self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded) - self.stackedWidget.addWidget(tab) + self.stackedWidget.addWidget(autoInstallPage) + def filterAutoInstallGames(self): + """Filter auto install game cards based on search text.""" + search_text = self.autoInstallSearchLineEdit.text().lower().strip() + visible_count = 0 - def on_auto_install_search_changed(self, text: str): - """Filter auto-install games based on search text.""" - filtered_games = [g for g in self.auto_install_games if text.lower() in g[0].lower() or text.lower() in g[1].lower()] - self.populate_auto_install_grid(filtered_games) - self.auto_install_clear_search_button.setVisible(bool(text)) + for card in self.allAutoInstallCards: + if search_text in card.name.lower(): + card.setVisible(True) + visible_count += 1 + else: + card.setVisible(False) - def clear_auto_install_search(self): - """Clear the auto-install search and repopulate grid.""" - self.auto_install_search_line.clear() - self.populate_auto_install_grid(self.auto_install_games) + # Re-layout the container + self.autoInstallContainerLayout.invalidate() + self.autoInstallContainer.updateGeometry() + self.autoInstallScrollArea.updateGeometry() def createWineTab(self): """Вкладка 'Wine Settings'.""" @@ -1787,7 +1822,7 @@ class MainWindow(QMainWindow): # # 8. Legendary Authentication # self.legendaryAuthButton = AutoSizeButton( # _("Open Legendary Login"), - # icon=self.theme_manager.get_icon("login") + # icon=self.theme_manager.get_icon("login")self.theme_manager.get_icon("login") # ) # self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) # self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) @@ -2713,11 +2748,6 @@ class MainWindow(QMainWindow): QDesktopServices.openUrl(url) return - if exec_line.startswith("autoinstall:"): - script_name = exec_line.split("autoinstall:")[1] - self.launch_autoinstall(script_name) - return - # Обработка EGS-игр if exec_line.startswith("legendary:launch:"): app_name = exec_line.split("legendary:launch:")[1] diff --git a/portprotonqt/portproton_api.py b/portprotonqt/portproton_api.py index 59561a3..f7b1667 100644 --- a/portprotonqt/portproton_api.py +++ b/portprotonqt/portproton_api.py @@ -136,42 +136,38 @@ class PortProtonAPI: callback(results) def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None: - """Download only autoinstall cover image (no metadata).""" + """Download only autoinstall cover image (PNG only, no metadata).""" xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) - user_game_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall", exe_name) - os.makedirs(user_game_folder, exist_ok=True) + autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") + user_game_folder = os.path.join(autoinstall_root, exe_name) - cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"] - results: str | None = None - pending_downloads = 0 + if not os.path.isdir(user_game_folder): + try: + os.mkdir(user_game_folder) + except FileExistsError: + pass - def on_cover_downloaded(local_path: str | None, ext: str): - nonlocal pending_downloads, results + cover_url = f"{self.base_url}/{exe_name}/cover.png" + local_cover_path = os.path.join(user_game_folder, "cover.png") + + def on_cover_downloaded(local_path: str | None): if local_path: logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}") - results = local_path else: - logger.debug(f"No autoinstall cover downloaded for {exe_name} with extension {ext}") - pending_downloads -= 1 - if pending_downloads == 0 and callback: - callback(results) + logger.debug(f"No autoinstall cover downloaded for {exe_name}") + if callback: + callback(local_path) - for ext in cover_extensions: - cover_url = f"{self.base_url}/{exe_name}/cover{ext}" - if self._check_file_exists(cover_url, timeout): - local_cover_path = os.path.join(user_game_folder, f"cover{ext}") - pending_downloads += 1 - self.downloader.download_async( - cover_url, - local_cover_path, - timeout=timeout, - callback=lambda path, ext=ext: on_cover_downloaded(path, ext) - ) - break - - if pending_downloads == 0: - logger.debug(f"No autoinstall covers found for {exe_name}") + if self._check_file_exists(cover_url, timeout): + self.downloader.download_async( + cover_url, + local_cover_path, + timeout=timeout, + callback=on_cover_downloaded + ) + else: + logger.debug(f"No autoinstall cover found for {exe_name}") if callback: callback(None) @@ -212,10 +208,8 @@ class PortProtonAPI: portwine_match = None for line in content.splitlines(): stripped = line.strip() - # Игнорируем закомментированные строки if stripped.startswith("#"): continue - # Ищем portwine_exe только в активных строках if "portwine_exe" in stripped and "=" in stripped: portwine_match = stripped break @@ -238,7 +232,7 @@ class PortProtonAPI: return None, None def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None: - """Load auto-install games with user/builtin covers and async autoinstall cover download.""" + """Load auto-install games with user/builtin covers (no async download here).""" games = [] auto_dir = os.path.join(self.portproton_location, "data", "scripts", "pw_autoinstall") if not os.path.exists(auto_dir): @@ -275,15 +269,10 @@ class PortProtonAPI: cover_path = os.path.join(user_game_folder, candidate) break - # Если обложки нет — пытаемся скачать if not cover_path: - logger.debug(f"No local cover found for autoinstall {exe_name}, trying to download...") - def on_cover_downloaded(path): - if path: - logger.info(f"Downloaded autoinstall cover for {exe_name}: {path}") - self.download_autoinstall_cover_async(exe_name, timeout=5, callback=on_cover_downloaded) + logger.debug(f"No local cover found for autoinstall {exe_name}") - # Формируем кортеж игры + # Формируем кортеж игры (добавлен exe_name в конец) game_tuple = ( display_name, # name "", # description @@ -297,7 +286,8 @@ class PortProtonAPI: "", # anticheat_status 0, # last_played 0, # playtime_seconds - "autoinstall" # game_source + "autoinstall", # game_source + exe_name # exe_name ) games.append(game_tuple)