diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml new file mode 100644 index 0000000..e92b90b --- /dev/null +++ b/.github/workflows/Build.yml @@ -0,0 +1,67 @@ +name: Version Change Action + +on: + push: + paths: + - version.json # Trigger on changes to version.json + +jobs: + version-release: + runs-on: windows-latest # Use Windows 10 runner + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' # Specify the Python version you need + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyqt5 requests pywin32 pyinstaller pillow # Install specific dependencies + + - name: Create actions-temp folder + run: mkdir actions-temp # Create the folder called actions-temp + + - name: Download picoBuild.py script + run: curl -L -o actions-temp/picoBuild.py https://raw.githubusercontent.com/nixietab/picodulce-build-script/refs/heads/main/picoBuild.py + + - name: Run picoBuild.py script + run: python actions-temp/picoBuild.py + + - name: Show directory structure + run: | + dir actions-temp + dir + + - name: Get version and name from version.json + id: version_info + run: | + $versionJson = Get-Content version.json | ConvertFrom-Json + echo "RELEASE_NAME=Release $($versionJson.version)" >> $env:GITHUB_ENV + echo "RELEASE_TAG=$($versionJson.version)" >> $env:GITHUB_ENV + + - name: Create GitHub Release + id: create_release + uses: actions/create-release@v1 + with: + tag_name: ${{ env.RELEASE_TAG }} + release_name: ${{ env.RELEASE_NAME }} + body: "This release was created automatically by a GitHub Action." + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: build/2hsu.exe + asset_name: 2hsu.exe + asset_content_type: application/octet-stream + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index f05b870..ed1ef6b 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,43 @@ - **Version Management**: Picodulce is designed to download and launch all available game versions, ensuring users have easy access to the latest updates as well as older versions. - **Offline and Online Support**: Whether you're connected to Microsoft or not, Picodulce ensures you can still enjoy your game by supporting both offline and online modes. -- **Integrated Mod Manager**: The latest update includes the [Marroc Mod Manager](https://github.com/nixietab/marroc), enabling users to effortlessly manage and customize their game with mods. +- **Integrated Mod Manager**: Includes the [Marroc Mod Manager](https://github.com/nixietab/marroc), enabling users to effortlessly manage and customize their game with mods and texturepacks. +- **Custom Theme Support**: Create and apply personalized themes with ease. A dedicated repository and guide are [available to help you get started.](https://github.com/nixietab/picodulce-themes) + +# Installation +If you are on windows you may be more interested in a [installer](https://github.com/nixietab/picodulce/releases/latest) + +### 1. Clone the repository + +``` git clone https://github.com/nixietab/picodulce ``` + +### 2. (Optional) Set Up a Virtual Environment +Setting up a virtual environment is recommended to avoid dependency conflicts. Picodulce relies on the path of the `picomc` project, and using a virtual environment helps prevent errors. + +Create the virtual environment: + +``` python -m venv venv ``` + +- **Linux/Mac:** + `source venv/bin/activate` +- **Windows:** + `.\\venv\\Scripts\\activate` + +### Install requirements + +Now on the venv you can install the requirements safely + +```pip install -r requirements.txt ``` + +### Running the launcher + +On the venv run it as a normal python script + +```python picodulce.py``` + +Just make sure you have Java installed for running the actual game ### About the name -The name "Picodulce" comes from a popular Argentine candy. This reflects the enjoyable and user-friendly experience that the launcher aims to provide, making game management straightforward and pleasant. +The name "Picodulce" comes from a popular Argentinian candy. This reflects the enjoyable and user-friendly experience that the launcher aims to provide, making game management straightforward and pleasant. diff --git a/picodulce.py b/picodulce.py index f36334d..bae6253 100644 --- a/picodulce.py +++ b/picodulce.py @@ -3,13 +3,14 @@ import subprocess import threading from threading import Thread import logging +import re import shutil import platform import requests import json import os import time -from PyQt5.QtWidgets import QApplication, QComboBox, QWidget, QVBoxLayout, QListWidget, QPushButton, QMessageBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QCheckBox, QTabWidget, QFrame, QSpacerItem, QSizePolicy, QMainWindow, QGridLayout, QTextEdit, QListWidget, QListWidgetItem +from PyQt5.QtWidgets import QApplication, QComboBox, QWidget, QInputDialog, QVBoxLayout, QListWidget, QPushButton, QMessageBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QCheckBox, QTabWidget, QFrame, QSpacerItem, QSizePolicy, QMainWindow, QGridLayout, QTextEdit, QListWidget, QListWidgetItem, QMenu from PyQt5.QtGui import QFont, QIcon, QColor, QPalette, QMovie, QPixmap, QDesktopServices, QBrush from PyQt5.QtCore import Qt, QObject, pyqtSignal, QThread, QUrl, QMetaObject, Q_ARG, QByteArray, QSize from datetime import datetime @@ -22,10 +23,8 @@ class PicomcVersionSelector(QWidget): self.open_dialogs = [] self.check_config_file() self.themes_integrity() - # Specify the path to the themes directory themes_folder = "themes" - # Default theme file name (can be changed) theme_file = self.config.get("Theme", "Dark.json") # Ensure the theme file exists in the themes directory @@ -50,8 +49,15 @@ class PicomcVersionSelector(QWidget): discord_rcp_thread.start() def load_theme_from_file(self, file_path, app): + self.theme = {} + # Check if the file exists, else load 'Dark.json' if not os.path.exists(file_path): - raise FileNotFoundError(f"Theme file '{file_path}' not found.") + print(f"Theme file '{file_path}' not found. Loading default 'Dark.json' instead.") + file_path = "themes/Dark.json" + + # Ensure the fallback file exists + if not os.path.exists(file_path): + raise FileNotFoundError(f"Default theme file '{file_path}' not found.") # Open and parse the JSON file with open(file_path, "r") as file: @@ -81,7 +87,7 @@ class PicomcVersionSelector(QWidget): "BrightText": QPalette.BrightText, "Link": QPalette.Link, "Highlight": QPalette.Highlight, - "HighlightedText": QPalette.HighlightedText + "HighlightedText": QPalette.HighlightedText, } # Apply colors from the palette config @@ -94,10 +100,18 @@ class PicomcVersionSelector(QWidget): # Apply the palette to the application app.setPalette(palette) + # Apply style sheet if present + if "stylesheet" in self.theme: + stylesheet = self.theme["stylesheet"] + app.setStyleSheet(stylesheet) + else: + print("Theme dosn't seem to have a stylesheet") + def themes_integrity(self): # Define folder and file paths themes_folder = "themes" dark_theme_file = os.path.join(themes_folder, "Dark.json") + native_theme_file = os.path.join(themes_folder, "Native.json") # Define the default content for Dark.json dark_theme_content = { @@ -125,12 +139,21 @@ class PicomcVersionSelector(QWidget): "background_image_base64": "" } + # Define the default content for Native.json + native_theme_content = { + "manifest": { + "name": "Native", + "description": "The native looks of your OS", + "author": "Your Qt Style", + "license": "Any" + }, + "palette": {} + } + # Step 1: Ensure the themes folder exists if not os.path.exists(themes_folder): print(f"Creating folder: {themes_folder}") os.makedirs(themes_folder) - else: - print(f"Folder already exists: {themes_folder}") # Step 2: Ensure Dark.json exists if not os.path.isfile(dark_theme_file): @@ -138,8 +161,24 @@ class PicomcVersionSelector(QWidget): with open(dark_theme_file, "w", encoding="utf-8") as file: json.dump(dark_theme_content, file, indent=2) print("Dark.json has been created successfully.") - else: - print(f"File already exists: {dark_theme_file}") + + # Step 3: Ensure Native.json exists + if not os.path.isfile(native_theme_file): + print(f"Creating file: {native_theme_file}") + with open(native_theme_file, "w", encoding="utf-8") as file: + json.dump(native_theme_content, file, indent=2) + print("Native.json has been created successfully.") + + # Check if both files exist and print OK message + if os.path.isfile(dark_theme_file) and os.path.isfile(native_theme_file): + print("Theme Integrity OK") + + + def resize_event(self, event): + if hasattr(self, 'movie_label'): + self.movie_label.setGeometry(0, 0, self.width(), self.height()) + event.accept() # Accept the resize event + def init_ui(self): self.setWindowTitle('PicoDulce Launcher') # Change window title @@ -156,27 +195,44 @@ class PicomcVersionSelector(QWidget): with open("config.json", "r") as config_file: config = json.load(config_file) - if config.get("ThemeBackground", False): # Default to False if ThemeBackground is missing + if self.config.get("ThemeBackground", False): # Default to False if ThemeBackground is missing # Get the base64 string for the background image from the theme file theme_background_base64 = self.theme.get("background_image_base64", "") if theme_background_base64: try: - # Decode the base64 string and create a QPixmap + # Decode the base64 string to get the binary data background_image_data = QByteArray.fromBase64(theme_background_base64.encode()) - pixmap = QPixmap() - if pixmap.loadFromData(background_image_data): + temp_gif_path = "temp.gif" # Write the gif into a temp file because Qt stuff + with open(temp_gif_path, 'wb') as temp_gif_file: + temp_gif_file.write(background_image_data) + + # Create a QMovie object from the temporary file + movie = QMovie(temp_gif_path) + if movie.isValid(): self.setAutoFillBackground(True) palette = self.palette() - palette.setBrush(QPalette.Window, QBrush(pixmap.scaled( - self.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation - ))) + + # Set the QMovie to a QLabel + self.movie_label = QLabel(self) + self.movie_label.setMovie(movie) + self.movie_label.setGeometry(0, 0, movie.frameRect().width(), movie.frameRect().height()) + self.movie_label.setScaledContents(True) # Ensure the QLabel scales its contents + movie.start() + + # Use the QLabel pixmap as the brush texture + brush = QBrush(QPixmap(movie.currentPixmap())) + brush.setStyle(Qt.TexturePattern) + palette.setBrush(QPalette.Window, brush) self.setPalette(palette) + + # Adjust the QLabel size when the window is resized + self.movie_label.resizeEvent = self.resize_event else: - print("Error: Failed to load background image from base64 string.") + print("Error: Failed to load background GIF from base64 string.") except Exception as e: - print(f"Error: Failed to decode and set background image. {e}") + print(f"Error: Failed to decode and set background GIF. {e}") else: - print("No background image base64 string found in the theme file.") + print("No background GIF base64 string found in the theme file.") # Create title label title_label = QLabel('PicoDulce Launcher') # Change label text @@ -194,7 +250,6 @@ class PicomcVersionSelector(QWidget): # Create play button for installed versions self.play_button = QPushButton('Play') - self.play_button.setFocusPolicy(Qt.NoFocus) # Set focus policy to prevent highlighting self.play_button.clicked.connect(self.play_instance) highlight_color = self.palette().color(QPalette.Highlight) self.play_button.setStyleSheet(f"background-color: {highlight_color.name()}; color: white;") @@ -263,6 +318,7 @@ class PicomcVersionSelector(QWidget): "IsRCPenabled": False, "CheckUpdate": False, "LastPlayed": "", + "Instance": "default", "Theme": "Dark.json", "ThemeBackground": True, "ThemeRepository": "https://raw.githubusercontent.com/nixietab/picodulce-themes/main/repo.json" @@ -351,7 +407,7 @@ class PicomcVersionSelector(QWidget): theme_background_checkbox.setChecked(self.config.get("ThemeBackground", False)) # Label to show currently selected theme - theme_filename = self.config.get('Theme', 'Default.json') + theme_filename = self.config.get('Theme', 'Dark.json') current_theme_label = QLabel(f"Current Theme: {theme_filename}") # QListWidget to display available themes @@ -361,41 +417,13 @@ class PicomcVersionSelector(QWidget): # Track selected theme self.selected_theme = theme_filename # Default to current theme - # Path to themes folder - themes_folder = os.path.join(os.getcwd(), "themes") - - def populate_themes(): - json_files_list_widget.clear() - if os.path.exists(themes_folder): - json_files = [f for f in os.listdir(themes_folder) if f.endswith('.json')] - for json_file in json_files: - json_path = os.path.join(themes_folder, json_file) - with open(json_path, 'r') as file: - theme_data = json.load(file) - - # Get manifest details - manifest = theme_data.get("manifest", {}) - name = manifest.get("name", "Unnamed") - description = manifest.get("description", "No description available") - author = manifest.get("author", "Unknown") - - # Create display text and list item - display_text = f"#{name}\n{description}\nBy: {author}" - list_item = QListWidgetItem(display_text) - list_item.setData(Qt.UserRole, json_file) # Store the JSON filename as metadata - json_files_list_widget.addItem(list_item) - - # Initially populate themes - populate_themes() + # Populate themes initially + self.populate_themes(json_files_list_widget) # Update current theme label when a theme is selected - def on_theme_selected(): - selected_item = json_files_list_widget.currentItem() - if selected_item: - self.selected_theme = selected_item.data(Qt.UserRole) - current_theme_label.setText(f"Current Theme: {self.selected_theme}") - - json_files_list_widget.itemClicked.connect(on_theme_selected) + json_files_list_widget.itemClicked.connect( + lambda: self.on_theme_selected(json_files_list_widget, current_theme_label) + ) # Add widgets to the layout customization_layout.addWidget(theme_background_checkbox) @@ -434,53 +462,100 @@ class PicomcVersionSelector(QWidget): dialog.setLayout(main_layout) dialog.exec_() + def populate_themes(self, json_files_list_widget): + themes_folder = os.path.join(os.getcwd(), "themes") + json_files_list_widget.clear() + if os.path.exists(themes_folder): + json_files = [f for f in os.listdir(themes_folder) if f.endswith('.json')] + for json_file in json_files: + json_path = os.path.join(themes_folder, json_file) + with open(json_path, 'r') as file: + theme_data = json.load(file) + + # Get manifest details + manifest = theme_data.get("manifest", {}) + name = manifest.get("name", "Unnamed") + description = manifest.get("description", "No description available") + author = manifest.get("author", "Unknown") + + # Create display text and list item + display_text = f"{name}\n{description}\nBy: {author}" + list_item = QListWidgetItem(display_text) + list_item.setData(Qt.UserRole, json_file) # Store the JSON filename as metadata + + # Style the name in bold + font = QFont() + font.setBold(False) + list_item.setFont(font) + + json_files_list_widget.addItem(list_item) + + # Apply spacing and styling to the list + json_files_list_widget.setStyleSheet(""" + QListWidget { + padding: 1px; + } + QListWidget::item { + margin: 3px 0; + padding: 3px; + } + """) + + def on_theme_selected(self, json_files_list_widget, current_theme_label): + selected_item = json_files_list_widget.currentItem() + if selected_item: + self.selected_theme = selected_item.data(Qt.UserRole) + current_theme_label.setText(f"Current Theme: {self.selected_theme}") + ## REPOSITORY BLOCK BEGGINS def download_themes_window(self): - # Create a QDialog to open the themes window dialog = QDialog(self) dialog.setWindowTitle("Themes Repository") - dialog.setGeometry(100, 100, 400, 300) + dialog.setGeometry(100, 100, 800, 600) - # Layout setup for the new window - layout = QVBoxLayout() + main_layout = QHBoxLayout(dialog) - # List widget to display themes self.theme_list = QListWidget(dialog) self.theme_list.setSelectionMode(QListWidget.SingleSelection) self.theme_list.clicked.connect(self.on_theme_click) - layout.addWidget(self.theme_list) + main_layout.addWidget(self.theme_list) - # Label to display the details of the selected theme - self.details_label = QLabel(dialog) # Define the label here - layout.addWidget(self.details_label) + right_layout = QVBoxLayout() + + self.details_label = QLabel(dialog) + self.details_label.setWordWrap(True) + self.details_label.setStyleSheet("padding: 10px;") + right_layout.addWidget(self.details_label) + + self.image_label = QLabel(dialog) + self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setStyleSheet("padding: 10px;") + right_layout.addWidget(self.image_label) - # Download button to download the selected theme's JSON download_button = QPushButton("Download Theme", dialog) download_button.clicked.connect(self.theme_download) - layout.addWidget(download_button) + right_layout.addWidget(download_button) - dialog.setLayout(layout) + # Add a spacer to push the button to the bottom + spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) + right_layout.addItem(spacer) + + main_layout.addLayout(right_layout) + dialog.setLayout(main_layout) - # Initially load themes into the list self.load_themes() - - dialog.exec_() # Open the dialog as a modal window + dialog.exec_() def fetch_themes(self): try: - # Read the config.json file with open("config.json", "r") as config_file: config = json.load(config_file) - - # Get the ThemeRepository value url = config.get("ThemeRepository") if not url: raise ValueError("ThemeRepository is not defined in config.json") - - # Fetch themes from the specified URL response = requests.get(url) - response.raise_for_status() # Raise an exception for HTTP errors + response.raise_for_status() return response.json() except (FileNotFoundError, json.JSONDecodeError) as config_error: self.show_error_popup("Error reading configuration", f"An error occurred while reading config.json: {config_error}") @@ -495,13 +570,9 @@ class PicomcVersionSelector(QWidget): def download_theme_json(self, theme_url, theme_name): try: response = requests.get(theme_url) - response.raise_for_status() # Raise an exception for HTTP errors - - # Create the themes directory if it doesn't exist + response.raise_for_status() if not os.path.exists('themes'): os.makedirs('themes') - - # Save the content of the theme JSON file to the 'themes' folder theme_filename = os.path.join('themes', f'{theme_name}.json') with open(theme_filename, 'wb') as f: f.write(response.content) @@ -520,17 +591,25 @@ class PicomcVersionSelector(QWidget): return os.path.exists(os.path.join('themes', f'{theme_name}.json')) def load_themes(self): - theme_list = self.theme_list themes_data = self.fetch_themes() themes = themes_data.get("themes", []) - - theme_list.clear() + installed_themes = [] + uninstalled_themes = [] for theme in themes: - # Add "[I]" if the theme is installed theme_display_name = f"{theme['name']} by {theme['author']}" if self.is_theme_installed(theme['name']): - theme_display_name += " [I]" # Mark installed themes - theme_list.addItem(theme_display_name) + theme_display_name += " [I]" + installed_themes.append(theme_display_name) + else: + uninstalled_themes.append(theme_display_name) + self.theme_list.clear() + self.theme_list.addItems(uninstalled_themes) + self.theme_list.addItems(installed_themes) + + # Autoselect the first item in the list if it exists + if self.theme_list.count() > 0: + self.theme_list.setCurrentRow(0) + self.on_theme_click() def on_theme_click(self): selected_item = self.theme_list.currentItem() @@ -538,14 +617,33 @@ class PicomcVersionSelector(QWidget): theme_name = selected_item.text().split(" by ")[0] theme = self.find_theme_by_name(theme_name) if theme: - # Display theme details in the QLabel (details_label) self.details_label.setText( - f"Name: {theme['name']}\n" - f"Description: {theme['description']}\n" - f"Author: {theme['author']}\n" - f"License: {theme['license']}\n" - f"Link: {theme['link']}" + f"Name: {theme['name']}
" + f"Description: {theme['description']}
" + f"Author: {theme['author']}
" + f"License: {theme['license']}
" + f"Link: {theme['link']}
" ) + self.details_label.setTextFormat(Qt.RichText) + self.details_label.setOpenExternalLinks(True) + preview = theme.get('preview') + if preview: + image_data = self.fetch_image(preview) + if image_data: + pixmap = QPixmap() + pixmap.loadFromData(image_data) + self.image_label.setPixmap(pixmap) + else: + self.image_label.clear() + + def fetch_image(self, url): + try: + response = requests.get(url) + response.raise_for_status() + return response.content + except requests.exceptions.RequestException as e: + self.show_error_popup("Error fetching image", f"An error occurred while fetching the image: {e}") + return None def find_theme_by_name(self, theme_name): themes_data = self.fetch_themes() @@ -563,10 +661,7 @@ class PicomcVersionSelector(QWidget): if theme: theme_url = theme["link"] self.download_theme_json(theme_url, theme_name) - self.load_themes() # Reload the list to show the "[I]" marker - - - + self.load_themes() ## REPOSITORY BLOCK ENDS @@ -705,7 +800,20 @@ class PicomcVersionSelector(QWidget): self.installed_version_combo.addItems(versions) def populate_installed_versions_normal_order(self): - # Run the command and get the output + # Run the 'picomc instance create default' command at the start + try: + process = subprocess.Popen(['picomc', 'instance', 'create', 'default'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + output, error = process.communicate() + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, process.args, error) + except FileNotFoundError: + logging.error("'picomc' command not found. Please make sure it's installed and in your PATH.") + return + except subprocess.CalledProcessError as e: + logging.error("Error creating default instance: %s", e.stderr) + return + + # Run the 'picomc version list' command and get the output try: process = subprocess.Popen(['picomc', 'version', 'list'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) output, error = process.communicate() @@ -767,12 +875,18 @@ class PicomcVersionSelector(QWidget): try: # Set current_state to the selected instance self.current_state = selected_instance + + # Read the config.json to get the "Instance" value + with open('config.json', 'r') as config_file: + config = json.load(config_file) + instance_value = config.get("Instance", "default") # Default to "default" if not found + # Update lastplayed field in config.json on a separate thread update_thread = threading.Thread(target=self.update_last_played, args=(selected_instance,)) update_thread.start() - # Run the game subprocess - subprocess.run(['picomc', 'play', selected_instance], check=True) + # Run the game subprocess with the instance_value from config.json + subprocess.run(['picomc', 'instance', 'launch', '--version-override', selected_instance, instance_value], check=True) except subprocess.CalledProcessError as e: error_message = f"Error playing {selected_instance}: {e}" @@ -785,7 +899,7 @@ class PicomcVersionSelector(QWidget): finally: # Reset current_state to "menu" after the game closes self.current_state = "menu" - + def update_last_played(self, selected_instance): config_path = "config.json" self.config["LastPlayed"] = selected_instance @@ -870,6 +984,38 @@ class PicomcVersionSelector(QWidget): dialog.exec_() self.open_dialogs.remove(dialog) + def create_account(self, dialog, username, is_microsoft): + # Remove leading and trailing spaces from the username + username = username.strip() + + if not username: + QMessageBox.warning(dialog, "Warning", "Username cannot be blank.") + return + + if not self.is_valid_username(username): + QMessageBox.warning(dialog, "Warning", "Invalid username. Usernames must be 3-16 characters long and can only contain letters, numbers, and underscores.") + return + + try: + command = ['picomc', 'account', 'create', username] + if is_microsoft: + command.append('--ms') + + subprocess.run(command, check=True) + QMessageBox.information(dialog, "Success", f"Account '{username}' created successfully!") + self.populate_accounts_for_all_dialogs() + dialog.accept() + except subprocess.CalledProcessError as e: + error_message = f"Error creating account: {e.stderr.decode()}" + logging.error(error_message) + QMessageBox.critical(dialog, "Error", error_message) + + def is_valid_username(self, username): + # Validate the username according to Minecraft's rules + if 3 <= len(username) <= 16 and re.match(r'^[a-zA-Z0-9_]+$', username): + return True + return False + def authenticate_account(self, dialog, account_name): # Authenticate a selected account account_name = account_name.strip().lstrip(" * ") @@ -904,24 +1050,6 @@ class PicomcVersionSelector(QWidget): logging.error(error_message) QMessageBox.critical(dialog, "Error", error_message) - def create_account(self, dialog, username, is_microsoft): - # Create a new account - if not username.strip(): - QMessageBox.warning(dialog, "Warning", "Username cannot be blank.") - return - - try: - command = ['picomc', 'account', 'create', username] - if is_microsoft: - command.append('--ms') - - subprocess.run(command, check=True) - QMessageBox.information(dialog, "Success", f"Account '{username}' created successfully!") - self.populate_accounts_for_all_dialogs() - except subprocess.CalledProcessError as e: - error_message = f"Error creating account: {e.stderr.decode()}" - logging.error(error_message) - QMessageBox.critical(dialog, "Error", error_message) def populate_accounts(self, account_combo): # Populate the account dropdown @@ -1177,9 +1305,11 @@ class ModLoaderAndVersionMenu(QDialog): # Create tabs install_mod_tab = QWidget() download_version_tab = QWidget() + instances_tab = QWidget() # New tab for instances tab_widget.addTab(download_version_tab, "Download Version") tab_widget.addTab(install_mod_tab, "Install Mod Loader") + tab_widget.addTab(instances_tab, "Instances") # Add the new tab # Add content to "Install Mod Loader" tab self.setup_install_mod_loader_tab(install_mod_tab) @@ -1187,6 +1317,238 @@ class ModLoaderAndVersionMenu(QDialog): # Add content to "Download Version" tab self.setup_download_version_tab(download_version_tab) + # Add content to "Instances" tab + self.setup_instances_tab(instances_tab) + + + def setup_instances_tab(self, instances_tab): + layout = QVBoxLayout(instances_tab) + + # Create title label + title_label = QLabel('Manage Minecraft Instances') + title_label.setFont(QFont("Arial", 14)) + layout.addWidget(title_label) + + # Create a label to display the current instance + self.current_instance_label = QLabel('Loading...') # Placeholder text + layout.addWidget(self.current_instance_label) + + # Create a QListWidget to display the instances + self.instances_list_widget = QListWidget() + layout.addWidget(self.instances_list_widget) + + # Create input field and button to create a new instance + self.create_instance_input = QLineEdit() + self.create_instance_input.setPlaceholderText("Enter instance name") + layout.addWidget(self.create_instance_input) + + create_instance_button = QPushButton("Create Instance") + create_instance_button.clicked.connect(self.create_instance) + layout.addWidget(create_instance_button) + + # Fetch and display the current instances + self.load_instances() + + # Connect the item selection to the instance selection method + self.instances_list_widget.itemClicked.connect(self.on_instance_selected) + + # Update the label with the current instance from the config + self.update_instance_label() + + def create_instance(self): + instance_name = self.create_instance_input.text().strip() + + if instance_name: + try: + # Run the "picomc instance create" command + process = subprocess.Popen(['picomc', 'instance', 'create', instance_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + output, error = process.communicate() + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, process.args, error) + + # Notify the user that the instance was created + QMessageBox.information(self, "Instance Created", f"Instance '{instance_name}' has been created successfully.") + + # Reload the instances list + self.load_instances() + + # Optionally select the newly created instance + self.on_instance_selected(self.instances_list_widget.item(self.instances_list_widget.count() - 1)) + + except FileNotFoundError: + logging.error("'picomc' command not found. Please make sure it's installed and in your PATH.") + except subprocess.CalledProcessError as e: + logging.error("Error creating instance: %s", e.stderr) + QMessageBox.critical(self, "Error", f"Failed to create instance: {e.stderr}") + else: + QMessageBox.warning(self, "Invalid Input", "Please enter a valid instance name.") + + def rename_instance(self, old_instance_name, new_instance_name): + if old_instance_name == "default": + QMessageBox.warning(self, "Cannot Rename Instance", "You cannot rename the 'default' instance.") + return + + try: + # Run the "picomc instance rename" command + process = subprocess.Popen( + ['picomc', 'instance', 'rename', old_instance_name, new_instance_name], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + output, error = process.communicate() + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, process.args, error) + + QMessageBox.information(self, "Instance Renamed", f"Instance '{old_instance_name}' has been renamed to '{new_instance_name}' successfully.") + + # Reload the instances list + self.load_instances() + + # Optionally select the newly renamed instance + matching_items = self.instances_list_widget.findItems(new_instance_name, Qt.MatchExactly) + if matching_items: + self.instances_list_widget.setCurrentItem(matching_items[0]) + + except FileNotFoundError: + logging.error("'picomc' command not found. Please make sure it's installed and in your PATH.") + except subprocess.CalledProcessError as e: + logging.error("Error renaming instance: %s", e.stderr) + QMessageBox.critical(self, "Error", f"Failed to rename instance: {e.stderr}") + + def delete_instance(self, instance_name): + if instance_name == "default": + QMessageBox.warning(self, "Cannot Delete Instance", "You cannot delete the 'default' instance.") + return + + confirm_delete = QMessageBox.question( + self, "Confirm Deletion", f"Are you sure you want to delete the instance '{instance_name}'?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No + ) + + if confirm_delete == QMessageBox.Yes: + try: + # Run the "picomc instance delete" command + process = subprocess.Popen(['picomc', 'instance', 'delete', instance_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + output, error = process.communicate() + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, process.args, error) + + # Notify the user that the instance was deleted + QMessageBox.information(self, "Instance Deleted", f"Instance '{instance_name}' has been deleted successfully.") + + # Reload the instances list + self.load_instances() + + except FileNotFoundError: + logging.error("'picomc' command not found. Please make sure it's installed and in your PATH.") + except subprocess.CalledProcessError as e: + logging.error("Error deleting instance: %s", e.stderr) + QMessageBox.critical(self, "Error", f"Failed to delete instance: {e.stderr}") + + def load_instances(self): + try: + process = subprocess.Popen(['picomc', 'instance', 'list'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + output, error = process.communicate() + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, process.args, error) + + # Parse the output and add each instance to the list widget + instances = output.splitlines() + self.instances_list_widget.clear() # Clear the previous list + for instance in instances: + item = QListWidgetItem() + self.instances_list_widget.addItem(item) + self.add_instance_buttons(item, instance) + + except FileNotFoundError: + logging.error("'picomc' command not found. Please make sure it's installed and in your PATH.") + except subprocess.CalledProcessError as e: + logging.error("Error fetching instances: %s", e.stderr) + + def add_instance_buttons(self, list_item, instance_name): + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + + label = QLabel(instance_name) + rename_button = QPushButton("Rename") + delete_button = QPushButton("Delete") + + # Stylize the buttons + button_style = """ + QPushButton { + padding: 2px 5px; + } + """ + rename_button.setStyleSheet(button_style) + delete_button.setStyleSheet(button_style) + + layout.addWidget(label) + layout.addStretch() + layout.addWidget(rename_button) + layout.addWidget(delete_button) + + widget.setLayout(layout) + list_item.setSizeHint(widget.sizeHint()) + self.instances_list_widget.setItemWidget(list_item, widget) + + # Connect button signals + rename_button.clicked.connect(lambda: self.prompt_rename_instance(instance_name)) + delete_button.clicked.connect(lambda: self.delete_instance(instance_name)) + + def prompt_rename_instance(self, old_instance_name): + new_instance_name, ok = QInputDialog.getText( + self, "Rename Instance", + f"Enter new name for instance '{old_instance_name}':" + ) + + if ok and new_instance_name: + self.rename_instance(old_instance_name, new_instance_name) + + def on_instance_selected(self, item): + widget = self.instances_list_widget.itemWidget(item) + instance_name = widget.findChild(QLabel).text() + + config_file = 'config.json' + + if os.path.exists(config_file): + try: + with open(config_file, 'r') as file: + config_data = json.load(file) + + config_data['Instance'] = instance_name + + with open(config_file, 'w') as file: + json.dump(config_data, file, indent=4) + + logging.info(f"Config updated: Instance set to {instance_name}") + + self.update_instance_label() + + except (json.JSONDecodeError, FileNotFoundError) as e: + logging.error(f"Error reading config.json: {e}") + else: + logging.warning(f"{config_file} not found. Unable to update instance.") + + def update_instance_label(self): + config_file = 'config.json' + + if os.path.exists(config_file): + try: + with open(config_file, 'r') as file: + config_data = json.load(file) + + current_instance = config_data.get('Instance', 'Not set') + self.current_instance_label.setText(f'Current instance: {current_instance}') + + except (json.JSONDecodeError, FileNotFoundError) as e: + logging.error(f"Error reading config.json: {e}") + else: + self.current_instance_label.setText('Current instance: Not set') + + def setup_install_mod_loader_tab(self, install_mod_tab): layout = QVBoxLayout(install_mod_tab) diff --git a/version.json b/version.json index 13bde8c..3a8dd18 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "0.11", + "version": "0.11.7", "links": [ "https://raw.githubusercontent.com/nixietab/picodulce/main/version.json", "https://raw.githubusercontent.com/nixietab/picodulce/main/picodulce.py",