Files
Launcher/pages/launch_profile.py
T

417 lines
15 KiB
Python
Raw Normal View History

2026-08-01 00:47:41 +08:00
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QIcon, QMouseEvent, QHideEvent, QPixmap, QPainter
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QScrollArea, QFrame,
QSizePolicy, QApplication)
2026-08-01 00:47:41 +08:00
from manager.game import LaunchManager
from core_lib.game.version_info import LATEST_VERSION_ALIASES
2026-08-01 00:47:41 +08:00
from manager.profile import ProfileSummary
from manager.widgets import AskForRequest, WidgetManager
from pages.process_log import ProcessLogWindow
2026-08-01 00:47:41 +08:00
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.display_name)
2026-08-01 00:47:41 +08:00
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.display_name)
2026-08-01 00:47:41 +08:00
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)
2026-08-01 00:47:41 +08:00
self.app = app
self.widget_mgr: WidgetManager = app.widget_manager
2026-08-01 00:47:41 +08:00
self.profile_manager = app.profile_manager
self.game_manager = app.game_manager
self.launch_manager: LaunchManager = self.game_manager.launch
self.process_log_windows: list[ProcessLogWindow] = []
2026-08-01 00:47:41 +08:00
# Layout
self.layout = QVBoxLayout(self)
self.bottom_layout = QHBoxLayout()
# Widgets
self.status_label = QLabel("Select a profile to launch")
2026-08-01 00:47:41 +08:00
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:
try:
profile_id = self.profile_button.property("profile_id")
self.status_label.setText("Preparing to launch...")
launch_profile = self.app.profile_manager.get_launch_profile(profile_id)
if launch_profile is None:
self.status_label.setText("Unable to load the selected profile.")
return
if self.launch_manager.is_profile_running(launch_profile.profile_id):
request = AskForRequest(
"Launch Request",
"Target profile {} is running. Are you sure you want to launch it again?".format(
launch_profile.display_name
),
)
self.widget_mgr.signal.askyesno.emit(request)
if not request.wait(60):
self.widget_mgr.signal.show_error_message.emit(
"Time out waiting for request for launch."
)
return
if not request.result() is True:
return
result = self.launch_manager.launch(launch_profile)
if result.success:
self.status_label.setText("Launched!")
log_window = ProcessLogWindow(
result.process,
launch_profile.profile_id,
title=f"Minecraft Log - {launch_profile.display_name} ({launch_profile.version_id})",
parent=None,
finished_callback=self.launch_manager.profile_finished,
)
for warning in result.warnings:
log_window.append_message(f"[launcher warning] {warning}")
self.process_log_windows.append(log_window)
log_window.show()
else:
self.widget_mgr.signal.show_error_message.emit(
"Unable to launch profile {}: {}".format(
launch_profile.display_name,
result.error_message,
)
)
finally:
self.status_label.setText("Select a profile to launch")