import shutil from pathlib import Path from PySide6.QtCore import Qt, QUrl, Signal from PySide6.QtGui import QDesktopServices, QIcon from PySide6.QtWidgets import ( QFileDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QVBoxLayout, QWidget, QMenu, QComboBox, ) from manager.profile import ProfileDetail, ProfileManager, ProfileSummary, UpdateProfileRequest from manager.game import AvailableVersion from pages.add_modded_version import AddModdedVersionDialog class ManageProfileItem(QPushButton): selected = Signal(object) def __init__(self, app, profile: ProfileSummary, parent=None, delete_callback=None, duplicate_callback=None): super().__init__(parent) self.app = app self.profile = profile self.setObjectName("manageProfileItem") self.setCheckable(True) self.setCursor(Qt.CursorShape.PointingHandCursor) self.setMinimumHeight(64) # Callback self.delete_callback = delete_callback self.duplicate_callback = duplicate_callback layout = QHBoxLayout(self) layout.setContentsMargins(12, 8, 12, 8) layout.setSpacing(12) icon_path = Path(profile.icon) if not icon_path.is_file(): icon_path = Path(app.icon_dir, "profile_default.png") icon = QLabel() icon.setFixedSize(40, 40) icon.setPixmap(QIcon(icon_path.as_posix()).pixmap(36, 36)) icon.setAlignment(Qt.AlignmentFlag.AlignCenter) texts = QVBoxLayout() texts.setSpacing(1) name = QLabel(profile.display_name) name.setObjectName("itemName") version = QLabel(profile.version_id) version.setObjectName("itemVersion") texts.addWidget(name) texts.addWidget(version) layout.addWidget(icon) layout.addLayout(texts, 1) self.clicked.connect(lambda: self.selected.emit(self.profile)) # Enable right click menu self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_right_click_menu) def show_right_click_menu(self, position): menu = QMenu(self) duplicate_action = menu.addAction(self.app.tr_text("Duplicate")) remove_action = menu.addAction(self.app.tr_text("Delete")) menu.addAction(duplicate_action) menu.addAction(remove_action) def duplicate_callback(): self.duplicate_callback(self.profile) def delete_callback(): self.delete_callback(self.profile) if callable(self.duplicate_callback): duplicate_action.triggered.connect(duplicate_callback) if callable(self.delete_callback): remove_action.triggered.connect(delete_callback) global_position = self.mapToGlobal(position) menu.exec(global_position) class ManageProfilePage(QWidget): """ Profile manage page """ VERSION_CATEGORIES = ( ("Vanilla", "vanilla"), ("Modded", "modded"), ("Custom", "custom"), ) def __init__(self, app, parent=None): super().__init__(parent) self.app = app self.profile_manager: ProfileManager = app.profile_manager self.manifest_manager = app.game_manager.manifest self.selected_profile_id: str | None = None self.profile_items: dict[str, ManageProfileItem] = {} self.version_options: list[AvailableVersion] = [] root = QVBoxLayout(self) title = QLabel(self.app.tr_text("Manage Profiles")) title.setObjectName("manageTitle") root.addWidget(title) columns = QHBoxLayout() columns.setSpacing(12) root.addLayout(columns, 1) # Widgets self.list_scroll = self._scroll_area("manageProfileListScroll") self.list_content = QWidget() self.list_layout = QVBoxLayout(self.list_content) self.list_layout.setContentsMargins(6, 6, 6, 6) self.list_layout.setSpacing(4) self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.list_scroll.setWidget(self.list_content) self.list_scroll.setMinimumWidth(260) self.list_scroll.setMaximumWidth(380) columns.addWidget(self.list_scroll, 2) self.options_scroll = self._scroll_area("manageProfileOptionsScroll") self.options_content = QWidget() self.options_layout = QVBoxLayout(self.options_content) self.options_layout.setContentsMargins(20, 16, 20, 20) self.options_layout.setSpacing(14) self.options_scroll.setWidget(self.options_content) columns.addWidget(self.options_scroll, 3) self.empty_label = QLabel(self.app.tr_text("Select a profile from the list to edit its settings.")) self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.empty_label.setWordWrap(True) self.options_layout.addWidget(self.empty_label, 1) self.editor = QFrame() self.editor.setObjectName("profileEditor") editor_layout = QVBoxLayout(self.editor) editor_layout.setSpacing(14) form = QFormLayout() form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) form.setVerticalSpacing(12) self.name_edit = QLineEdit() self.version_dropdown = QComboBox() self.version_dropdown.setObjectName("Dropdown") self.version_dropdown.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed, ) self.version_dropdown.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) self.version_dropdown.setMinimumContentsLength(24) self.type_dropdown = QComboBox() self.type_dropdown.setObjectName("Dropdown") self.game_dir_edit = QLineEdit() self.java_args_edit = QLineEdit() self.icon_edit = QLineEdit() self.width_spin = self._dimension_spin() self.height_spin = self._dimension_spin() form.addRow(self.app.tr_text("Profile name"), self.name_edit) form.addRow(self.app.tr_text("Type"), self.type_dropdown) form.addRow(self.app.tr_text("Version"), self.version_dropdown) form.addRow(self.app.tr_text("Game directory"), self.game_dir_edit) form.addRow(self.app.tr_text("Java arguments"), self.java_args_edit) form.addRow(self.app.tr_text("Icon"), self.icon_edit) resolution = QHBoxLayout() resolution.addWidget(self.width_spin) resolution.addWidget(QLabel("×")) resolution.addWidget(self.height_spin) form.addRow(self.app.tr_text("Resolution"), resolution) editor_layout.addLayout(form) file_actions = QHBoxLayout() add_version_button = QPushButton(self.app.tr_text("Add Modded Version")) add_version_button.clicked.connect(self.open_add_modded_version_dialog) open_button = QPushButton(self.app.tr_text("Open Profile Folder")) open_button.clicked.connect(self.open_profile_folder) import_button = QPushButton(self.app.tr_text("Import Mods…")) import_button.clicked.connect(self.import_mods) file_actions.addWidget(add_version_button) file_actions.addWidget(open_button) file_actions.addWidget(import_button) file_actions.addStretch() editor_layout.addLayout(file_actions) actions = QHBoxLayout() duplicate_button = QPushButton(self.app.tr_text("Duplicate")) duplicate_button.clicked.connect(self.duplicate_profile) delete_button = QPushButton(self.app.tr_text("Delete")) delete_button.setObjectName("dangerButton") delete_button.clicked.connect(self.delete_profile) save_button = QPushButton(self.app.tr_text("Save Changes")) save_button.setObjectName("saveProfileButton") save_button.clicked.connect(self.save_profile) actions.addWidget(duplicate_button) actions.addWidget(delete_button) actions.addStretch() actions.addWidget(save_button) editor_layout.addLayout(actions) self.options_layout.addWidget(self.editor) self.options_layout.addStretch() self.editor.hide() self.type_dropdown.currentIndexChanged.connect(self._filter_versions) self.profile_manager.profiles_changed.connect(self.refresh_profiles) self.app.apply_qss(Path("manage_profile.qss"), self.setStyleSheet) self._load_type_dropdown() self.refresh_profiles() @staticmethod def _scroll_area(name: str) -> QScrollArea: area = QScrollArea() area.setObjectName(name) area.setWidgetResizable(True) area.setFrameShape(QFrame.Shape.NoFrame) area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) return area def _dimension_spin(self) -> QSpinBox: spin = QSpinBox() spin.setRange(0, 16384) spin.setSpecialValueText(self.app.tr_text("Default")) spin.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) return spin def _load_type_dropdown(self) -> None: self.type_dropdown.blockSignals(True) self.type_dropdown.clear() for display_name, category in self.VERSION_CATEGORIES: self.type_dropdown.addItem(self.app.tr_text(display_name), category) self.type_dropdown.blockSignals(False) def refresh_profiles(self) -> None: self.version_options = self.manifest_manager.list_available_profile_versions() self.load_profiles() def _filter_versions(self, _index: int = -1) -> None: selected_category = self.type_dropdown.currentData() selected_option = self.version_dropdown.currentData() selected_id = ( selected_option.id if isinstance(selected_option, AvailableVersion) else None ) self.version_dropdown.blockSignals(True) self.version_dropdown.clear() for option in self.version_options: if option.source == selected_category: self.version_dropdown.addItem(option.display_name, option) if selected_id is not None: self._select_version(selected_id) self.version_dropdown.blockSignals(False) def load_profiles(self) -> None: while self.list_layout.count(): item = self.list_layout.takeAt(0) if item.widget(): item.widget().deleteLater() self.profile_items.clear() profiles = self.profile_manager.list_profiles() for profile in profiles: item = ManageProfileItem(self.app, profile, self.list_content, duplicate_callback=self.duplicate_profile, delete_callback=self.delete_profile) item.selected.connect(self.select_profile) self.profile_items[profile.id] = item self.list_layout.addWidget(item) available_ids = {profile.id for profile in profiles} if self.selected_profile_id not in available_ids: self.selected_profile_id = None if self.selected_profile_id is None and profiles: current = self.profile_manager.get_current_profile_id() selected = next((p for p in profiles if p.id == current), profiles[0]) self.select_profile(selected) elif self.selected_profile_id: profile = next(p for p in profiles if p.id == self.selected_profile_id) self.select_profile(profile) else: self.editor.hide() self.empty_label.show() def select_profile(self, profile: ProfileSummary) -> None: detail = self.profile_manager.get_profile(profile.id) if detail is None: return self.selected_profile_id = profile.id for profile_id, item in self.profile_items.items(): item.setChecked(profile_id == profile.id) self._set_detail(detail) self.empty_label.hide() self.editor.show() def _select_version(self, version_id: str) -> None: for index in range(self.version_dropdown.count()): data = self.version_dropdown.itemData(index) if isinstance(data, AvailableVersion) and data.id == version_id: self.version_dropdown.setCurrentIndex(index) return def _select_type(self, category: str) -> None: index = self.type_dropdown.findData(category) if index >= 0: self.type_dropdown.setCurrentIndex(index) def _set_detail(self, profile: ProfileDetail) -> None: self.name_edit.setText(profile.name) option = next( (item for item in self.version_options if item.id == profile.version_id), None, ) # Preserve profiles imported from another launcher, even when their # version manifest is not available locally anymore. if option is None: option = AvailableVersion( id=profile.version_id, display_name=f"{profile.version_id} (Unavailable)", version_type=profile.version_type or "custom", source="custom", ) self.version_options.append(option) self._select_type(option.source) self._filter_versions() self._select_version(profile.version_id) self.game_dir_edit.setText(str(profile.game_dir or "")) self.java_args_edit.setText(profile.java_args) self.icon_edit.setText(profile.icon) width, height = profile.resolution or (0, 0) self.width_spin.setValue(width) self.height_spin.setValue(height) def _profile_dir(self) -> Path | None: if self.selected_profile_id is None: return None detail = self.profile_manager.get_profile(self.selected_profile_id) if detail is None: return None return detail.game_dir or Path(self.app.default_game_dir) def save_profile(self) -> None: if self.selected_profile_id is None: return width, height = self.width_spin.value(), self.height_spin.value() if (width == 0) != (height == 0): self.app.widget_manager.signal.show_warning_message.emit( self.app.tr_text("Set both resolution values, or set both to Default.") ) return game_dir_text = self.game_dir_edit.text().strip() version = self.version_dropdown.currentData() if not isinstance(version, AvailableVersion): self.app.widget_manager.signal.show_warning_message.emit( self.app.tr_text("Select a version.") ) return request = UpdateProfileRequest( name=self.name_edit.text().strip(), version_id=version.id, version_type=version.version_type, game_dir=Path(game_dir_text) if game_dir_text else None, java_args=self.java_args_edit.text(), icon=self.icon_edit.text().strip(), resolution=(width, height) if width and height else None, ) if self.profile_manager.validate_update(request).is_valid and self.profile_manager.update_profile( self.selected_profile_id, request ): self.profile_manager.save() self.app.widget_manager.signal.show_info_message.emit( self.app.tr_text("Profile settings saved.") ) else: errors = self.profile_manager.validate_update(request).errors if errors: self.app.widget_manager.signal.show_warning_message.emit("\n".join(errors.values())) def open_add_modded_version_dialog(self) -> None: dialog = AddModdedVersionDialog(self.app, self) dialog.version_created.connect(self._modded_version_created) dialog.exec() def _modded_version_created(self, version_id: str) -> None: self.version_options = self.manifest_manager.list_available_profile_versions() # self._select_type("modded") # self._filter_versions() # self._select_version(version_id) self.app.widget_manager.signal.show_info_message.emit( self.app.tr_text("Modded version {version_id} added.", version_id=version_id) ) def open_profile_folder(self) -> None: profile_dir = self._profile_dir() if profile_dir is None: return profile_dir.mkdir(parents=True, exist_ok=True) QDesktopServices.openUrl(QUrl.fromLocalFile(str(profile_dir.resolve()))) def import_mods(self) -> None: profile_dir = self._profile_dir() if profile_dir is None: return files, _ = QFileDialog.getOpenFileNames( self, self.app.tr_text("Import Mods"), "", "Minecraft mods (*.jar *.zip);;All files (*)" ) if not files: return mods_dir = profile_dir / "mods" mods_dir.mkdir(parents=True, exist_ok=True) try: for source in files: shutil.copy2(source, mods_dir / Path(source).name) except OSError as error: self.app.widget_manager.signal.show_error_message.emit( self.app.tr_text("Unable to import mod: {error}", error=error) ) return self.app.widget_manager.signal.show_info_message.emit( self.app.tr_text( "Imported {count} mod(s) into {directory}.", count=len(files), directory=mods_dir, ) ) def duplicate_profile(self, profile: ProfileSummary | None=None) -> None: if self.selected_profile_id is None and profile is None: return profile_id = self.selected_profile_id new_id = self.profile_manager.duplicate_profile(profile_id) if new_id: self.selected_profile_id = new_id self.load_profiles() self.profile_manager.save() def delete_profile(self, profile: ProfileSummary | None=None) -> None: if self.selected_profile_id is None and profile is None: return profile_id = self.selected_profile_id answer = QMessageBox.question( self, self.app.tr_text("Delete Profile"), self.app.tr_text("Delete this profile? Game files will not be removed."), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if answer == QMessageBox.StandardButton.Yes: if self.profile_manager.remove_profile(profile_id): self.selected_profile_id = None self.profile_manager.save()