Settings page is "fully" implementation.

This commit is contained in:
wei
2026-08-03 20:55:37 +08:00
parent ed9d25023d
commit 48b70c12af
16 changed files with 579 additions and 261 deletions
+14
View File
@@ -14,6 +14,8 @@ from core_lib.launcher.object import Launcher as LauncherObject
from manager.account import AccountManager from manager.account import AccountManager
from manager.game import GameManager from manager.game import GameManager
from manager.profile import ProfileManager from manager.profile import ProfileManager
from manager.settings import SettingsManager
from manager.settings_models import AccountSettings, AppearanceSettings, GeneralSettings
from manager.widgets import WidgetManager from manager.widgets import WidgetManager
from window import LauncherMainWindow from window import LauncherMainWindow
@@ -31,6 +33,14 @@ class Launcher(QApplication, LauncherObject):
# Widget (Signal, Event) # Widget (Signal, Event)
self.widget_manager = WidgetManager() self.widget_manager = WidgetManager()
# Manager # Manager
self.settings_manager = SettingsManager(self.root_settings_dir)
self.settings_manager.register("general", GeneralSettings)
self.settings_manager.register("appearance", AppearanceSettings)
self.settings_manager.register("account", AccountSettings)
if self.root_settings_dir.is_file():
self.settings_manager.load()
else:
self.settings_manager.load_defaults().save()
self.profile_manager = ProfileManager(self, self.widget_manager) self.profile_manager = ProfileManager(self, self.widget_manager)
self.account_manager = AccountManager(self, self.widget_manager) self.account_manager = AccountManager(self, self.widget_manager)
self.game_manager = GameManager(self) self.game_manager = GameManager(self)
@@ -123,6 +133,10 @@ class Launcher(QApplication, LauncherObject):
def log_dir(self): def log_dir(self):
return self.work_dir / "log" return self.work_dir / "log"
@property
def root_settings_dir(self):
return self.data_dir / "settings.toml"
@property @property
def default_game_dir(self) -> Path: def default_game_dir(self) -> Path:
return self.work_dir / "minecraft" if sys.platform.lower() == "darwin" else self.work_dir / ".minecraft" return self.work_dir / "minecraft" if sys.platform.lower() == "darwin" else self.work_dir / ".minecraft"
+20 -1
View File
@@ -1,4 +1,7 @@
from pathlib import Path from pathlib import Path
from typing import Mapping
from pydantic import ValidationError
class LauncherException(Exception): class LauncherException(Exception):
@@ -110,4 +113,20 @@ class AuthenticationSessionException(LauncherException):
user_message = "Authentication failed. Session may expire." user_message = "Authentication failed. Session may expire."
class ThirdPartyAPIException(LauncherException): class ThirdPartyAPIException(LauncherException):
user_message = "An error occurred while fetching third party API." user_message = "An error occurred while fetching third party API."
class SettingsError(LauncherException):
user_message = "An settings error occurred."
class ModelRegistrationError(SettingsError):
user_message = "Settings section could not be registered."
class ConfigValidationError(SettingsError):
user_message = "Settings section could not be validated."
def __init__(self, errors: Mapping[str, ValidationError]) -> None:
self.errors = dict(errors)
details = "\n".join(
f"[{section}]\n{error}" for section, error in self.errors.items()
)
super().__init__(f"Invalid settings configuration:\n{details}")
+4 -3
View File
@@ -71,9 +71,10 @@ def main():
sys.exit(-1) sys.exit(-1)
else: else:
# check workdir # check workdir
logger.error("Work directory '%s' does not exist." % work_dir) if not os.path.exists(work_dir) or not os.path.isdir(work_dir):
show_error(f"Specified work directory \"{work_dir}\" does not exist or is not a directory.") logger.error("Work directory '%s' does not exist." % work_dir)
sys.exit(-1) show_error(f"Specified work directory \"{work_dir}\" does not exist or is not a directory.")
sys.exit(-1)
if os.getcwd() != args.work_dir: if os.getcwd() != args.work_dir:
os.chdir(work_dir) os.chdir(work_dir)
+2 -2
View File
@@ -910,13 +910,13 @@ class AccountManager(QObject):
# #
# Account Login, Refresh, API Handle # Account Login, Refresh, API Handle
# #
def start_login(self): def start_login(self, client_id: str = CLIENT_ID):
if self.login_thread is not None: if self.login_thread is not None:
return False return False
# Create login worker and run it in outer thread # Create login worker and run it in outer thread
self.login_thread = QThread(self) self.login_thread = QThread(self)
self.login_worker = LoginWorker(CLIENT_ID) self.login_worker = LoginWorker(client_id)
self.login_worker.moveToThread(self.login_thread) self.login_worker.moveToThread(self.login_thread)
self.login_worker.device_code_ready.connect( self.login_worker.device_code_ready.connect(
+15 -4
View File
@@ -4,7 +4,6 @@ import subprocess
import threading import threading
import time import time
import traceback import traceback
import types
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -671,6 +670,7 @@ class LaunchManager:
self.java_mgr = java_manager self.java_mgr = java_manager
self.game_file_mgr = game_file_manager self.game_file_mgr = game_file_manager
self.account_mgr = account_manager self.account_mgr = account_manager
self.launching_profiles: set[str] = set()
self.launched_profiles: list[str] = [] self.launched_profiles: list[str] = []
self.signal = self.LaunchSignal() self.signal = self.LaunchSignal()
@@ -879,12 +879,20 @@ class LaunchManager:
return result return result
def start_launch_task(self, profile: LaunchProfile) -> Thread: def start_launch_task(self, profile: LaunchProfile) -> Thread | None:
def worker(): def worker():
result = self.launch(profile) result = self.launch(profile)
self.signal.finished.emit(profile, result) self.signal.finished.emit(profile, result)
return result return result
profile_id = profile.profile_id
# Check if profile is in launching process
if profile_id in self.launching_profiles:
return None
self.launching_profiles.add(profile_id)
thread = threading.Thread( thread = threading.Thread(
target=worker, target=worker,
name=f"launch-{profile.profile_id}", name=f"launch-{profile.profile_id}",
@@ -893,8 +901,11 @@ class LaunchManager:
thread.start() thread.start()
return thread return thread
def is_profile_running(self, profile_id): def is_profile_running(self, profile_id: str) -> bool:
return profile_id in self.launched_profiles return (
profile_id in self.launching_profiles
or profile_id in self.launched_profiles
)
def profile_finished(self, profile_id: str, exit_code: int) -> None: def profile_finished(self, profile_id: str, exit_code: int) -> None:
try: try:
+38 -1
View File
@@ -251,7 +251,10 @@ class ProfileManager(QObject):
) )
result.append(profile_summary) result.append(profile_summary)
return result settings = self.profiles.get("settings", {})
sorting = settings.get("profileSorting", "ByLastPlayed")
current_id = get_current_profile_id(self.profiles)
return sort_profile_summaries(result, sorting, current_id)
def get_profile(self, profile_id: str) -> ProfileDetail | None: def get_profile(self, profile_id: str) -> ProfileDetail | None:
if not self.contains(profile_id, show_error=True): if not self.contains(profile_id, show_error=True):
@@ -701,3 +704,37 @@ class ProfileManager(QObject):
@property @property
def is_modified(self) -> bool: def is_modified(self) -> bool:
return self._is_modified return self._is_modified
def sort_profile_summaries(
profiles: list[ProfileSummary],
sorting: str,
current_profile_id: str | None = None,
) -> list[ProfileSummary]:
"""Order profiles according to launcher_profiles.json settings."""
ordered = list(profiles)
if sorting == "ByName":
return sorted(
ordered,
key=lambda profile: (
profile.display_name.casefold(),
profile.version_id.casefold(),
profile.id,
),
)
if sorting == "ByLastPlayed":
def last_used_timestamp(profile: ProfileSummary) -> float:
if profile.last_used is None:
return float("-inf")
try:
return profile.last_used.timestamp()
except (OSError, OverflowError, ValueError):
return float("-inf")
ordered.sort(key=last_used_timestamp, reverse=True)
if current_profile_id is not None:
ordered.sort(key=lambda profile: profile.id != current_profile_id)
return ordered
+271
View File
@@ -0,0 +1,271 @@
from __future__ import annotations
from collections.abc import Mapping
from enum import Enum
from pathlib import Path
from types import UnionType
from typing import Annotated, Any, Literal, TypeVar, Union, cast, get_args, get_origin
from pydantic import BaseModel, ValidationError
import tomli_w
from core_lib.common.exception import ModelRegistrationError, ConfigValidationError, SettingsError
try:
import tomllib
except ModuleNotFoundError: # Python 3.10
import tomli as tomllib
ModelT = TypeVar("ModelT", bound=BaseModel)
class SettingsManager:
"""
A settings manager (Based-on pydantic)
Vibed!
"""
def __init__(self, path: str | Path | None = None) -> None:
self.path = Path(path) if path is not None else None
self._models: dict[str, type[BaseModel]] = {}
self._settings: dict[str, BaseModel] = {}
@property
def registered_sections(self) -> tuple[str, ...]:
return tuple(self._models)
def register(self, section: str, model: type[ModelT], *, replace: bool = False) -> type[ModelT]:
"""
Register a new model
:param section:
:param model:
:param replace:
:return:
model: type[ModelT]
"""
if not section:
raise ModelRegistrationError(
"Section must be a non-empty"
)
elif "." in section:
raise ModelRegistrationError(
"Top level section cannot contain dots"
)
if not isinstance(model, type) or not issubclass(model, BaseModel):
raise ModelRegistrationError("Model must be a Pydantic BaseModel class")
if section in self._models and not replace:
raise ModelRegistrationError(f"Section {section!r} is already registered")
self._models[section] = model
self._settings.pop(section, None)
return model
def model(self, section: str):
def decorator(model: type[ModelT]) -> type[ModelT]:
return self.register(section, model)
return decorator
def load(self, path: str | Path | None = None, *, reject_unknown: bool = True) -> SettingsManager:
"""
Load a toml settings file
:param reject_unknown: Raise exception if settings file contains unknown section
:param path: If not set, use default path instead
"""
config_path = self._resolve_path(path)
# Check file exist
if not config_path.exists() or not config_path.is_file():
raise FileNotFoundError(f"Settings file {config_path} does not exist")
with config_path.open("rb") as file:
raw = tomllib.load(file)
unknown = set(raw).difference(self._models)
if reject_unknown and unknown:
names = ", ".join(sorted(unknown))
raise SettingsError(f"unregistered TOML section(s): {names}")
loaded: dict[str, BaseModel] = {}
errors: dict[str, ValidationError] = {}
for section, model in self._models.items():
value: Any = raw.get(section, {})
try:
# Validate and save section data
loaded[section] = model.model_validate(value)
except ValidationError as error:
errors[section] = error
if errors:
raise ConfigValidationError(errors)
self.path = config_path
self._settings = loaded
return self
def get(self, section: str, model: type[ModelT] | None = None) -> ModelT:
"""
Get target section model
:param section:
:param model:
:return:
model: type[ModelT]
"""
if section not in self._models:
raise KeyError(f"settings section {section!r} is not registered")
if section not in self._settings:
raise SettingsError(f"settings have not been loaded for {section!r}")
value = self._settings[section]
if model is not None and not isinstance(value, model):
raise TypeError(
f"section {section!r} contains {type(value).__name__}, "
f"not {model.__name__}"
)
return cast(ModelT, value)
def set(self, section: str, value: BaseModel | Mapping[str, Any]) -> BaseModel:
"""Validate and replace one loaded settings section."""
try:
model = self._models[section]
except KeyError as error:
raise KeyError(f"settings section {section!r} is not registered") from error
validated = model.model_validate(value)
self._settings[section] = validated
return validated
def load_defaults(self) -> SettingsManager:
"""Load every registered model using its declared default values."""
loaded: dict[str, BaseModel] = {}
errors: dict[str, ValidationError] = {}
for section, model in self._models.items():
try:
loaded[section] = model.model_validate({})
except ValidationError as error:
errors[section] = error
if errors:
raise ConfigValidationError(errors)
self._settings = loaded
return self
def save(self, path: str | Path | None = None) -> Path:
"""Validate all loaded sections and save them as UTF-8 TOML."""
config_path = self._resolve_path(path)
missing = set(self._models).difference(self._settings)
if missing:
names = ", ".join(sorted(missing))
raise SettingsError(f"settings section(s) have not been loaded: {names}")
data = {
section: self._models[section]
.model_validate(self._settings[section])
.model_dump(mode="json")
for section in self._models
}
config_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = config_path.with_suffix(config_path.suffix + ".tmp")
temporary_path.write_bytes(tomli_w.dumps(data).encode("utf-8"))
temporary_path.replace(config_path)
self.path = config_path
return config_path
def choices(self, section: str, field: str) -> tuple[Any, ...]:
"""
Return choices for a field, accepting paths such as ``window.mode``.
This can be used by a settings page to populate a combo box. Pydantic
uses the same annotation to validate values read from TOML.
:param section:
:param field:
"""
try:
model = self._models[section]
except KeyError as error:
raise KeyError(f"settings section {section!r} is not registered") from error
annotation: Any = model
for part in field.split("."):
nested_model = _model_type(annotation)
if nested_model is None:
raise KeyError(
f"{part!r} cannot be resolved in field path {section}.{field}"
)
try:
annotation = nested_model.model_fields[part].annotation
except KeyError as error:
raise KeyError(f"field path {section}.{field} does not exist") from error
values = _choice_values(annotation)
if values is None:
raise SettingsError(
f"field {section}.{field} is not declared with Literal or Enum"
)
return values
def validate(self, data: Mapping[str, Any], *, reject_unknown: bool = True) -> None:
"""
Validate an in-memory mapping without changing loaded settings.
:param data:
:param reject_unknown:
:return:
"""
unknown = set(data).difference(self._models)
if reject_unknown and unknown:
names = ", ".join(sorted(unknown))
raise SettingsError(f"unregistered settings section(s): {names}")
errors: dict[str, ValidationError] = {}
for section, model in self._models.items():
try:
model.model_validate(data.get(section, {}))
except ValidationError as error:
errors[section] = error
if errors:
raise ConfigValidationError(errors)
def _resolve_path(self, path: str | Path | None) -> Path:
resolved = Path(path) if path is not None else self.path
if resolved is None:
raise SettingsError("no TOML configuration path was provided")
return resolved
def _choice_values(annotation: Any) -> tuple[Any, ...] | None:
"""Extract choices from a field annotation, including Annotated/Optional."""
origin = get_origin(annotation)
if origin is Literal:
return get_args(annotation)
if isinstance(annotation, type) and issubclass(annotation, Enum):
return tuple(member.value for member in annotation)
if origin is Annotated:
return _choice_values(get_args(annotation)[0])
if origin in (Union, UnionType):
choices: list[Any] = []
found = False
for member in get_args(annotation):
values = _choice_values(member)
if values is not None:
choices.extend(values)
found = True
return tuple(choices) if found else None
return None
def _model_type(annotation: Any) -> type[BaseModel] | None:
"""Find a nested BaseModel type inside Annotated or Optional."""
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return annotation
origin = get_origin(annotation)
if origin is Annotated:
return _model_type(get_args(annotation)[0])
if origin in (Union, UnionType):
for member in get_args(annotation):
model = _model_type(member)
if model is not None:
return model
return None
+4 -3
View File
@@ -17,10 +17,11 @@ from PySide6.QtWidgets import (
QWidget, QDialog, QLineEdit, QApplication, QToolButton, QWidget, QDialog, QLineEdit, QApplication, QToolButton,
) )
from constant import CLIENT_ID, MICROSOFT_VERIFY_ENDPOINT from constant import MICROSOFT_VERIFY_ENDPOINT
from core_lib.qt.hack import move_window_to_center from core_lib.qt.hack import move_window_to_center
from manager.account import AccountManager, AccountSummary from manager.account import AccountManager, AccountSummary
from manager.settings_models import AccountSettings
class AccountWidget(QWidget): class AccountWidget(QWidget):
@@ -162,7 +163,6 @@ class AccountPage(QWidget):
self.app = app self.app = app
self.widget_manager = app.widget_manager self.widget_manager = app.widget_manager
self.account_manager: AccountManager = app.account_manager self.account_manager: AccountManager = app.account_manager
self.client_id = CLIENT_ID
self.login_dialog = None self.login_dialog = None
@@ -343,7 +343,8 @@ class AccountPage(QWidget):
self.login_dialog.rejected.connect(self.account_manager.cancel_login) self.login_dialog.rejected.connect(self.account_manager.cancel_login)
self.login_dialog.show() self.login_dialog.show()
self.account_manager.start_login() account_settings = self.app.settings_manager.get("account", AccountSettings)
self.account_manager.start_login(str(account_settings.client_id))
def _show_device_code(self, device: dict): def _show_device_code(self, device: dict):
self._verification_uri = device.get("verification_uri", MICROSOFT_VERIFY_ENDPOINT) self._verification_uri = device.get("verification_uri", MICROSOFT_VERIFY_ENDPOINT)
+12 -2
View File
@@ -362,8 +362,18 @@ class LaunchProfilePage(QWidget):
profiles: list[ProfileSummary] = self.profile_manager.list_profiles() profiles: list[ProfileSummary] = self.profile_manager.list_profiles()
self.profile_popup.load_profiles(profiles) self.profile_popup.load_profiles(profiles)
if len(profiles) > 0: if not profiles:
self.select_profile(profiles[0]) self.profile_button.setProperty("profile_id", None)
self.profile_button.name_label.setText("No profiles available")
self.profile_button.version_label.clear()
return
selected_id = self.profile_button.property("profile_id")
available = {profile.id: profile for profile in profiles}
selected = available.get(selected_id)
if selected is None:
selected = available.get(self.profile_manager.get_current_profile_id())
self.select_profile(selected or profiles[0])
def toggle_profile_popup(self) -> None: def toggle_profile_popup(self) -> None:
if self.profile_popup.isVisible(): if self.profile_popup.isVisible():
+190 -243
View File
@@ -1,328 +1,275 @@
from PySide6.QtCore import Qt, Signal from pathlib import Path
from PySide6.QtGui import QPalette, QFont
from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox,
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QPushButton, QPushButton,
QSlider,
QStackedWidget, QStackedWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QLineEdit, QPlainTextEdit, QWidget, QScrollArea,
) )
from constant import LAUNCHER_NAME, LAUNCHER_DESCRIPTION, LAUNCHER_VERSION from constant import LAUNCHER_DESCRIPTION, LAUNCHER_NAME, LAUNCHER_VERSION
from manager.settings_models import AccountSettings, AppearanceSettings, GeneralSettings
LANGUAGE_LABELS = {
"en": "English",
"zh-TW": "Chinese (Traditional)",
}
THEME_LABELS = {
"system": "Follow system settings",
"light": "Light",
"dark": "Dark",
}
class SettingsItem(QPushButton): class SettingsItem(QPushButton):
""" def __init__(self, title: str, description: str = "") -> None:
A modern look settings button.
"""
clicked_page = Signal()
def __init__(self, title: str, description: str = ""):
super().__init__() super().__init__()
self.setObjectName("settingsItem")
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
# Widget size
self.setMinimumHeight(68)
self.setMaximumWidth(800)
# Layout
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(16, 10, 16, 10) layout.setContentsMargins(16, 10, 16, 10)
layout.setSpacing(3) layout.setSpacing(3)
# Widgets title_label = QLabel(title)
self.title_label = QLabel(title) title_label.setObjectName("settingsItemTitle")
self.title_label.setAttribute( title_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
Qt.WidgetAttribute.WA_TransparentForMouseEvents layout.addWidget(title_label)
)
self.description_label = QLabel(description)
self.description_label.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents
)
layout.addWidget(self.title_label)
# Show description if existing
if description: if description:
layout.addWidget(self.description_label) description_label = QLabel(description)
description_label.setObjectName("settingsItemDescription")
self.update_palette() description_label.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents
def update_palette(self): )
""" layout.addWidget(description_label)
Apply system palette
:return:
"""
palette = self.palette()
button_text = palette.color(QPalette.ColorRole.ButtonText)
placeholder_text = palette.color(QPalette.ColorRole.PlaceholderText)
title_palette = self.title_label.palette()
title_palette.setColor(
QPalette.ColorRole.WindowText,
button_text,
)
self.title_label.setPalette(title_palette)
description_palette = self.description_label.palette()
description_palette.setColor(
QPalette.ColorRole.WindowText,
placeholder_text,
)
self.description_label.setPalette(description_palette)
title_font = self.title_label.font()
title_font.setPointSize(11)
title_font.setWeight(QFont.Weight.Bold)
self.title_label.setFont(title_font)
description_font = self.description_label.font()
description_font.setPointSize(9)
self.description_label.setFont(description_font)
class SettingsSubPage(QWidget): class SettingsSubPage(QWidget):
def __init__(self, title: str, back_callback): def __init__(self, title: str, back_callback) -> None:
super().__init__() super().__init__()
self.setObjectName("settingsSubPage")
main_layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(20, 16, 20, 20) main_layout.setContentsMargins(20, 16, 20, 20)
main_layout.setSpacing(16) main_layout.setSpacing(16)
header_layout = QHBoxLayout() header_layout = QHBoxLayout()
back_button = QPushButton("")
back_button.setObjectName("settingsBackButton")
back_button.setFixedSize(36, 36)
back_button.setCursor(Qt.CursorShape.PointingHandCursor)
back_button.clicked.connect(back_callback)
self.back_button = QPushButton("") title_label = QLabel(title)
self.back_button.setFixedSize(36, 36) title_label.setObjectName("settingsSubPageTitle")
self.back_button.setCursor(Qt.CursorShape.PointingHandCursor) header_layout.addWidget(back_button)
self.back_button.clicked.connect(back_callback) header_layout.addWidget(title_label)
self.title_label = QLabel(title)
title_font = self.title_label.font()
title_font.setPointSize(15)
title_font.setBold(True)
self.title_label.setFont(title_font)
header_layout.addWidget(self.back_button)
header_layout.addWidget(self.title_label)
header_layout.addStretch() header_layout.addStretch()
self.content_layout = QVBoxLayout() self.content_layout = QVBoxLayout()
self.content_layout.setSpacing(12) self.content_layout.setSpacing(12)
main_layout.addLayout(header_layout) main_layout.addLayout(header_layout)
main_layout.addLayout(self.content_layout) main_layout.addLayout(self.content_layout)
main_layout.addStretch() main_layout.addStretch()
self.update_palette()
def update_palette(self):
# Apply system palette
palette = self.palette()
button_color = palette.color(QPalette.ColorRole.Button)
border_color = palette.color(QPalette.ColorRole.Mid)
self.back_button.setStyleSheet("""
QPushButton {
border: none;
border-radius: 8px;
background-color: transparent;
font-size: 22px;
}
QPushButton:hover {
background-color: palette(button);
}
QPushButton:pressed {
background-color: palette(midlight);
}
""")
def changeEvent(self, event):
# Update when system palette change
if event.type() in (
event.Type.PaletteChange,
event.Type.ApplicationPaletteChange,
):
self.update_palette()
super().changeEvent(event)
class SettingsPage(QWidget): class SettingsPage(QWidget):
def __init__(self, app, parent=None): def __init__(self, app, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self.app = app self.app = app
self.settings = app.settings_manager
self.setWindowTitle("Settings") self._settings_dirty = False
self.resize(560, 440) self.setObjectName("settingsPage")
root_layout = QVBoxLayout(self) root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setContentsMargins(0, 0, 0, 0)
self.stack = QStackedWidget() self.stack = QStackedWidget()
self.main_page = self.create_main_page() self.main_page = self._create_main_page()
self.general_page = self.create_general_page() self.general_page = self._create_general_page()
self.appearance_page = self.create_appearance_page() self.appearance_page = self._create_appearance_page()
self.profile_page = self.create_profile_page() self.account_page = self._create_account_page()
self.about_page = self.create_about_page() self.about_page = self._create_about_page()
for page in (
self.stack.addWidget(self.main_page) self.main_page,
self.stack.addWidget(self.general_page) self.general_page,
self.stack.addWidget(self.appearance_page) self.appearance_page,
self.stack.addWidget(self.profile_page) self.account_page,
self.stack.addWidget(self.about_page) self.about_page,
):
self.stack.addWidget(page)
root_layout.addWidget(self.stack) root_layout.addWidget(self.stack)
self.app.apply_qss(Path("settings.qss"), self.setStyleSheet)
self.language_combo.currentIndexChanged.connect(self._mark_settings_dirty)
self.theme_combo.currentIndexChanged.connect(self._mark_settings_dirty)
self.client_id_edit.textEdited.connect(self._mark_settings_dirty)
def create_main_page(self) -> QWidget: def _create_main_page(self) -> QWidget:
page = QWidget() page = QWidget()
page.setObjectName("settingsMainPage")
layout = QVBoxLayout(page) layout = QVBoxLayout(page)
layout.setContentsMargins(24, 20, 24, 24) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
scroll_area = QScrollArea()
scroll_area.setObjectName("settingsScrollArea")
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QScrollArea.Shape.NoFrame)
scroll_content = QWidget()
scroll_content.setObjectName("settingsScrollContent")
scroll_area.setWidget(scroll_content)
scroll_layout = QVBoxLayout(scroll_content)
scroll_layout.setContentsMargins(24, 20, 24, 24)
scroll_layout.setSpacing(12)
title = QLabel("Settings") title = QLabel("Settings")
title.setObjectName("settingsPageTitle")
scroll_layout.addWidget(title)
scroll_layout.addSpacing(8)
title_font = title.font() pages = (
title_font.setPointSize(18) ("General", "Language and launcher directories", "general_page"),
title_font.setBold(True) ("Appearance", "Launcher theme", "appearance_page"),
title.setFont(title_font) ("Account", "Microsoft authentication settings", "account_page"),
("About", "Version and project information", "about_page"),
# Setting options )
info_button = SettingsItem( for title_text, description, page_name in pages:
"Wait!", button = SettingsItem(title_text, description)
"Setting page is not full implemented yet.", button.clicked.connect(
) lambda checked=False, name=page_name: self._open_page(
getattr(self, name)
general_button = SettingsItem( )
"General", )
"Some general settings for launcher ", scroll_layout.addWidget(button)
)
general_button.clicked.connect(
lambda: self.open_page(self.general_page)
)
appearance_button = SettingsItem(
"Appearance",
"Settings to change the launcher look like",
)
appearance_button.clicked.connect(
lambda: self.open_page(self.appearance_page)
)
profile_button = SettingsItem(
"Profile",
"Store profile settings",
)
profile_button.clicked.connect(
lambda: self.open_page(self.profile_page)
)
about_button = SettingsItem(
"About",
"About the launcher",
)
about_button.clicked.connect(
lambda: self.open_page(self.about_page)
)
layout.addWidget(title)
layout.addSpacing(8)
layout.addWidget(info_button)
layout.addWidget(general_button)
layout.addWidget(appearance_button)
layout.addWidget(profile_button)
layout.addWidget(about_button)
layout.addStretch()
scroll_layout.addStretch()
layout.addWidget(scroll_area)
return page return page
def create_general_page(self) -> QWidget: def _create_general_page(self) -> QWidget:
page = SettingsSubPage("General", self.go_back) page = SettingsSubPage("General", self._go_back)
current = self.settings.get("general", GeneralSettings)
language_label = QLabel("Language") page.content_layout.addWidget(QLabel("Current launcher working directory"))
language_combo = QComboBox()
language_combo.addItems([
"English",
"Chinese (Traditional)",
])
launcher_dir_label = QLabel("Current launcher working directory")
launcher_dir = QLineEdit(self.app.work_dir.as_posix()) launcher_dir = QLineEdit(self.app.work_dir.as_posix())
launcher_dir.setReadOnly(True) launcher_dir.setReadOnly(True)
page.content_layout.addWidget(launcher_dir)
program_dir_label = QLabel("Current program directory") page.content_layout.addWidget(QLabel("Current program directory"))
program_dir = QLineEdit(self.app.program_dir.as_posix()) program_dir = QLineEdit(self.app.program_dir.as_posix())
program_dir.setReadOnly(True) program_dir.setReadOnly(True)
page.content_layout.addWidget(launcher_dir_label)
page.content_layout.addWidget(launcher_dir)
page.content_layout.addWidget(program_dir_label)
page.content_layout.addWidget(program_dir) page.content_layout.addWidget(program_dir)
page.content_layout.addSpacing(8) page.content_layout.addSpacing(8)
page.content_layout.addWidget(language_label) page.content_layout.addWidget(QLabel("Language"))
page.content_layout.addWidget(language_combo) self.language_combo = self._choice_combo(
"general", "language", LANGUAGE_LABELS, current.language
)
page.content_layout.addWidget(self.language_combo)
return page return page
def create_appearance_page(self) -> QWidget: def _create_appearance_page(self) -> QWidget:
page = SettingsSubPage("Appearance", self.go_back) page = SettingsSubPage("Appearance", self._go_back)
current = self.settings.get("appearance", AppearanceSettings)
theme_label = QLabel("Theme")
theme_combo = QComboBox()
theme_combo.addItems([
"Follow system settings",
"Light (unfinished)",
"Dark",
])
page.content_layout.addWidget(theme_label)
page.content_layout.addWidget(theme_combo)
page.content_layout.addWidget(QLabel("Theme"))
self.theme_combo = self._choice_combo(
"appearance", "theme", THEME_LABELS, current.theme
)
page.content_layout.addWidget(self.theme_combo)
return page return page
def create_profile_page(self) -> QWidget: def _create_account_page(self) -> QWidget:
page = SettingsSubPage("Profile", self.go_back) page = SettingsSubPage("Account", self._go_back)
current = self.settings.get("account", AccountSettings)
test_label = QLabel("Still in development...") page.content_layout.addWidget(QLabel("Microsoft OAuth Client ID"))
self.client_id_edit = QLineEdit(str(current.client_id))
page.content_layout.addWidget(test_label) self.client_id_edit.setPlaceholderText(
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
)
page.content_layout.addWidget(self.client_id_edit)
help_label = QLabel(
"The Client ID must belong to an Azure application configured "
"for Microsoft device-code authentication."
)
help_label.setObjectName("settingsHelpText")
help_label.setWordWrap(True)
page.content_layout.addWidget(help_label)
return page return page
def create_about_page(self) -> QWidget: def _create_about_page(self) -> QWidget:
page = SettingsSubPage("About", self.go_back) page = SettingsSubPage("About", self._go_back)
brand_label = QLabel(LAUNCHER_NAME) brand_label = QLabel(LAUNCHER_NAME)
brand_label.setStyleSheet("QLabel { font-size: 20px; }") brand_label.setObjectName("settingsBrand")
version_label = QLabel(f"Version: {LAUNCHER_VERSION}")
version_label = QLabel("Version: {}".format(LAUNCHER_VERSION)) version_label.setObjectName("settingsVersion")
version_label.setStyleSheet("QLabel { font-size: 12px; }") description = QPlainTextEdit(LAUNCHER_DESCRIPTION)
description.setObjectName("settingsDescription")
text = QPlainTextEdit(LAUNCHER_DESCRIPTION) description.setReadOnly(True)
text.setStyleSheet("QPlainTextEdit { font-size: 13px; }")
text.setReadOnly(True)
page.content_layout.addWidget(brand_label) page.content_layout.addWidget(brand_label)
page.content_layout.addWidget(version_label) page.content_layout.addWidget(version_label)
page.content_layout.addWidget(text) page.content_layout.addWidget(description)
return page return page
def open_page(self, page: QWidget): def _choice_combo(
self,
section: str,
field: str,
labels: dict[str, str],
current: str,
) -> QComboBox:
combo = QComboBox()
for value in self.settings.choices(section, field):
combo.addItem(labels.get(value, str(value)), value)
index = combo.findData(current)
combo.setCurrentIndex(max(index, 0))
return combo
def _mark_settings_dirty(self, *_args) -> None:
self._settings_dirty = True
def _save_pending_settings(self, *, show_error: bool = True) -> bool:
if not self._settings_dirty:
return True
data = {
"general": {"language": self.language_combo.currentData()},
"appearance": {"theme": self.theme_combo.currentData()},
"account": {"client_id": self.client_id_edit.text().strip()},
}
try:
self.settings.validate(data)
for section, value in data.items():
self.settings.set(section, value)
self.settings.save()
except Exception as error:
if show_error:
QMessageBox.critical(self, "Settings error", str(error))
return False
self._settings_dirty = False
return True
def _open_page(self, page: QWidget) -> None:
self.stack.setCurrentWidget(page) self.stack.setCurrentWidget(page)
def go_back(self): def _go_back(self) -> None:
self.stack.setCurrentWidget(self.main_page) if self._save_pending_settings():
self.stack.setCurrentWidget(self.main_page)
def hideEvent(self, event) -> None:
self._save_pending_settings()
super().hideEvent(event)
+6
View File
@@ -10,8 +10,11 @@ authors = [
dependencies = [ dependencies = [
"colorlog>=6.9,<7", "colorlog>=6.9,<7",
"keyring>=25.6,<26", "keyring>=25.6,<26",
"pydantic>=2.11,<3",
"PySide6>=6.8,<7", "PySide6>=6.8,<7",
"requests>=2.32,<3", "requests>=2.32,<3",
"tomli>=2.2,<3; python_version < '3.11'",
"tomli-w>=1.2,<2",
] ]
[dependency-groups] [dependency-groups]
@@ -21,6 +24,9 @@ build = [
"zstandard>=0.23", "zstandard>=0.23",
"imageio>=2.37.0" "imageio>=2.37.0"
] ]
test = [
"pytest>=8.4,<10",
]
[project.urls] [project.urls]
Homepage = "https://repo.weispace.net/wei/Launcher" Homepage = "https://repo.weispace.net/wei/Launcher"
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+2 -1
View File
@@ -23,7 +23,7 @@ QLabel#profileArrow {
font-size: 18px; font-size: 18px;
} }
QFrame#profilePopup { QFrame#profilePopup {
background: palette(mid); /* background: palette(mid);*/
border: 1px solid palette(midlight); border: 1px solid palette(midlight);
border-radius: 5px; border-radius: 5px;
} }
@@ -32,6 +32,7 @@ QPushButton#profileItem {
background: transparent; background: transparent;
border: none; border: none;
text-align: left; text-align: left;
border-radius: 5px;
} }
QPushButton#profileItem:hover { QPushButton#profileItem:hover {
+1 -1
View File
@@ -204,7 +204,7 @@ class LauncherMainWindow(QMainWindow):
# current account's avatar # current account's avatar
# Profile btn # Profile btn
self.open_create_profile_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "add_temp.png"))) self.open_create_profile_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "add.png")))
# Bind tool buttons # Bind tool buttons
self.toolbar.addWidget(self.home_button) self.toolbar.addWidget(self.home_button)