Settings page is "fully" implementation.
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -111,3 +114,19 @@ class AuthenticationSessionException(LauncherException):
|
|||||||
|
|
||||||
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}")
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 |
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user