This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user