Files
korin-timer/src/titletimer/config.py
T
magges 7edbd09538
Build AppImage / build-appimage (push) Successful in 42s
Initial commit: Korin Timer
2026-08-03 20:42:26 +02:00

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()