diff --git a/app.py b/app.py index 37c5430..182d5cd 100644 --- a/app.py +++ b/app.py @@ -46,7 +46,7 @@ class Launcher(QApplication, LauncherObject): self.game_manager = GameManager(self) # Window config - self.setApplicationName("TestLauncher") + self.setApplicationName("TapLauncher") self.main_window = LauncherMainWindow( app=self ) @@ -139,7 +139,8 @@ class Launcher(QApplication, LauncherObject): @property def default_game_dir(self) -> Path: - return self.work_dir / "minecraft" if sys.platform.lower() == "darwin" else self.work_dir / ".minecraft" + # Completely with official Minecraft launcher + return self.work_dir @property def resources_dir(self): diff --git a/manager/profile.py b/manager/profile.py index b1df0c3..482ed9c 100644 --- a/manager/profile.py +++ b/manager/profile.py @@ -667,13 +667,13 @@ class ProfileManager(QObject): if not isinstance(request.icon, str): errors["icon"] = "Icon must be a string." - if request.game_dir is not _UNSET: + if request.game_dir is not _UNSET and request.game_dir is not None: try: Path(request.game_dir) except (TypeError, ValueError): errors["game_dir"] = "Game directory is invalid." - if request.game_dir is not _UNSET: + if request.resolution is not _UNSET and request.resolution is not None: if not isinstance(request.resolution, tuple) or len(request.resolution) != 2: errors["resolution"] = ( "Resolution must contain width and height." diff --git a/manager/settings_models.py b/manager/settings_models.py new file mode 100644 index 0000000..c270756 --- /dev/null +++ b/manager/settings_models.py @@ -0,0 +1,26 @@ +"""Launcher settings schemas persisted by SettingsManager.""" + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from constant import CLIENT_ID + + +class GeneralSettings(BaseModel): + model_config = ConfigDict(extra="forbid") + + language: Literal["en", "zh-TW"] = "zh-TW" + + +class AppearanceSettings(BaseModel): + model_config = ConfigDict(extra="forbid") + + theme: Literal["system", "light", "dark"] = "system" + + +class AccountSettings(BaseModel): + model_config = ConfigDict(extra="forbid") + + client_id: UUID = UUID(CLIENT_ID) diff --git a/pages/manage_profile.py b/pages/manage_profile.py new file mode 100644 index 0000000..29c4d4b --- /dev/null +++ b/pages/manage_profile.py @@ -0,0 +1,312 @@ +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, +) + +from manager.profile import ProfileDetail, ProfileManager, ProfileSummary, UpdateProfileRequest + + +class ManageProfileItem(QPushButton): + selected = Signal(object) + + def __init__(self, app, profile: ProfileSummary, parent=None): + super().__init__(parent) + self.profile = profile + self.setObjectName("manageProfileItem") + self.setCheckable(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumHeight(64) + + 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)) + + +class ManageProfilePage(QWidget): + """Select a profile on the left and edit its launcher settings on the right.""" + + def __init__(self, app, parent=None): + super().__init__(parent) + self.app = app + self.profile_manager: ProfileManager = app.profile_manager + self.selected_profile_id: str | None = None + self.profile_items: dict[str, ManageProfileItem] = {} + + root = QVBoxLayout(self) + title = QLabel("Manage Profiles (Experimental)") + title.setObjectName("manageTitle") + root.addWidget(title) + + columns = QHBoxLayout() + columns.setSpacing(12) + root.addLayout(columns, 1) + + 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("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_edit = QLineEdit() + self.type_edit = QLineEdit() + 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("Profile name", self.name_edit) + form.addRow("Version ID", self.version_edit) + form.addRow("Profile type", self.type_edit) + form.addRow("Game directory", self.game_dir_edit) + form.addRow("Java arguments", self.java_args_edit) + form.addRow("Icon", self.icon_edit) + + resolution = QHBoxLayout() + resolution.addWidget(self.width_spin) + resolution.addWidget(QLabel("×")) + resolution.addWidget(self.height_spin) + form.addRow("Resolution", resolution) + editor_layout.addLayout(form) + + file_actions = QHBoxLayout() + open_button = QPushButton("Open Profile Folder") + open_button.clicked.connect(self.open_profile_folder) + import_button = QPushButton("Import Mods…") + import_button.clicked.connect(self.import_mods) + file_actions.addWidget(open_button) + file_actions.addWidget(import_button) + file_actions.addStretch() + editor_layout.addLayout(file_actions) + + actions = QHBoxLayout() + duplicate_button = QPushButton("Duplicate") + duplicate_button.clicked.connect(self.duplicate_profile) + delete_button = QPushButton("Delete") + delete_button.setObjectName("dangerButton") + delete_button.clicked.connect(self.delete_profile) + save_button = QPushButton("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.profile_manager.profiles_changed.connect(self.load_profiles) + self.app.apply_qss(Path("manage_profile.qss"), self.setStyleSheet) + self.load_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 + + @staticmethod + def _dimension_spin() -> QSpinBox: + spin = QSpinBox() + spin.setRange(0, 16384) + spin.setSpecialValueText("Default") + spin.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + return spin + + 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) + 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 _set_detail(self, profile: ProfileDetail) -> None: + self.name_edit.setText(profile.name) + self.version_edit.setText(profile.version_id) + self.type_edit.setText(profile.version_type) + 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( + "Set both resolution values, or set both to Default." + ) + return + game_dir_text = self.game_dir_edit.text().strip() + request = UpdateProfileRequest( + name=self.name_edit.text().strip(), + version_id=self.version_edit.text().strip(), + version_type=self.type_edit.text().strip(), + 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("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_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, "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(f"Unable to import mod: {error}") + return + self.app.widget_manager.signal.show_info_message.emit( + f"Imported {len(files)} mod(s) into {mods_dir}." + ) + + def duplicate_profile(self) -> None: + if self.selected_profile_id is None: + return + new_id = self.profile_manager.duplicate_profile(self.selected_profile_id) + if new_id: + self.selected_profile_id = new_id + self.load_profiles() + self.profile_manager.save() + + def delete_profile(self) -> None: + if self.selected_profile_id is None: + return + answer = QMessageBox.question( + self, "Delete Profile", "Delete this profile? Game files will not be removed.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer == QMessageBox.StandardButton.Yes: + profile_id = self.selected_profile_id + self.selected_profile_id = None + if self.profile_manager.remove_profile(profile_id): + self.profile_manager.save() diff --git a/resources/icons/profile_edit.png b/resources/icons/profile_edit.png new file mode 100644 index 0000000..ff7db35 Binary files /dev/null and b/resources/icons/profile_edit.png differ diff --git a/resources/styles/manage_profile.qss b/resources/styles/manage_profile.qss new file mode 100644 index 0000000..9f6071e --- /dev/null +++ b/resources/styles/manage_profile.qss @@ -0,0 +1,33 @@ +QLabel#manageTitle { + font-size: 24px; + font-weight: 700; + padding: 4px 2px 8px 2px; +} + +QScrollArea#manageProfileListScroll, +QScrollArea#manageProfileOptionsScroll { + background: palette(base); + border: 1px solid palette(mid); + border-radius: 8px; +} + +QPushButton#manageProfileItem { + background: transparent; + border: none; + border-radius: 6px; + text-align: left; +} + +QLabel#itemVersion { + color: #aeb6c2; + font-size: 12px; +} + +QPushButton#manageProfileItem:hover { background: palette(midlight); } +QPushButton#manageProfileItem:checked { background: #3d6fb4; } + +QLabel#itemName { font-size: 15px; font-weight: 600; } + +QFrame#profileEditor { background: transparent; } +QPushButton#saveProfileButton { min-width: 130px; font-weight: 600; } +QPushButton#dangerButton { color: #e56b6f; } diff --git a/resources/styles/settings.qss b/resources/styles/settings.qss new file mode 100644 index 0000000..cbc8780 --- /dev/null +++ b/resources/styles/settings.qss @@ -0,0 +1,84 @@ +QWidget#settingsPage, +QWidget#settingsMainPage, +QWidget#settingsScrollContent { + background-color: palette(window); +} + +QScrollArea#settingsScrollArea { + border: none; + background-color: palette(window); +} + +QScrollArea#settingsScrollArea > QWidget > QWidget { + background-color: palette(window); +} + +QLabel#settingsPageTitle { + font-size: 18pt; + font-weight: bold; +} + +QPushButton#settingsItem { + min-height: 68px; + border: 1px solid palette(mid); + border-radius: 8px; + background-color: palette(button); + text-align: left; +} + +QPushButton#settingsItem:hover { + background-color: palette(midlight); +} + +QPushButton#settingsItem:pressed { + background-color: palette(dark); +} + +QLabel#settingsItemTitle { + color: palette(button-text); + font-size: 11pt; + font-weight: bold; +} + +QLabel#settingsItemDescription { + color: palette(placeholder-text); + font-size: 9pt; +} + +QLabel#settingsHelpText { + color: palette(placeholder-text); + font-size: 9pt; +} + +QPushButton#settingsBackButton { + border: none; + border-radius: 8px; + background-color: transparent; + font-size: 22px; +} + +QPushButton#settingsBackButton:hover { + background-color: palette(button); +} + +QPushButton#settingsBackButton:pressed { + background-color: palette(midlight); +} + +QLabel#settingsSubPageTitle { + font-size: 15pt; + font-weight: bold; +} + +QLabel#settingsBrand { + font-size: 20px; + font-weight: bold; +} + +QLabel#settingsVersion { + font-size: 12px; +} + +QPlainTextEdit#settingsDescription { + font-size: 13px; +} diff --git a/window.py b/window.py index 21bbea5..40de299 100644 --- a/window.py +++ b/window.py @@ -16,6 +16,7 @@ from manager.widgets import AskForRequest, SelectPathRequest from pages.account import AccountPage from pages.create_profile import CreateProfilePage from pages.launch_profile import LaunchProfilePage +from pages.manage_profile import ManageProfilePage from pages.test import TestPage from pages.settings import SettingsPage @@ -163,6 +164,9 @@ class LauncherMainWindow(QMainWindow): # Profile page self.create_profile_page = CreateProfilePage(self.app, self) + # Profile management page + self.manage_profile_page = ManageProfilePage(self.app, self) + # Settings page self.settings_page = SettingsPage(self.app, self) @@ -172,6 +176,7 @@ class LauncherMainWindow(QMainWindow): self.central.addWidget(self.account_page) self.central.addWidget(self.settings_page) self.central.addWidget(self.create_profile_page) + self.central.addWidget(self.manage_profile_page) self.central.addWidget(self.launch_profile_page) # Bind toolbar and center widget @@ -193,24 +198,32 @@ class LauncherMainWindow(QMainWindow): icon=self.app.get_icon(Path(self.app.icon_dir, "profile_default.png"), True)) # Home btn - self.home_button = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "home.png"))) + self.home_button = self.toolbar.add_button(" Home", icon=self.app.get_icon(Path(self.app.icon_dir, "home.png"))) # Settings btn (will be moved to the bottom of the toolbar) - self.open_settings_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "settings.png"))) + self.open_settings_page = self.toolbar.add_button(" Settings", icon=self.app.get_icon(Path(self.app.icon_dir, "settings.png"))) # Account btn (will be moved to the bottom (or last one item) of the toolbar - self.open_account_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "head.png"), True)) + self.open_account_page = self.toolbar.add_button(" Accounts", icon=self.app.get_icon(Path(self.app.icon_dir, "head.png"), True)) self.account_page.set_related_button(self.open_account_page) # AccountPage require this to set button icon to # current account's avatar # Profile btn - self.open_create_profile_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "add.png"))) + self.open_create_profile_page = self.toolbar.add_button("Create Profile", + icon=self.app.get_icon(Path(self.app.icon_dir, "add.png"))) + + # manage profile + self.open_manage_profile_page = self.toolbar.add_button( + " Manage Profiles", + icon=self.app.get_icon(Path(self.app.icon_dir, "profile_edit.png"), True), + ) # Bind tool buttons self.toolbar.addWidget(self.home_button) self.toolbar.addWidget(self.open_test_page) self.toolbar.addWidget(self.open_create_profile_page) self.toolbar.addWidget(self.open_launch_profile_page) + self.toolbar.addWidget(self.open_manage_profile_page) self.toolbar.setMovable(True) self.toolbar.setFloatable(False) self.toolbar.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) @@ -245,6 +258,10 @@ class LauncherMainWindow(QMainWindow): lambda: self.set_current_page(self.create_profile_page, self.open_create_profile_page) ) + self.open_manage_profile_page.clicked.connect( + lambda: self.set_current_page(self.manage_profile_page, self.open_manage_profile_page) + ) + # Bind signal event self.bind_event()