diff --git a/app.py b/app.py index b4a708a..52e7126 100644 --- a/app.py +++ b/app.py @@ -19,85 +19,6 @@ from manager.profile import ProfileManager from manager.widgets import WidgetManager from window import LauncherMainWindow -class ToolBar(QtWidgets.QToolBar): - def __init__(self, app: QApplication, parent=None): - super(ToolBar, self).__init__(parent) - self.app = app - self.spacer = QWidget() - self.orientationChanged.connect(self._update_widget_size) - - def _update_widget_size(self, orientation): - vertical = orientation == Qt.Orientation.Vertical - - if vertical: - self.spacer.setSizePolicy( - QtWidgets.QSizePolicy.Policy.Preferred, - QtWidgets.QSizePolicy.Policy.Expanding, - ) - else: - self.spacer.setSizePolicy( - QtWidgets.QSizePolicy.Policy.Expanding, - QtWidgets.QSizePolicy.Policy.Preferred, - ) - - for button in self.findChildren(QToolButton): - if vertical: - button.setToolButtonStyle( - Qt.ToolButtonStyle.ToolButtonIconOnly - ) - button.setFixedSize(60, 60) - continue - - button.setMinimumSize(0, 0) - button.setMaximumSize(16777215, 16777215) - - if not button.icon().isNull() and not button.text(): - button.setToolButtonStyle( - Qt.ToolButtonStyle.ToolButtonIconOnly - ) - button.setFixedSize(60, 60) - elif button.icon().isNull(): - button.setToolButtonStyle( - Qt.ToolButtonStyle.ToolButtonTextOnly - ) - button.setFixedWidth(150) - else: - button.setToolButtonStyle( - Qt.ToolButtonStyle.ToolButtonTextBesideIcon - ) - button.setMinimumWidth(60) - - def add_button(self, text=None, icon: QIcon=None) -> QToolButton: - button = QtWidgets.QToolButton(self) - - if text: - text = " "+text - button.setText(text) - # else: - # button.setText("\u00a0") - - if icon: - button.setIcon(icon) - button.setIconSize(QSize(32, 32)) - - if text: - button.setToolButtonStyle( - Qt.ToolButtonStyle.ToolButtonTextBesideIcon - ) - else: - button.setToolButtonStyle( - Qt.ToolButtonStyle.ToolButtonIconOnly - ) - - button.setAutoRaise(True) - button.setFixedHeight(60) - - return button - - def update_all(self): - self._update_widget_size(self.orientation()) - self.update() - class Launcher(QApplication, LauncherObject): def __init__(self, logger): super().__init__() diff --git a/core_lib/qt/hack.py b/core_lib/qt/hack.py index 9274831..0046a7f 100644 --- a/core_lib/qt/hack.py +++ b/core_lib/qt/hack.py @@ -89,4 +89,17 @@ def is_light_theme(widget=None) -> bool: return luminance >= 0.5 +def add_icon_padding(icon: QIcon, icon_size=32, padding=6) -> QIcon: + source = icon.pixmap(icon_size, icon_size) + + canvas_size = icon_size + padding * 2 + canvas = QPixmap(canvas_size, canvas_size) + canvas.fill(Qt.GlobalColor.transparent) + + painter = QPainter(canvas) + painter.drawPixmap(padding, padding, source) + painter.end() + + return QIcon(canvas) + # ======================== AI generated END ======================== \ No newline at end of file diff --git a/manager/profile.py b/manager/profile.py index cb5c970..ec08602 100644 --- a/manager/profile.py +++ b/manager/profile.py @@ -101,6 +101,8 @@ class ProfileManager(QObject): self.load, ) + self.load() + @property def default_profile_path(self): return Path(self.app.work_dir, DEFAuLT_PROFILE_RELATIVE_PATH) diff --git a/pages/account.py b/pages/account.py index 94a0d4a..536a30c 100644 --- a/pages/account.py +++ b/pages/account.py @@ -53,6 +53,7 @@ class AccountWidget(QWidget): elif selected_action == check_log_stat_action and callable(self.check_account_callback): self.check_account_callback() elif selected_action == switch_acc_action and callable(self.switch_account_callback): self.switch_account_callback() + class AccountPage(QWidget): def __init__(self, app, parent=None): QWidget.__init__(self, parent) @@ -60,31 +61,6 @@ class AccountPage(QWidget): self.layout = QHBoxLayout() - # center message (will be deleted if launcher finished development) - # content = """Still digging!""" - # message = "If you found that something might be a issue. You can report it to the main repo! Any suggestion are welcome." - # self.icon_label = QLabel() - # self.icon_label.setObjectName("icon_label") - # self.dev_info_box = QLabel(content) - # self.dev_info_box.setObjectName("dev_info_box") - # self.dev_info_2 = QLabel(message) - # self.dev_info_2.setObjectName("dev_info_message") - # - # self.layout.addStretch() - # self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter) - # self.layout.addWidget(self.dev_info_box, alignment=Qt.AlignmentFlag.AlignHCenter) - # self.layout.addWidget(self.dev_info_2, alignment=Qt.AlignmentFlag.AlignHCenter) - # self.layout.addStretch() - # - # if Path(self.app.resources_dir, "pictures", "in_progress.png").exists(): - # image = QPixmap(Path(self.app.resources_dir, "pictures", "in_progress.png")) - # self.icon_label.setPixmap(image.scaled(300, 300)) - # else: - # self.icon_label.setText("Ouch. The icon is missing.") - # - # self.setLayout(self.layout) - # self.app.apply_qss(Path("main.qss"), self.setStyleSheet) - self.main_layout = QVBoxLayout() self.right_layout = QVBoxLayout() diff --git a/pages/create_profile.py b/pages/create_profile.py index 0f17ed0..156aac3 100644 --- a/pages/create_profile.py +++ b/pages/create_profile.py @@ -156,16 +156,28 @@ class CreateProfilePage(QWidget): 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() @@ -243,6 +255,34 @@ class CreateProfilePage(QWidget): 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): diff --git a/pages/launch_profile.py b/pages/launch_profile.py index c7c78b7..638d019 100644 --- a/pages/launch_profile.py +++ b/pages/launch_profile.py @@ -1,7 +1,357 @@ -from PySide6.QtWidgets import QWidget +from pathlib import Path + +from PySide6.QtCore import Qt, Signal, QPointF +from PySide6.QtGui import QIcon, QMouseEvent, QHideEvent, QPixmap, QPainter, QColor +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QScrollArea, QGridLayout, \ + QComboBox, QFrame, QSizePolicy, QApplication, QGraphicsDropShadowEffect + +from manager.profile import ProfileSummary + + +class ProfileButton(QFrame): + clicked = Signal() + """ + ProfileButton + + This object is coded by codex! + """ + + def __init__(self, app, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.app = app + self.setObjectName("profileButton") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumHeight(64) + self.setMaximumHeight(64) + + # Layout + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(12, 8, 12, 8) + self.layout.setSpacing(12) + self.text_layout = QVBoxLayout() + self.text_layout.setSpacing(1) + + # Widget + self.icon_label = QLabel() + self.icon_label.setFixedSize(40, 40) + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.name_label = QLabel() + self.name_label.setObjectName("profileName") + self.version_label = QLabel() + self.version_label.setObjectName("profileVersion") + self.arrow_label = QLabel("▾") + self.arrow_label.setObjectName("profileArrow") + self.arrow_label.setStyleSheet("border-style:solid; background-color: transparent; ") + self.arrow_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.arrow_label.setFixedWidth(24) + + # Bind + self.text_layout.addWidget(self.name_label) + self.text_layout.addWidget(self.version_label) + + self.layout.addWidget(self.icon_label) + self.layout.addLayout(self.text_layout, 1) + self.layout.addWidget(self.arrow_label) + + def set_profile(self, profile: ProfileSummary) -> None: + """ + Set current profile id + :param profile: + :return: + """ + self.setProperty("profile_id", profile.id) + self.name_label.setText(profile.name) + self.version_label.setText(profile.version_id) + + icon_path = profile.icon + + if not isinstance(icon_path, Path): + icon_path = Path(icon_path) + + if not icon_path.exists() or not icon_path.is_file(): + # Use default + icon_path = Path(self.app.icon_dir, "profile_default.png") + + self.icon_label.setPixmap(QIcon(icon_path.as_posix()).pixmap(36, 36)) + + def set_popup_open(self, opened: bool) -> None: + self.arrow_label.setText("▴" if opened else "▾") + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + super().mousePressEvent(event) + +class ProfileListItem(QPushButton): + selected = Signal(object) + + def __init__(self, profile: ProfileSummary, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.profile = profile + self.setObjectName("profileItem") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumHeight(62) + + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 7, 12, 7) + layout.setSpacing(12) + + self.icon_label = QLabel() + self.icon_label.setFixedSize(40, 40) + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + text_layout = QVBoxLayout() + text_layout.setSpacing(1) + name_label = QLabel(profile.name) + name_label.setObjectName("itemName") + version_label = QLabel(profile.version_id) + version_label.setObjectName("itemVersion") + text_layout.addWidget(name_label) + text_layout.addWidget(version_label) + + layout.addWidget(self.icon_label) + layout.addLayout(text_layout, 1) + self.clicked.connect(lambda: self.selected.emit(self.profile)) + + def set_icon(self, icon_path: Path) -> None: + self.icon_label.setPixmap(QIcon(icon_path.as_posix()).pixmap(36, 36)) + +class ProfilePopup(QFrame): + profile_selected = Signal(object) + closed = Signal() + + def __init__(self, app, parent: QWidget | None = None) -> None: + super().__init__(parent, Qt.WindowType.Popup) + self.app = app + self.setFixedHeight(210) + self.setObjectName("profilePopup") + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(4, 4, 4, 4) + self.layout.setSpacing(0) + + self.scroll_area = QScrollArea(self) + self.scroll_area.setObjectName("profileScrollArea") + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + self.scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + + self.content = QWidget() + self.content.setObjectName("profileListContent") + self.content_layout = QVBoxLayout(self.content) + self.content_layout.setContentsMargins(0, 0, 0, 0) + self.content_layout.setSpacing(2) + + self.scroll_area.setWidget(self.content) + self.layout.addWidget(self.scroll_area) + self.content_layout.setAlignment( + Qt.AlignmentFlag.AlignTop + ) + + def load_profiles(self, profiles: list[ProfileSummary]) -> None: + for profile in profiles: + icon_path = profile.icon + + if not isinstance(icon_path, Path): + icon_path = Path(icon_path) + + if not icon_path.exists() or not icon_path.is_file(): + # Use default + icon_path = Path(self.app.icon_dir, "profile_default.png") + + item = ProfileListItem(profile, self) + item.selected.connect(self.profile_selected) + item.set_icon(icon_path) + self.content_layout.addWidget(item) + + + def hideEvent(self, event: QHideEvent) -> None: + self.closed.emit() + super().hideEvent(event) class LaunchProfilePage(QWidget): def __init__(self, app, parent=None): super(LaunchProfilePage, self).__init__(parent) - self.app = app \ No newline at end of file + self.app = app + self.profile_manager = app.profile_manager + + # Layout + self.layout = QVBoxLayout(self) + self.bottom_layout = QHBoxLayout() + + # Widgets + self.status_label = QLabel("Select a version to launch") + self.status_label.setStyleSheet("font-size: 12px; font-weight: 600;") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Profile button (just like the official launcher look like) + self.profile_button = ProfileButton(self.app) + self.profile_button.setMinimumWidth(260) + self.profile_button.setMaximumWidth(360) + self.profile_popup = ProfilePopup(parent=self, app=self.app) + self.profile_button.clicked.connect(self.toggle_profile_popup) + self.profile_popup.profile_selected.connect(self.select_profile) + self.profile_popup.closed.connect(self.refresh_profile_button) + + launch_button = QPushButton("Launch Game") + launch_button.setObjectName("launchButton") + launch_button.setMinimumWidth(120) + launch_button.setMinimumHeight(42) + launch_button.setMaximumHeight(64) + launch_button.setSizePolicy( + QSizePolicy.Policy.Fixed, + QSizePolicy.Policy.Expanding, + ) + launch_button.clicked.connect(self.launch_profile) + + # Profile overlay + self.profile_frame = self.ProfileBackgroundFrame( + parent=self, + image_path=Path( + self.app.icon_dir, + "profile_default.png", + ), + ) + self.profile_frame.setObjectName("profileFrame") + + # Frame layout and overlay + frame_layout = QVBoxLayout(self.profile_frame) + frame_layout.setContentsMargins(20, 20, 20, 20) + + self.profile_overlay = QFrame(self.profile_frame) + self.profile_overlay.setObjectName("header") + + # Add a nice shadow + # shadow = QGraphicsDropShadowEffect(self.profile_frame) + # shadow.setBlurRadius(24) + # shadow.setOffset(QPointF(0, 6)) + # shadow.setColor(QColor(0, 0, 0, 150)) + + # self.profile_frame.setGraphicsEffect(shadow) + + # Header + self.header_label = QLabel("Launch Profile") + self.header_label.setObjectName("headerLabel") + + self.header_layout = QHBoxLayout(self.profile_overlay) + self.header_layout.addWidget(self.header_label) + self.header_layout.addStretch() + + frame_layout.addWidget(self.profile_overlay) + frame_layout.addStretch() + + # Bind button widget + self.bottom_layout.setContentsMargins(0, 0, 0, 0) + self.bottom_layout.setSpacing(10) + self.bottom_layout.addWidget(self.profile_button) + self.bottom_layout.addStretch() + self.bottom_layout.addWidget(self.status_label) + self.bottom_layout.addStretch() + self.bottom_layout.addWidget(launch_button) + + self.layout.addWidget(self.profile_frame, 1) + self.layout.addLayout(self.bottom_layout) + + self.app.apply_qss(Path("profile.qss"), self.setStyleSheet) + + self.load_profiles() + + class ProfileBackgroundFrame(QFrame): + def __init__(self, image_path, parent=None): + super().__init__(parent) + self.background = QPixmap(str(image_path)) + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QPainter(self) + + if not self.background.isNull(): + scaled = self.background.scaled( + self.size(), + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation, + ) + + x = (self.width() - scaled.width()) // 2 + y = (self.height() - scaled.height()) // 2 + painter.drawPixmap(x, y, scaled) + + def load_profiles(self): + profiles: list[ProfileSummary] = self.profile_manager.list_profiles() + self.profile_popup.load_profiles(profiles) + + if len(profiles) > 0: + self.select_profile(profiles[0]) + + def toggle_profile_popup(self) -> None: + if self.profile_popup.isVisible(): + self.profile_popup.hide() + self.profile_button.set_popup_open(False) + return + + self.profile_popup.setFixedWidth(self.profile_button.width()) + + button_top = self.profile_button.mapToGlobal( + self.profile_button.rect().topLeft() + ) + button_bottom = self.profile_button.mapToGlobal( + self.profile_button.rect().bottomLeft() + ) + + screen = QApplication.screenAt( + self.profile_button.mapToGlobal( + self.profile_button.rect().center() + ) + ) + if screen is None: + screen = QApplication.primaryScreen() + + # Show profile popup at bottom if screen space is enough to render (if not, render at top of the button) + available = screen.availableGeometry() + popup_width = self.profile_popup.width() + popup_height = self.profile_popup.height() + space_below = available.bottom() - button_bottom.y() + space_above = button_top.y() - available.top() + + if space_below < popup_height and space_above > space_below: + popup_y = button_top.y() - popup_height + else: + popup_y = button_bottom.y() + 1 + + popup_x = max( + available.left(), + min(button_bottom.x(), available.right() - popup_width + 1), + ) + popup_y = max( + available.top(), + min(popup_y, available.bottom() - popup_height + 1), + ) + + self.profile_popup.move(popup_x, popup_y) + self.profile_popup.show() + self.profile_button.set_popup_open(True) + + def refresh_profile_button(self) -> None: + # Refresh profile button (Due to some unknown issue, the button arrow have a black + # background after profile menu profile_popup. Rerender the button can fix this issue) + self.profile_button.set_popup_open(False) + self.profile_button.updateGeometry() + self.profile_button.repaint() + + def select_profile(self, profile: ProfileSummary) -> None: + # Update select profile id + self.profile_button.set_profile(profile) + self.profile_popup.hide() + self.profile_button.set_popup_open(False) + + def launch_profile(self) -> None: + profile_id = self.profile_button.property("profile_id") + self.status_label.setText(f"Preparing to launch...") \ No newline at end of file diff --git a/resources/icons/COPYRIGHT.md b/resources/icons/COPYRIGHT.md index 2425a47..32074c8 100644 --- a/resources/icons/COPYRIGHT.md +++ b/resources/icons/COPYRIGHT.md @@ -6,5 +6,5 @@ - profile_default.png : [Source](https://www.pixilart.com/art/minecraft-crafting-table-8aa9e1b9aee1a62) - modLoader/fabric.png : [Source](https://fabricmc.net/assets/logo.png) - modLoader/neoforged.png : [Source](https://raw.githubusercontent.com/neoforged/.github/refs/heads/main/art/neoforged.ico) - +- vanilla.png : [Source](https://en.wikipedia.org/wiki/File:Updated_Minecraft_cube.svg) ## For the icon owner: If you want to take down icon that infringing upon your copyright. Contact us with this [link](https://repo.weispace.net/wei/Launcher) \ No newline at end of file diff --git a/resources/icons/modLoader/fabric.png b/resources/icons/modLoader/fabric.png new file mode 100644 index 0000000..4ab8370 Binary files /dev/null and b/resources/icons/modLoader/fabric.png differ diff --git a/resources/icons/modLoader/neoforged.ico b/resources/icons/modLoader/neoforged.ico new file mode 100644 index 0000000..3e78a3f Binary files /dev/null and b/resources/icons/modLoader/neoforged.ico differ diff --git a/resources/icons/vanilla.png b/resources/icons/vanilla.png new file mode 100644 index 0000000..839775a Binary files /dev/null and b/resources/icons/vanilla.png differ diff --git a/resources/styles/profile.qss b/resources/styles/profile.qss new file mode 100644 index 0000000..ec9fdf5 --- /dev/null +++ b/resources/styles/profile.qss @@ -0,0 +1,56 @@ +QFrame#profileButton { + background: palette(mid); + border: 1px solid palette(midlight); + border-radius: 5px; +} + +QFrame#profileButton:hover { + background: palette(midlight); +} + +QLabel#profileName, QLabel#itemName { + color: #f4f6f8; + font-size: 16px; + font-weight: 600; +} + +QLabel#profileVersion, QLabel#itemVersion { + color: #aeb6c2; + font-size: 12px; +} + +QLabel#profileArrow { + font-size: 18px; +} +QFrame#profilePopup { + background: palette(mid); + border: 1px solid palette(midlight); + border-radius: 5px; +} + +QPushButton#profileItem { + background: transparent; + border: none; + text-align: left; +} + +QPushButton#profileItem:hover { + background: #3d6fb4; +} + +QPushButton#launchButton { + min-width: 150px; + font-size: 15px; + font-weight: 600; +} + +QLabel#headerLabel { + font-size: 20px; + font-weight: bold +} + +QFrame#profileFrame { + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 10px; +} \ No newline at end of file