import threading from collections.abc import Callable from pathlib import Path from PySide6.QtCore import QSize, Qt, QObject, Signal, Slot from PySide6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QListWidget, QPushButton, QLabel, QHBoxLayout, \ QSizePolicy, QListWidgetItem, QInputDialog from core_lib.common.exception import VersionManifestFetchException, VersionManifestException from core_lib.game.mod import fabric from core_lib.game.mod.mod_core import find_useful_part_in_mod_version_manifest from core_lib.game.version_info import get_all_versions, get_available_version_types from manager.game import ManifestManager, GameManager from manager.profile import CreateProfileRequest from manager.widgets import SignalTransferItem, WidgetManager class CreateProfilePage(QWidget): def __init__(self, app, parent=None): super(CreateProfilePage, self).__init__(parent) self.app = app # Manager self.widget_mgr: WidgetManager = app.widget_manager self.game_mgr: GameManager = app.game_manager self.manifest_mgr: ManifestManager = self.game_mgr.manifest # Layout self.layout = QVBoxLayout() self.content_layout = QHBoxLayout() self.main_layout = QVBoxLayout() self.right_layout = QVBoxLayout() self.operate_layout = QHBoxLayout() # Some items self.version_type_label = QLabel("Version Type") self.version_type_dropdown = QComboBox() self.version_type_dropdown.setObjectName("Dropdown") # Mod loader self.mod_loader_icon_label = QLabel() icon = self.app.get_icon(Path(self.app.icon_dir, "mod_loader_temp.png")) pixmap = icon.pixmap(QSize(100, 100)) self.mod_loader_icon_label.setPixmap(pixmap) self.mod_loader_label = QLabel("Mod Loader") self.mod_loader_dropdown = QComboBox() self.mod_loader_dropdown.setObjectName("Dropdown") # Mod loader layout self.mod_config_layout = QHBoxLayout() self.mod_config_fields_layout = QVBoxLayout() self.mod_loader_version_label = QLabel( "Mod Loader Version" ) self.mod_loader_version_list = QListWidget() self.mod_loader_version_list.setObjectName( "ListWidget" ) # Version list self.version_list_type_label = QLabel("Version: Vanilla") self.version_list_label = QLabel("Select a version to continue:") self.version_list = QListWidget() self.version_list.setObjectName("ListWidget") # Button self.create_profile_button = QPushButton("Create Profile") self.create_profile_button.setObjectName("Button") self.refresh_button = QPushButton("Refresh") self.refresh_button.setObjectName("Button") # Bind widget self.main_layout.addWidget(self.version_list_label) self.main_layout.addWidget(self.version_list) self.operate_layout.addWidget(self.version_list_type_label) self.operate_layout.addStretch() self.operate_layout.addWidget(self.refresh_button) self.operate_layout.addWidget(self.create_profile_button) self.mod_config_fields_layout.addWidget( self.mod_loader_label ) self.mod_config_fields_layout.addWidget( self.mod_loader_dropdown ) self.mod_config_fields_layout.addWidget( self.version_type_label ) self.mod_config_fields_layout.addWidget( self.version_type_dropdown ) self.mod_config_fields_layout.addStretch() self.mod_config_layout.addWidget( self.mod_loader_icon_label, alignment=Qt.AlignmentFlag.AlignTop, ) self.mod_config_layout.addLayout( self.mod_config_fields_layout ) self.right_layout.addLayout( self.mod_config_layout ) self.right_layout.addWidget(self.mod_loader_version_label) self.right_layout.addWidget( self.mod_loader_version_list, 1, ) self.mod_loader_version_list.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding, ) self.right_layout.setContentsMargins(0, 20, 0, 0) self.setLayout(self.layout) self.content_layout.addLayout( self.main_layout, stretch=2, ) self.content_layout.addLayout( self.right_layout, stretch=1, ) self.layout.addLayout( self.content_layout, stretch=1, ) self.layout.addLayout( self.operate_layout, ) # Signals self.signals = self.PageSignal() self.signals.get_selected_item.connect(self.get_selected_item) self.signals.update_list.connect(self.update_list) self.signals.clean_list.connect(self.clean_list) # Button event self.create_profile_button.clicked.connect(self.create_profile) # Update version list while current type is changed self.version_type_dropdown.currentTextChanged.connect( self.start_load_version_list ) self.version_list.currentTextChanged.connect( self.start_load_mod_loader_version ) self.mod_loader_dropdown.currentTextChanged.connect( self.start_load_mod_loader_version ) self.mod_loader_dropdown.currentTextChanged.connect( self.update_mod_loader_icon ) self.loader_list = { "Don't Install": { "type": "vanilla", "icon": Path(self.app.icon_dir, "vanilla.png"), "loadVersionMethod": self.load_vanilla_version_list, "profileCreateHandler": self.handle_vanilla_profile_create, }, "Fabric": { "type": "modded", "icon": Path(self.app.icon_dir, "modLoader", "fabric.png"), "loadVersionMethod": getattr(self, "load_fabric_version_list", None), "profileCreateHandler": self.handle_fabric_profile_create, }, "NeoForge": { "type": "modded", "icon": Path(self.app.icon_dir, "modLoader", "neoforged.ico"), "loadVersionMethod": getattr(self, "load_neoforge_version_list", None), "profileCreateHandler": self.handle_neoforge_profile_create, } } self.init_all() class PageSignal(QObject): mod_loader_dropdown_changed = Signal(str) load_version_list = Signal() # widget update_list = Signal(object) clean_list = Signal(object) update_mod_loader_icon_label = Signal(object) get_selected_item = Signal(object) # # Signal Handler # class UpdateListRequest(object): def __init__(self, target_list: QListWidget | QComboBox, items: list[str], clean: bool=True, current_index: int | None = None, custom_handler: Callable = None, block_signals: bool = False, complete_callback: Callable = None): self.target_list = target_list self.items: list[str | dict] = items self.clean = clean self.current_index = current_index self.custom_handler = custom_handler self.block_signals = block_signals self.complete_callback = complete_callback @Slot(object) def update_list(self, request: UpdateListRequest): target_list = request.target_list items = request.items if not isinstance(target_list, QListWidget | QComboBox): raise TypeError( "Target list must be a QListWidget or QComboBox." ) if request.block_signals: target_list.blockSignals(True) # Handle update try: if request.clean: target_list.clear() if callable(request.custom_handler): handler = request.custom_handler handler(request) else: target_list.addItems(items) index = request.current_index if index is None: return # Check and set current index if 0 <= request.current_index < target_list.count(): if isinstance(target_list, QComboBox): target_list.setCurrentIndex(index) elif isinstance(target_list, QListWidget): target_list.setCurrentRow(index) else: self.app.logger.warning( "Target list index out of range. Current index is {}.".format( request.current_index ) ) finally: if request.block_signals: target_list.blockSignals(False) if callable(request.complete_callback): request.complete_callback() @Slot(str) def update_mod_loader_icon(self, loader_name: str): loader = self.loader_list.get(loader_name) if not loader: self.app.logger.warning( "Mod Loader {} not found".format(loader_name) ) return icon_path = loader.get("icon") if not isinstance(icon_path, Path): self.app.logger.warning( "Mod Loader {} icon path not found or is not Path type".format(loader_name) ) return pixmap = self.app.get_picture( icon_path, QSize(100, 100), ) pixmap = pixmap.scaled( QSize(100, 100), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) self.mod_loader_icon_label.setPixmap(pixmap) @staticmethod def clean_list(target_list: QListWidget | QComboBox): if not isinstance(target_list, QListWidget | QComboBox): raise TypeError("Target list is not a QListWidget") target_list.clear() class GetSelectedItem(SignalTransferItem): def __init__(self, target_list: QListWidget | QComboBox): SignalTransferItem.__init__(self) self.target_list = target_list class DisplayListItem(QListWidgetItem): def __init__(self, parent: QListWidget=None, display_text: str=None, raw_value: str=None): QListWidgetItem.__init__(self, parent) self.display_text = display_text self.raw_value = raw_value self.setText(self.display_text) @Slot(object) def get_selected_item(self, request: GetSelectedItem): if not isinstance(request, self.GetSelectedItem): raise TypeError("Item is not a GetSelectedItem") target = request.target_list if isinstance(target, QComboBox): selected_text = target.currentText() elif isinstance(target, QListWidget): item = target.currentItem() if isinstance(item, self.DisplayListItem): selected_text = item.raw_value else: selected_text = None else: raise TypeError("Target list is not a QListWidget or QComboBox") if not selected_text: request.result_queue.put(None) else: request.result_queue.put(selected_text) request.wait_event.set() def update_type_dropdown(self, types: list): # Move release to top if "release" in types: types.pop(types.index("release")) types.insert(0, "release") self.version_type_dropdown.addItems(types) if len(self.version_type_dropdown.currentText()) == 0: self.version_type_dropdown.setCurrentIndex(0) def update_version_list(self, request: UpdateListRequest): version_list = request.target_list if not isinstance(version_list, QListWidget | QComboBox): raise TypeError("Target list is not a QListWidget or QComboBox") for version in request.items: ver_id = version.get("id", None) or version.get("version", None) stable = version.get("stable", None) if not ver_id: self.app.logger.warning("Provided version {} missing ID.".format(version)) display_text = ver_id if stable is not None and isinstance(stable, bool): display_text = f"{ver_id}"+(" (stable)" if stable else "") item = self.DisplayListItem( parent=request.target_list, display_text=display_text, raw_value=ver_id, ) version_list.addItem(item) def update_mod_loader_list(self, loaders: dict): for loader_name, value in loaders.items(): self.mod_loader_version_list.addItem(loader_name) # # Thread Starter # def init_all(self): self.start_load_type_list() self.start_load_mod_loader_list() def start_load_type_list(self): thread = threading.Thread(target=self.load_type_list) thread.start() def start_load_version_list(self): thread = threading.Thread(target=self.load_version_list) thread.start() def start_load_mod_loader_list(self): thread = threading.Thread(target=self.load_mod_loader_list) thread.start() def start_load_mod_loader_version(self): thread = threading.Thread(target=self.load_mod_loader_version) thread.start() # # Processor # def create_profile(self): request = self.GetSelectedItem( target_list=self.version_list, ) self.signals.get_selected_item.emit(request) if not request.wait(10): self.widget_mgr.signal.show_error_message.emit( "Timeout while waiting for selected version.", ) return minecraft_version = request.result() if not minecraft_version: self.widget_mgr.signal.show_warning_message.emit( "Selected a version before creating profile.", ) return loader_request = self.GetSelectedItem( target_list=self.mod_loader_dropdown, ) self.signals.get_selected_item.emit(loader_request) if not loader_request.wait(10): self.widget_mgr.signal.show_error_message.emit( "Timeout while waiting for selected mod loader.", ) return loader_name = loader_request.result() if loader_name not in self.loader_list: self.widget_mgr.signal.show_error_message.emit( "Loader {} not found.".format(loader_name), ) return loader = self.loader_list[loader_name] profile_create_handler: Callable[[str, str, str | None], None] = loader.get("profileCreateHandler", None) if profile_create_handler is None: self.widget_mgr.signal.show_warning_message.emit( "Profile creation for loader {} is not implemented.".format(loader_name), ) return loader_version_request = self.GetSelectedItem( target_list=self.mod_loader_version_list, ) self.signals.get_selected_item.emit(loader_version_request) if not loader_version_request.wait(10): self.widget_mgr.signal.show_error_message.emit( "Timeout while waiting for selected mod loader version.", ) return loader_version = loader_version_request.result() if not loader_version and loader_version is not None: loader_version = None profile_name, accepted = QInputDialog.getText( self, "Create Profile", "Profile name:", text=f"Minecraft {minecraft_version}", ) if not accepted: return profile_name = profile_name.strip() if not profile_name: self.widget_mgr.signal.show_warning_message.emit( "Profile name cannot be empty.", ) return # Call handler if callable(profile_create_handler): profile_create_handler(profile_name, minecraft_version, loader_version) def handle_vanilla_profile_create(self, profile_name: str, version: str, loader_version: None=None, switch_to_current=False): """ Handle vanilla profile creation. :param profile_name: :param version: :param loader_version: :param switch_to_current: Switch new profile to current profile. :return: """ manifest = self.manifest_mgr.get_cached_version_manifest(version) if not manifest: self.widget_mgr.signal.show_warning_message.emit( "Unable to create profile due to manifest fetch failure.", ) return version_id: str = manifest.get("id") version_type = manifest.get( "type", self.version_type_dropdown.currentText() or "custom", ) if version_id is None: self.widget_mgr.signal.show_error_message.emit( "Unable to create profile due to version ID is missing in manifest.", ) return request = CreateProfileRequest( name=profile_name, version_id=version_id, version_type=version_type, ) # Switch to current profile and save profile_id = self.app.profile_manager.create_profile(request) if profile_id is None: return if switch_to_current: if not self.app.profile_manager.set_current_profile(profile_id): return if not self.app.profile_manager.save(): return self.widget_mgr.signal.show_info_message.emit( f"Profile {profile_id} successfully created.", ) def handle_fabric_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None, switch_to_current=False): """ Handle fabric profile creation. :param profile_name: :param minecraft_version: :param loader_version: :param switch_to_current: :return: """ if not loader_version: self.widget_mgr.signal.show_warning_message.emit( "Selected a fabric loader version before creating profile.", ) return # Create vanilla manifest vanilla_manifest = self.manifest_mgr.get_cached_version_manifest( minecraft_version ) if not vanilla_manifest: self.widget_mgr.signal.show_error_message.emit( "Unable to create profile due to vanilla manifest fetch failure.", ) return version_id = fabric.get_full_version_string(version=minecraft_version, loader_version=loader_version) def fetch_loader_manifest(_version_id: str, ) -> tuple[dict, None]: manifest = fabric.get_loader_manifest_from_loader_data( minecraft_version, {"version": loader_version}, ) return manifest, None loader_manifest = self.manifest_mgr.get_cached_custom_version_manifest( version_id, fetch_manifest_handler=fetch_loader_manifest,) if not loader_manifest: self.widget_mgr.signal.show_error_message.emit( "Unable to create profile due to mod loader manifest fetch failure.", ) return loader_info = find_useful_part_in_mod_version_manifest( loader_manifest ) # Check mod info if loader_info["id"] != version_id: self.app.logger.error(f"Fabric manifest ID mismatch: expected {version_id}, got {loader_info["id"]}.") self.widget_mgr.signal.show_error_message.emit( "Unable to create profile. Exising loader version ID mismatch.", ) return if loader_info["inheritsFrom"] != minecraft_version: self.app.logger.error( f"Fabric manifest inherits from " f'{loader_info["inheritsFrom"]}, not {minecraft_version}.' ) self.widget_mgr.signal.show_error_message.emit( "Unable to create profile. Inherits version is mismatched as expected.", ) return request = CreateProfileRequest( name=profile_name, version_id=version_id, version_type=loader_info["type"] or vanilla_manifest["type"] or "custom", ) profile_id = self.app.profile_manager.create_profile( request ) if profile_id is None: return if switch_to_current: if not self.app.profile_manager.set_current_profile(profile_id): return if not self.app.profile_manager.save(): return self.widget_mgr.signal.show_info_message.emit( f"Profile {profile_id} with fabric loader successfully created.", ) def handle_forge_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None): if not loader_version: self.widget_mgr.signal.show_warning_message.emit( "Selected a forge loader version before creating profile.", ) def handle_neoforge_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None): if not loader_version: self.widget_mgr.signal.show_warning_message.emit( "Selected a neoforge loader version before creating profile.", ) # # Background Thread # def load_type_list(self): manifest = self.manifest_mgr.get_cached_version_manifest() if not manifest: return ver_types = get_available_version_types(manifest) release_index = ver_types.index("release") if "release" in ver_types else None request = self.UpdateListRequest( target_list=self.version_type_dropdown, items=ver_types, current_index=release_index, clean=True, block_signals=True, complete_callback=self.start_load_version_list, ) self.signals.update_list.emit(request) def load_version_list(self): # Get selected version type request = self.GetSelectedItem( target_list=self.version_type_dropdown ) self.signals.get_selected_item.emit(request) if not request.wait(10): self.widget_mgr.signal.show_error_message.emit( "Timeout while waiting for selected type.", ) return selected_type = request.result() if not selected_type: self.widget_mgr.signal.show_warning_message.emit( "No type selected.", ) return # Fetch (or read) manifest manifest = self.manifest_mgr.get_cached_version_manifest() if not manifest: return versions = get_all_versions( manifest, specified_type=selected_type ) # Update list request = self.UpdateListRequest( target_list=self.version_list, items=versions, clean=True, current_index=0, custom_handler=self.update_version_list ) self.signals.update_list.emit(request) def load_vanilla_version_list(self): """ A dummy port for load_mod_loader_version :return: """ self.clean_list(self.mod_loader_version_list) def load_fabric_version_list(self): self.signals.clean_list.emit(self.mod_loader_version_list) request = self.GetSelectedItem( target_list=self.version_list ) self.signals.get_selected_item.emit(request) if not request.wait(10): self.widget_mgr.signal.show_error_message.emit( "Timeout while waiting for selected version.", ) selected_version = request.result() if not selected_version: self.widget_mgr.signal.show_warning_message.emit( "No version selected.", ) try: loaders = fabric.get_version_support_loader_list( selected_version ) except (VersionManifestFetchException, VersionManifestException) as e: self.widget_mgr.signal.show_error_message.emit( "Failed to fetch version manifest: {}".format(e.user_message), ) return except Exception as e: self.widget_mgr.signal.show_error_message.emit( "An unexpected error occurred while fetching version manifest: {}".format(e), ) return request = self.UpdateListRequest( target_list=self.mod_loader_version_list, items=loaders, current_index=0, custom_handler=self.update_version_list, block_signals=True, ) self.signals.update_list.emit(request) def load_mod_loader_list(self): loaders = [loader for loader in self.loader_list.keys()] request = self.UpdateListRequest( target_list=self.mod_loader_dropdown, items=loaders, current_index=0, clean=True ) self.signals.update_list.emit(request) def load_mod_loader_version(self): request = self.GetSelectedItem( target_list=self.mod_loader_dropdown ) self.signals.get_selected_item.emit(request) if not request.wait(10): self.widget_mgr.signal.show_error_message.emit( "Timeout while waiting for selected loader.", ) return loader_name = request.result() if loader_name not in self.loader_list: raise KeyError("Target loader {} not found".format(loader_name)) loader = self.loader_list[loader_name] version_handler = loader.get("loadVersionMethod", None) if not version_handler: self.widget_mgr.signal.show_warning_message.emit( "Selected loader \"{}\" is not implemented".format(loader_name), ) return if callable(version_handler): version_handler() else: self.app.logger.warning( "Select mod loader \"loadVersionMethod\" is not callable." )