310 lines
12 KiB
Python
310 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import shutil
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "titletimer" / "config.json"
|
|
# Ships inside the titletimer package dir itself (not the repo-root
|
|
# config.example.json, which is just a symlink to this) so it's included
|
|
# automatically wherever src/titletimer is bundled - AppImage included -
|
|
# without needing a separate packaging step.
|
|
EXAMPLE_CONFIG_PATH = Path(__file__).parent / "config.example.json"
|
|
# Same reasoning as EXAMPLE_CONFIG_PATH above - ships inside the package
|
|
# dir so `cp -r src/titletimer` in build-appimage.sh picks it up for free,
|
|
# no separate packaging step. Used for _NET_WM_ICON (see x11_overlay.py:
|
|
# set_wm_icon) since GTK4 dropped gtk_window_set_icon*() entirely and an
|
|
# unintegrated AppImage run has no .desktop file installed anywhere a WM
|
|
# could resolve Icon=titletimer from.
|
|
APP_ICON_PATH = Path(__file__).parent / "app_icon.png"
|
|
|
|
# Each character/profile is its own full config.json under this directory
|
|
# (e.g. profiles/Chung.json, profiles/Elsword.json) - see resolve_active_profile
|
|
# for the migration from the old single-file layout and how the
|
|
# last-active one is remembered across restarts.
|
|
PROFILES_DIR = Path.home() / ".config" / "titletimer" / "profiles"
|
|
LAST_PROFILE_FILE = Path.home() / ".config" / "titletimer" / "last_profile.txt"
|
|
DEFAULT_PROFILE_NAME = "Default"
|
|
|
|
# UI language is an app-wide preference, not per-character, so it lives
|
|
# next to LAST_PROFILE_FILE rather than inside any profile's config.json.
|
|
LANGUAGE_FILE = Path.home() / ".config" / "titletimer" / "language.txt"
|
|
DEFAULT_LANGUAGE = "de"
|
|
VALID_LANGUAGES = {"de", "en"}
|
|
|
|
VALID_ANCHORS = {"top-left", "top-right", "bottom-left", "bottom-right"}
|
|
|
|
|
|
@dataclass
|
|
class TitleConfig:
|
|
id: str
|
|
name: str
|
|
# One or more evdev key names that must all be held down together to
|
|
# trigger this title (e.g. ("KEY_LEFTCTRL", "KEY_F1")) - stored sorted
|
|
# for a stable canonical order; matched as a set (see state.py), not a
|
|
# press sequence.
|
|
hotkey: tuple[str, ...]
|
|
cooldown_seconds: float
|
|
color: str = "#4da6ff"
|
|
# Optional alternate combo that only does step 1 (IDLE -> STANDBY),
|
|
# never step 2 - e.g. a movement key already pressed during normal
|
|
# play, so the main `hotkey` only needs pressing once (for step 2)
|
|
# instead of twice. None = no pre-activation combo configured.
|
|
pre_hotkey: tuple[str, ...] | None = None
|
|
# If set, STANDBY (step 1) automatically reverts to IDLE after this many
|
|
# seconds without step 2 - so a forgotten/stale standby (especially one
|
|
# entered via pre_hotkey, which can trigger far more casually than a
|
|
# deliberate hotkey press) doesn't linger indefinitely. None = no
|
|
# timeout, stays in STANDBY until step 2 or another title bumps it.
|
|
standby_timeout_seconds: float | None = None
|
|
# If True, pressing the main hotkey while COOLDOWN is already running
|
|
# restarts it (fresh cooldown_seconds from now) instead of being
|
|
# ignored - for buffs you want to be able to refresh/extend by
|
|
# re-triggering rather than only ever timing out on their own.
|
|
restart_on_repeat: bool = False
|
|
# Absolute path to an image file (PNG/SVG/...), shown left of the name
|
|
# in the overlay row. Optional - a title without one just shows text.
|
|
icon: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class OverlayConfig:
|
|
anchor: str = "top-right"
|
|
margin_x: int = 24
|
|
margin_y: int = 24
|
|
scale: float = 1.0
|
|
# Absolute root-window position, in pixels. Set once the user
|
|
# Ctrl+Alt-drags the overlay (see drag.py); overrides anchor/margin
|
|
# placement when both are present so a dragged position survives
|
|
# restarts.
|
|
x: int | None = None
|
|
y: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class AppConfig:
|
|
dogma_mode: bool = True
|
|
overlay: OverlayConfig = field(default_factory=OverlayConfig)
|
|
titles: list[TitleConfig] = field(default_factory=list)
|
|
|
|
|
|
def load_config(path: Path | None = None) -> AppConfig:
|
|
path = path or DEFAULT_CONFIG_PATH
|
|
if not path.exists():
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(EXAMPLE_CONFIG_PATH.read_text())
|
|
logger.info("No config found, created default at %s from bundled example.", path)
|
|
|
|
raw = json.loads(path.read_text())
|
|
|
|
overlay_raw = raw.get("overlay", {})
|
|
anchor = overlay_raw.get("anchor", "top-right")
|
|
if anchor not in VALID_ANCHORS:
|
|
raise ValueError(f"overlay.anchor must be one of {VALID_ANCHORS}, got {anchor!r}")
|
|
|
|
overlay_x = overlay_raw.get("x")
|
|
overlay_y = overlay_raw.get("y")
|
|
overlay = OverlayConfig(
|
|
anchor=anchor,
|
|
margin_x=int(overlay_raw.get("margin_x", 24)),
|
|
margin_y=int(overlay_raw.get("margin_y", 24)),
|
|
scale=float(overlay_raw.get("scale", 1.0)),
|
|
x=int(overlay_x) if overlay_x is not None else None,
|
|
y=int(overlay_y) if overlay_y is not None else None,
|
|
)
|
|
|
|
titles_raw = raw.get("titles", [])
|
|
if not titles_raw:
|
|
raise ValueError("config must define at least one entry under 'titles'")
|
|
|
|
seen_ids: set[str] = set()
|
|
seen_hotkeys: set[frozenset[str]] = set()
|
|
titles: list[TitleConfig] = []
|
|
for entry in titles_raw:
|
|
for required in ("id", "name", "hotkey", "cooldown_seconds"):
|
|
if required not in entry:
|
|
raise ValueError(f"title entry missing required field {required!r}: {entry}")
|
|
|
|
title_id = entry["id"]
|
|
hotkey_raw = entry["hotkey"]
|
|
# Accept a bare string for backwards compatibility with configs
|
|
# written before multi-key combos existed.
|
|
hotkey = tuple(sorted((hotkey_raw,) if isinstance(hotkey_raw, str) else hotkey_raw))
|
|
if not hotkey:
|
|
raise ValueError(f"title entry has an empty hotkey: {entry}")
|
|
hotkey_combo = frozenset(hotkey)
|
|
|
|
pre_hotkey_raw = entry.get("pre_hotkey")
|
|
pre_hotkey = tuple(sorted(pre_hotkey_raw)) if pre_hotkey_raw else None
|
|
pre_hotkey_combo = frozenset(pre_hotkey) if pre_hotkey else None
|
|
|
|
if title_id in seen_ids:
|
|
raise ValueError(f"duplicate title id: {title_id}")
|
|
if hotkey_combo in seen_hotkeys:
|
|
raise ValueError(f"duplicate hotkey binding: {hotkey}")
|
|
if pre_hotkey_combo is not None and pre_hotkey_combo in seen_hotkeys:
|
|
raise ValueError(f"duplicate hotkey binding: {pre_hotkey}")
|
|
seen_ids.add(title_id)
|
|
seen_hotkeys.add(hotkey_combo)
|
|
if pre_hotkey_combo is not None:
|
|
seen_hotkeys.add(pre_hotkey_combo)
|
|
|
|
standby_timeout = entry.get("standby_timeout_seconds")
|
|
titles.append(
|
|
TitleConfig(
|
|
id=title_id,
|
|
name=entry["name"],
|
|
hotkey=hotkey,
|
|
cooldown_seconds=float(entry["cooldown_seconds"]),
|
|
color=entry.get("color", "#4da6ff"),
|
|
pre_hotkey=pre_hotkey,
|
|
standby_timeout_seconds=float(standby_timeout) if standby_timeout else None,
|
|
restart_on_repeat=bool(entry.get("restart_on_repeat", False)),
|
|
icon=entry.get("icon"),
|
|
)
|
|
)
|
|
|
|
return AppConfig(
|
|
dogma_mode=bool(raw.get("dogma_mode", True)),
|
|
overlay=overlay,
|
|
titles=titles,
|
|
)
|
|
|
|
|
|
def save_config(path: Path, config: AppConfig) -> None:
|
|
"""Serialize a full AppConfig to disk - used by the settings window,
|
|
which already holds a complete, validated AppConfig in memory.
|
|
|
|
Unlike persist_overlay_position (a narrow read-modify-write patch),
|
|
this rewrites the whole file, so any keys this app doesn't know about
|
|
are lost - acceptable here since the settings window is the canonical
|
|
editor for the whole schema.
|
|
"""
|
|
raw: dict = {
|
|
"dogma_mode": config.dogma_mode,
|
|
"overlay": {
|
|
"anchor": config.overlay.anchor,
|
|
"margin_x": config.overlay.margin_x,
|
|
"margin_y": config.overlay.margin_y,
|
|
"scale": config.overlay.scale,
|
|
},
|
|
"titles": [
|
|
{
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"hotkey": list(t.hotkey),
|
|
"cooldown_seconds": t.cooldown_seconds,
|
|
"color": t.color,
|
|
**({"pre_hotkey": list(t.pre_hotkey)} if t.pre_hotkey else {}),
|
|
**(
|
|
{"standby_timeout_seconds": t.standby_timeout_seconds}
|
|
if t.standby_timeout_seconds
|
|
else {}
|
|
),
|
|
**({"restart_on_repeat": True} if t.restart_on_repeat else {}),
|
|
**({"icon": t.icon} if t.icon else {}),
|
|
}
|
|
for t in config.titles
|
|
],
|
|
}
|
|
if config.overlay.x is not None and config.overlay.y is not None:
|
|
raw["overlay"]["x"] = config.overlay.x
|
|
raw["overlay"]["y"] = config.overlay.y
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(raw, indent=2))
|
|
|
|
|
|
def persist_overlay_position(path: Path, x: int, y: int) -> None:
|
|
"""Write a dragged overlay position back to the config file in place.
|
|
|
|
Rewrites only overlay.x/overlay.y, leaving every other key (including
|
|
ones this app doesn't know about) untouched.
|
|
"""
|
|
raw = json.loads(path.read_text())
|
|
raw.setdefault("overlay", {})["x"] = x
|
|
raw["overlay"]["y"] = y
|
|
path.write_text(json.dumps(raw, indent=2))
|
|
|
|
|
|
def load_language() -> str:
|
|
if LANGUAGE_FILE.exists():
|
|
lang = LANGUAGE_FILE.read_text().strip()
|
|
if lang in VALID_LANGUAGES:
|
|
return lang
|
|
return DEFAULT_LANGUAGE
|
|
|
|
|
|
def save_language(lang: str) -> None:
|
|
if lang not in VALID_LANGUAGES:
|
|
raise ValueError(f"language must be one of {VALID_LANGUAGES}, got {lang!r}")
|
|
LANGUAGE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
LANGUAGE_FILE.write_text(lang)
|
|
|
|
|
|
def _validate_profile_name(name: str) -> str:
|
|
name = name.strip()
|
|
if not name:
|
|
raise ValueError("Charaktername darf nicht leer sein.")
|
|
if "/" in name or "\\" in name or name in (".", ".."):
|
|
raise ValueError(f"Ungültiger Charaktername: {name!r}")
|
|
return name
|
|
|
|
|
|
def profile_path(name: str) -> Path:
|
|
return PROFILES_DIR / f"{_validate_profile_name(name)}.json"
|
|
|
|
|
|
def list_profiles() -> list[str]:
|
|
if not PROFILES_DIR.is_dir():
|
|
return []
|
|
return sorted(p.stem for p in PROFILES_DIR.glob("*.json"))
|
|
|
|
|
|
def resolve_active_profile() -> str:
|
|
"""Figure out which profile to load at startup.
|
|
|
|
On the very first run under the profile system, migrates the old
|
|
single `config.json` (if present) into `profiles/Default.json` so
|
|
existing users keep their setup instead of falling back to the
|
|
bundled example. Otherwise returns the last-active profile (see
|
|
set_active_profile), falling back to the alphabetically first one if
|
|
that name no longer exists (e.g. it was since deleted/renamed).
|
|
"""
|
|
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
|
profiles = list_profiles()
|
|
if not profiles:
|
|
if DEFAULT_CONFIG_PATH.exists():
|
|
DEFAULT_CONFIG_PATH.rename(profile_path(DEFAULT_PROFILE_NAME))
|
|
logger.info(
|
|
"Migrated existing config.json to profile %r.", DEFAULT_PROFILE_NAME
|
|
)
|
|
return DEFAULT_PROFILE_NAME
|
|
|
|
if LAST_PROFILE_FILE.exists():
|
|
last = LAST_PROFILE_FILE.read_text().strip()
|
|
if last in profiles:
|
|
return last
|
|
return profiles[0]
|
|
|
|
|
|
def set_active_profile(name: str) -> None:
|
|
LAST_PROFILE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
LAST_PROFILE_FILE.write_text(name)
|
|
|
|
|
|
def rename_profile(name: str, new_name: str) -> None:
|
|
profile_path(name).rename(profile_path(new_name))
|
|
|
|
|
|
def duplicate_profile(name: str, new_name: str) -> None:
|
|
shutil.copy(profile_path(name), profile_path(new_name))
|
|
|
|
|
|
def delete_profile(name: str) -> None:
|
|
profile_path(name).unlink()
|