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