Files
pristontale-eu-linux/ptlinux/settings.py

132 lines
4.2 KiB
Python

"""User-editable launcher settings with conservative defaults."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import json
import os
from pathlib import Path
from .paths import AppPaths
@dataclass(frozen=True)
class Settings:
width: int = 1600
height: int = 900
output_width: int = 1600
output_height: int = 900
refresh_rate: int = 60
fullscreen: bool = False
borderless: bool = True
scaler: str = "fit"
filter: str = "linear"
runtime: str = "auto"
proton_sync: str = "auto"
proton_path: str | None = None
game_source: str | None = None
@property
def source_override(self) -> Path | None:
return Path(self.game_source).expanduser().resolve() if self.game_source else None
def load_settings(paths: AppPaths) -> Settings:
try:
value = json.loads(paths.settings_file.read_text(encoding="utf-8"))
except FileNotFoundError:
return Settings()
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"invalid settings file {paths.settings_file}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError(f"settings file must contain an object: {paths.settings_file}")
settings = Settings(
width=_integer(value, "width", 1600),
height=_integer(value, "height", 900),
output_width=_integer(value, "output_width", 1600),
output_height=_integer(value, "output_height", 900),
refresh_rate=_integer(value, "refresh_rate", 60),
fullscreen=_boolean(value, "fullscreen", False),
borderless=_boolean(value, "borderless", True),
scaler=_string(value, "scaler", "fit"),
filter=_string(value, "filter", "linear"),
runtime=_string(value, "runtime", "auto"),
proton_sync=_string(value, "proton_sync", "auto"),
proton_path=_optional_string(value, "proton_path"),
game_source=_optional_string(value, "game_source"),
)
_validate(settings)
return settings
def save_settings(paths: AppPaths, settings: Settings) -> None:
_validate(settings)
paths.config.mkdir(parents=True, exist_ok=True)
temporary = paths.settings_file.with_name(
f".{paths.settings_file.name}.{os.getpid()}.tmp"
)
temporary.write_text(
json.dumps(asdict(settings), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
temporary.replace(paths.settings_file)
def ensure_settings(paths: AppPaths) -> Settings:
if paths.settings_file.exists():
return load_settings(paths)
settings = Settings()
save_settings(paths, settings)
return settings
def _validate(settings: Settings) -> None:
for name, value in (
("width", settings.width),
("height", settings.height),
("output_width", settings.output_width),
("output_height", settings.output_height),
):
if value < 640 or value > 16384:
raise ValueError(f"{name} must be between 640 and 16384")
if settings.refresh_rate < 24 or settings.refresh_rate > 1000:
raise ValueError("refresh_rate must be between 24 and 1000")
if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}:
raise ValueError(f"unsupported legacy scaler setting: {settings.scaler}")
if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}:
raise ValueError(f"unsupported legacy filter setting: {settings.filter}")
if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}:
raise ValueError(f"unsupported runtime selection: {settings.runtime}")
if settings.proton_sync not in {"auto", "fsync", "esync"}:
raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}")
def _integer(value: dict[str, object], key: str, default: int) -> int:
item = value.get(key, default)
if isinstance(item, bool) or not isinstance(item, int):
raise ValueError(f"{key} must be an integer")
return item
def _boolean(value: dict[str, object], key: str, default: bool) -> bool:
item = value.get(key, default)
if not isinstance(item, bool):
raise ValueError(f"{key} must be a boolean")
return item
def _string(value: dict[str, object], key: str, default: str) -> str:
item = value.get(key, default)
if not isinstance(item, str) or not item:
raise ValueError(f"{key} must be a non-empty string")
return item
def _optional_string(value: dict[str, object], key: str) -> str | None:
item = value.get(key)
if item is None:
return None
if not isinstance(item, str) or not item:
raise ValueError(f"{key} must be null or a non-empty string")
return item