diff --git a/app.py b/app.py index 3801a12..37c5430 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,8 @@ from core_lib.launcher.object import Launcher as LauncherObject from manager.account import AccountManager from manager.game import GameManager from manager.profile import ProfileManager +from manager.settings import SettingsManager +from manager.settings_models import AccountSettings, AppearanceSettings, GeneralSettings from manager.widgets import WidgetManager from window import LauncherMainWindow @@ -31,6 +33,14 @@ class Launcher(QApplication, LauncherObject): # Widget (Signal, Event) self.widget_manager = WidgetManager() # 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.account_manager = AccountManager(self, self.widget_manager) self.game_manager = GameManager(self) @@ -123,6 +133,10 @@ class Launcher(QApplication, LauncherObject): def log_dir(self): return self.work_dir / "log" + @property + def root_settings_dir(self): + return self.data_dir / "settings.toml" + @property def default_game_dir(self) -> Path: return self.work_dir / "minecraft" if sys.platform.lower() == "darwin" else self.work_dir / ".minecraft" diff --git a/core_lib/common/exception.py b/core_lib/common/exception.py index 3dbf6e0..11604f2 100644 --- a/core_lib/common/exception.py +++ b/core_lib/common/exception.py @@ -1,4 +1,7 @@ from pathlib import Path +from typing import Mapping + +from pydantic import ValidationError class LauncherException(Exception): @@ -110,4 +113,20 @@ class AuthenticationSessionException(LauncherException): user_message = "Authentication failed. Session may expire." class ThirdPartyAPIException(LauncherException): - user_message = "An error occurred while fetching third party API." \ No newline at end of file + 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}") \ No newline at end of file diff --git a/main.py b/main.py index 28cb198..45ec42e 100644 --- a/main.py +++ b/main.py @@ -71,9 +71,10 @@ def main(): sys.exit(-1) else: # check workdir - logger.error("Work directory '%s' does not exist." % work_dir) - show_error(f"Specified work directory \"{work_dir}\" does not exist or is not a directory.") - sys.exit(-1) + if not os.path.exists(work_dir) or not os.path.isdir(work_dir): + logger.error("Work directory '%s' does not exist." % work_dir) + 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: os.chdir(work_dir) diff --git a/manager/account.py b/manager/account.py index e09119d..dc5a45d 100644 --- a/manager/account.py +++ b/manager/account.py @@ -910,13 +910,13 @@ class AccountManager(QObject): # # 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: return False # Create login worker and run it in outer thread 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.device_code_ready.connect( diff --git a/manager/game.py b/manager/game.py index b7805b3..71fb217 100644 --- a/manager/game.py +++ b/manager/game.py @@ -4,7 +4,6 @@ import subprocess import threading import time import traceback -import types from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path @@ -671,6 +670,7 @@ class LaunchManager: self.java_mgr = java_manager self.game_file_mgr = game_file_manager self.account_mgr = account_manager + self.launching_profiles: set[str] = set() self.launched_profiles: list[str] = [] self.signal = self.LaunchSignal() @@ -879,12 +879,20 @@ class LaunchManager: return result - def start_launch_task(self, profile: LaunchProfile) -> Thread: + def start_launch_task(self, profile: LaunchProfile) -> Thread | None: def worker(): result = self.launch(profile) self.signal.finished.emit(profile, 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( target=worker, name=f"launch-{profile.profile_id}", @@ -893,8 +901,11 @@ class LaunchManager: thread.start() return thread - def is_profile_running(self, profile_id): - return profile_id in self.launched_profiles + def is_profile_running(self, profile_id: str) -> bool: + 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: try: diff --git a/manager/profile.py b/manager/profile.py index 1545065..b1df0c3 100644 --- a/manager/profile.py +++ b/manager/profile.py @@ -251,7 +251,10 @@ class ProfileManager(QObject): ) 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: if not self.contains(profile_id, show_error=True): @@ -701,3 +704,37 @@ class ProfileManager(QObject): @property def is_modified(self) -> bool: 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 diff --git a/manager/settings.py b/manager/settings.py new file mode 100644 index 0000000..6e71a45 --- /dev/null +++ b/manager/settings.py @@ -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 diff --git a/pages/account.py b/pages/account.py index d613c67..b38469e 100644 --- a/pages/account.py +++ b/pages/account.py @@ -17,10 +17,11 @@ from PySide6.QtWidgets import ( 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 manager.account import AccountManager, AccountSummary +from manager.settings_models import AccountSettings class AccountWidget(QWidget): @@ -162,7 +163,6 @@ class AccountPage(QWidget): self.app = app self.widget_manager = app.widget_manager self.account_manager: AccountManager = app.account_manager - self.client_id = CLIENT_ID self.login_dialog = None @@ -343,7 +343,8 @@ class AccountPage(QWidget): self.login_dialog.rejected.connect(self.account_manager.cancel_login) 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): self._verification_uri = device.get("verification_uri", MICROSOFT_VERIFY_ENDPOINT) diff --git a/pages/launch_profile.py b/pages/launch_profile.py index 3fdfdbd..d2c4a78 100644 --- a/pages/launch_profile.py +++ b/pages/launch_profile.py @@ -362,8 +362,18 @@ class LaunchProfilePage(QWidget): profiles: list[ProfileSummary] = self.profile_manager.list_profiles() self.profile_popup.load_profiles(profiles) - if len(profiles) > 0: - self.select_profile(profiles[0]) + if not profiles: + 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: if self.profile_popup.isVisible(): diff --git a/pages/settings.py b/pages/settings.py index c2f72d1..5bb6b30 100644 --- a/pages/settings.py +++ b/pages/settings.py @@ -1,328 +1,275 @@ -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QPalette, QFont +from pathlib import Path + +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QHBoxLayout, QLabel, + QLineEdit, + QMessageBox, + QPlainTextEdit, QPushButton, - QSlider, QStackedWidget, 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): - """ - A modern look settings button. - """ - clicked_page = Signal() - - def __init__(self, title: str, description: str = ""): + def __init__(self, title: str, description: str = "") -> None: super().__init__() - + self.setObjectName("settingsItem") self.setCursor(Qt.CursorShape.PointingHandCursor) - # Widget size - self.setMinimumHeight(68) - self.setMaximumWidth(800) - # Layout layout = QVBoxLayout(self) layout.setContentsMargins(16, 10, 16, 10) layout.setSpacing(3) - # Widgets - self.title_label = QLabel(title) - self.title_label.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents - ) + title_label = QLabel(title) + title_label.setObjectName("settingsItemTitle") + title_label.setAttribute(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: - layout.addWidget(self.description_label) - - self.update_palette() - - def update_palette(self): - """ - 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) + description_label = QLabel(description) + description_label.setObjectName("settingsItemDescription") + description_label.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents + ) + layout.addWidget(description_label) class SettingsSubPage(QWidget): - def __init__(self, title: str, back_callback): + def __init__(self, title: str, back_callback) -> None: super().__init__() + self.setObjectName("settingsSubPage") main_layout = QVBoxLayout(self) main_layout.setContentsMargins(20, 16, 20, 20) main_layout.setSpacing(16) 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("←") - self.back_button.setFixedSize(36, 36) - self.back_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.back_button.clicked.connect(back_callback) - - 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) + title_label = QLabel(title) + title_label.setObjectName("settingsSubPageTitle") + header_layout.addWidget(back_button) + header_layout.addWidget(title_label) header_layout.addStretch() self.content_layout = QVBoxLayout() self.content_layout.setSpacing(12) - main_layout.addLayout(header_layout) main_layout.addLayout(self.content_layout) 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): - def __init__(self, app, parent=None): + def __init__(self, app, parent=None) -> None: super().__init__(parent) self.app = app - - self.setWindowTitle("Settings") - self.resize(560, 440) + self.settings = app.settings_manager + self._settings_dirty = False + self.setObjectName("settingsPage") root_layout = QVBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) - self.stack = QStackedWidget() - self.main_page = self.create_main_page() - self.general_page = self.create_general_page() - self.appearance_page = self.create_appearance_page() - self.profile_page = self.create_profile_page() - self.about_page = self.create_about_page() - - self.stack.addWidget(self.main_page) - self.stack.addWidget(self.general_page) - self.stack.addWidget(self.appearance_page) - self.stack.addWidget(self.profile_page) - self.stack.addWidget(self.about_page) + self.main_page = self._create_main_page() + self.general_page = self._create_general_page() + self.appearance_page = self._create_appearance_page() + self.account_page = self._create_account_page() + self.about_page = self._create_about_page() + for page in ( + self.main_page, + self.general_page, + self.appearance_page, + self.account_page, + self.about_page, + ): + self.stack.addWidget(page) 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.setObjectName("settingsMainPage") layout = QVBoxLayout(page) - layout.setContentsMargins(24, 20, 24, 24) - layout.setSpacing(12) + layout.setContentsMargins(0, 0, 0, 0) + + 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.setObjectName("settingsPageTitle") + scroll_layout.addWidget(title) + scroll_layout.addSpacing(8) - title_font = title.font() - title_font.setPointSize(18) - title_font.setBold(True) - title.setFont(title_font) - - # Setting options - info_button = SettingsItem( - "Wait!", - "Setting page is not full implemented yet.", - ) - - general_button = SettingsItem( - "General", - "Some general settings for launcher ", - ) - 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() + pages = ( + ("General", "Language and launcher directories", "general_page"), + ("Appearance", "Launcher theme", "appearance_page"), + ("Account", "Microsoft authentication settings", "account_page"), + ("About", "Version and project information", "about_page"), + ) + for title_text, description, page_name in pages: + button = SettingsItem(title_text, description) + button.clicked.connect( + lambda checked=False, name=page_name: self._open_page( + getattr(self, name) + ) + ) + scroll_layout.addWidget(button) + scroll_layout.addStretch() + layout.addWidget(scroll_area) return page - def create_general_page(self) -> QWidget: - page = SettingsSubPage("General", self.go_back) + def _create_general_page(self) -> QWidget: + page = SettingsSubPage("General", self._go_back) + current = self.settings.get("general", GeneralSettings) - language_label = QLabel("Language") - - language_combo = QComboBox() - language_combo.addItems([ - "English", - "Chinese (Traditional)", - ]) - - launcher_dir_label = QLabel("Current launcher working directory") + page.content_layout.addWidget(QLabel("Current launcher working directory")) launcher_dir = QLineEdit(self.app.work_dir.as_posix()) 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.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.addSpacing(8) - page.content_layout.addWidget(language_label) - page.content_layout.addWidget(language_combo) - + page.content_layout.addWidget(QLabel("Language")) + self.language_combo = self._choice_combo( + "general", "language", LANGUAGE_LABELS, current.language + ) + page.content_layout.addWidget(self.language_combo) return page - def create_appearance_page(self) -> QWidget: - page = SettingsSubPage("Appearance", self.go_back) - - 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) + def _create_appearance_page(self) -> QWidget: + page = SettingsSubPage("Appearance", self._go_back) + current = self.settings.get("appearance", AppearanceSettings) + 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 - def create_profile_page(self) -> QWidget: - page = SettingsSubPage("Profile", self.go_back) + def _create_account_page(self) -> QWidget: + page = SettingsSubPage("Account", self._go_back) + current = self.settings.get("account", AccountSettings) - test_label = QLabel("Still in development...") - - page.content_layout.addWidget(test_label) + page.content_layout.addWidget(QLabel("Microsoft OAuth Client ID")) + self.client_id_edit = QLineEdit(str(current.client_id)) + 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 - def create_about_page(self) -> QWidget: - page = SettingsSubPage("About", self.go_back) - + def _create_about_page(self) -> QWidget: + page = SettingsSubPage("About", self._go_back) brand_label = QLabel(LAUNCHER_NAME) - brand_label.setStyleSheet("QLabel { font-size: 20px; }") - - version_label = QLabel("Version: {}".format(LAUNCHER_VERSION)) - version_label.setStyleSheet("QLabel { font-size: 12px; }") - - text = QPlainTextEdit(LAUNCHER_DESCRIPTION) - text.setStyleSheet("QPlainTextEdit { font-size: 13px; }") - text.setReadOnly(True) + brand_label.setObjectName("settingsBrand") + version_label = QLabel(f"Version: {LAUNCHER_VERSION}") + version_label.setObjectName("settingsVersion") + description = QPlainTextEdit(LAUNCHER_DESCRIPTION) + description.setObjectName("settingsDescription") + description.setReadOnly(True) page.content_layout.addWidget(brand_label) page.content_layout.addWidget(version_label) - page.content_layout.addWidget(text) - + page.content_layout.addWidget(description) 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) - def go_back(self): - self.stack.setCurrentWidget(self.main_page) \ No newline at end of file + def _go_back(self) -> None: + if self._save_pending_settings(): + self.stack.setCurrentWidget(self.main_page) + + def hideEvent(self, event) -> None: + self._save_pending_settings() + super().hideEvent(event) diff --git a/pyproject.toml b/pyproject.toml index d13a710..4565840 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,11 @@ authors = [ dependencies = [ "colorlog>=6.9,<7", "keyring>=25.6,<26", + "pydantic>=2.11,<3", "PySide6>=6.8,<7", "requests>=2.32,<3", + "tomli>=2.2,<3; python_version < '3.11'", + "tomli-w>=1.2,<2", ] [dependency-groups] @@ -21,6 +24,9 @@ build = [ "zstandard>=0.23", "imageio>=2.37.0" ] +test = [ + "pytest>=8.4,<10", +] [project.urls] Homepage = "https://repo.weispace.net/wei/Launcher" diff --git a/resources/icons/add.png b/resources/icons/add.png new file mode 100644 index 0000000..9e157ee Binary files /dev/null and b/resources/icons/add.png differ diff --git a/resources/icons/add_temp.png b/resources/icons/add_temp.png index 7fb3fb4..d3d2c57 100644 Binary files a/resources/icons/add_temp.png and b/resources/icons/add_temp.png differ diff --git a/resources/icons/settings.png b/resources/icons/settings.png new file mode 100644 index 0000000..d029c39 Binary files /dev/null and b/resources/icons/settings.png differ diff --git a/resources/styles/profile.qss b/resources/styles/profile.qss index ec9fdf5..6f93b13 100644 --- a/resources/styles/profile.qss +++ b/resources/styles/profile.qss @@ -23,7 +23,7 @@ QLabel#profileArrow { font-size: 18px; } QFrame#profilePopup { - background: palette(mid); +/* background: palette(mid);*/ border: 1px solid palette(midlight); border-radius: 5px; } @@ -32,6 +32,7 @@ QPushButton#profileItem { background: transparent; border: none; text-align: left; + border-radius: 5px; } QPushButton#profileItem:hover { diff --git a/window.py b/window.py index 226750b..21bbea5 100644 --- a/window.py +++ b/window.py @@ -204,7 +204,7 @@ class LauncherMainWindow(QMainWindow): # 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_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 self.toolbar.addWidget(self.home_button)