commit 7edbd095382317fc7063c0d9f8908321a2307b79 Author: magges Date: Mon Aug 3 20:42:26 2026 +0200 Initial commit: Korin Timer diff --git a/.gitea/workflows/build-appimage.yaml b/.gitea/workflows/build-appimage.yaml new file mode 100644 index 0000000..8073491 --- /dev/null +++ b/.gitea/workflows/build-appimage.yaml @@ -0,0 +1,46 @@ +name: Build AppImage + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-appimage: + runs-on: ubuntu-latest + container: node:24-trixie + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gobject-introspection \ + gir1.2-gtk-4.0 \ + gir1.2-gtk4layershell-1.0 \ + python3 \ + python3-cairo \ + python3-evdev \ + python3-gi \ + python3-gi-cairo \ + python3-xlib \ + squashfs-tools + + - name: Build AppImage + run: ./packaging/build-appimage.sh + + - name: Upload AppImage + uses: actions/upload-artifact@v3 + with: + name: Korin-Timer.AppImage + path: build/Korin-Timer.AppImage + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83fc18a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +build/ +*.AppImage +docs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9099ec --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Korin Timer + +Korin Timer ist ein transparentes Linux-Overlay für Elsword. Es zeigt die +Cooldowns deiner Titel an und lässt sich komplett über globale Hotkeys +bedienen. + +## Schnellstart + +1. Öffne im Repository den Bereich **Releases** und lade + `Korin-Timer.AppImage` herunter. +2. Mache die Datei ausführbar und starte sie: + + ```bash + chmod +x Korin-Timer.AppImage + ./Korin-Timer.AppImage + ``` + +3. Beim ersten Start öffnen sich Overlay und Einstellungsfenster. Lege dort + deine Titel, Hotkeys und Cooldowns an und klicke auf **Speichern**. + +Die App benötigt Leserechte auf die Eingabegeräte, damit Hotkeys außerhalb +des Fensters funktionieren. Führe einmal aus und melde dich anschließend +vollständig ab und wieder an: + +```bash +sudo usermod -aG input "$USER" +``` + +## Bedienung + +- Drücke den Hotkey eines Titels, um seinen Cooldown zu starten. +- Optional kann ein Titel erst per Vor-Aktivierungs-Hotkey in den Standby + wechseln. Erst der normale Hotkey startet dann den Cooldown. +- Mit **Neu triggerbar** startet derselbe Hotkey einen bereits laufenden + Cooldown erneut. +- Im Dogma-Modus ist immer nur ein Titel gleichzeitig aktiv. +- Verschiebe das Overlay mit **Strg + Alt + linker Maustaste ziehen**. + Die Position wird im aktiven Profil gespeichert. + +Das Einstellungsfenster muss geöffnet bleiben: Sein Schließen beendet auch +das rahmenlose, klickdurchlässige Overlay. + +## Profile + +Jeder Charakter erhält ein eigenes Profil unter +`~/.config/titletimer/profiles/`. Über das Dropdown im Einstellungsfenster +kannst du Profile anlegen, duplizieren, umbenennen, wechseln und löschen. +Ein Wechsel übernimmt die neue Konfiguration sofort; nicht gespeicherte +Änderungen des vorherigen Profils gehen dabei verloren. + +## Neues Release erstellen + +Jeder Push auf `main` startet automatisch einen Gitea-Actions-Build. Nach +einem erfolgreichen Lauf: + +1. Öffne **Actions** und den aktuellen erfolgreichen Build. +2. Lade das Artefakt `Korin-Timer.AppImage` herunter. +3. Öffne **Releases** → **New Release**. +4. Erstelle einen Tag, etwa `v0.1.0`, und hänge die AppImage-Datei an. +5. Veröffentliche das Release. + +Das Actions-Artefakt wird nach 30 Tagen gelöscht; der Release-Anhang bleibt +verfügbar. + +## Aus dem Quellcode starten + +Für Entwicklung brauchst du GTK4, PyGObject, `evdev` und `python-xlib`. +Danach startest du die Anwendung aus dem Projektverzeichnis mit: + +```bash +PYTHONPATH=src python3 -m titletimer +``` + +Nur das Einstellungsfenster öffnest du mit: + +```bash +PYTHONPATH=src python3 -m titletimer --settings +``` + +## AppImage lokal bauen + +Der CI-Workflow ist der empfohlene Buildweg. In einer Debian-artigen +Umgebung kann das AppImage auch lokal gebaut werden: + +```bash +./packaging/build-appimage.sh +``` + +Das Ergebnis liegt anschließend unter `build/Korin-Timer.AppImage` und wird +nicht ins Repository eingecheckt. diff --git a/config.example.json b/config.example.json new file mode 120000 index 0000000..46d7cbc --- /dev/null +++ b/config.example.json @@ -0,0 +1 @@ +src/titletimer/config.example.json \ No newline at end of file diff --git a/packaging/AppRun b/packaging/AppRun new file mode 100755 index 0000000..f5653d9 --- /dev/null +++ b/packaging/AppRun @@ -0,0 +1,16 @@ +#!/bin/bash +set -e +HERE="$(dirname "$(readlink -f "${0}")")" +RT="${HERE}/usr/lib/titletimer/rt" +SRC="${HERE}/usr/lib/titletimer/src" + +# Fully self-contained: our own Python + GTK4 + gtk4-layer-shell + PyGObject +# + pycairo + evdev + python-xlib + ld.so/libc, none of it from the host. +# Needed on targets like NixOS with no FHS python3/GTK4 on PATH at all. +export PYTHONHOME="${RT}/python-lib" +export PYTHONPATH="${RT}:${RT}/python-lib/python3.13:${RT}/python-lib/python3.13/lib-dynload:${SRC}" +export GI_TYPELIB_PATH="${RT}/typelibs" +export LD_LIBRARY_PATH="${RT}/lib" +export GDK_BACKEND=x11 + +exec "${RT}/lib/ld-linux-x86-64.so.2" --library-path "${RT}/lib" "${RT}/bin/python3" -m titletimer "$@" diff --git a/packaging/build-appimage.sh b/packaging/build-appimage.sh new file mode 100755 index 0000000..d63029a --- /dev/null +++ b/packaging/build-appimage.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Builds Korin-Timer.AppImage as a FULLY self-contained +# bundle: its own Python interpreter + stdlib, GTK4, gtk4-layer-shell, +# PyGObject, pycairo, evdev, python-xlib, and every transitive shared +# library they need (~75 .so files) including ld.so + libc themselves. +# +# Why: this targets NixOS, which has no FHS /usr/bin/python3 or system +# GTK4 on PATH at all - "the host probably already has this" (the usual +# AppImage assumption) doesn't hold there. Bundling everything, including +# the dynamic linker, means it doesn't depend on the host's glibc version +# either. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="${ROOT}/build" +APPDIR="${BUILD_DIR}/AppDir" + +rm -rf "${APPDIR}" +mkdir -p "${APPDIR}/usr/lib/titletimer/src" +mkdir -p "${APPDIR}/usr/share/applications" +mkdir -p "${APPDIR}/usr/share/icons/hicolor/64x64/apps" +mkdir -p "${APPDIR}/usr/share/icons/hicolor/128x128/apps" +mkdir -p "${APPDIR}/usr/share/icons/hicolor/256x256/apps" + +cp -r "${ROOT}/src/titletimer" "${APPDIR}/usr/lib/titletimer/src/" +cp "${ROOT}/packaging/AppRun" "${APPDIR}/AppRun" +chmod +x "${APPDIR}/AppRun" +cp "${ROOT}/packaging/korin-timer.desktop" "${APPDIR}/" +cp "${ROOT}/packaging/korin-timer.desktop" "${APPDIR}/usr/share/applications/" +# Pre-sized on the dev host (packaging/icon-*.png) rather than resized here at +# build time - keeps this script's dependencies to what it already needs +# (no ImageMagick/GdkPixbuf-CLI requirement inside the build container). +cp "${ROOT}/packaging/icon-256.png" "${APPDIR}/titletimer.png" +cp "${ROOT}/packaging/icon-64.png" "${APPDIR}/usr/share/icons/hicolor/64x64/apps/titletimer.png" +cp "${ROOT}/packaging/icon-128.png" "${APPDIR}/usr/share/icons/hicolor/128x128/apps/titletimer.png" +cp "${ROOT}/packaging/icon-256.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/titletimer.png" + +bash "${ROOT}/packaging/bundle-runtime.sh" "${APPDIR}/usr/lib/titletimer/rt" + +OUT="${BUILD_DIR}/Korin-Timer.AppImage" +APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage" +if [ ! -x "${APPIMAGETOOL}" ]; then + echo "Downloading appimagetool..." + curl -L -o "${APPIMAGETOOL}" \ + https://github.com/AppImage/appimagetool/releases/latest/download/appimagetool-x86_64.AppImage + chmod +x "${APPIMAGETOOL}" +fi + +if ARCH=x86_64 "${APPIMAGETOOL}" "${APPDIR}" "${OUT}" 2>/tmp/appimagetool.err; then + echo "Built ${OUT}" +else + # appimagetool's AppImage is itself a static-PIE ELF (no PT_INTERP). Some + # restricted/sandboxed environments can't exec that binary format at all + # (execve fails outright, unrelated to FUSE) - if so, fall back to + # assembling the AppImage by hand: a type-2 runtime stub concatenated with + # a plain squashfs of the AppDir is exactly what appimagetool does + # internally anyway. + echo "appimagetool failed to run directly (see /tmp/appimagetool.err)." >&2 + echo "Falling back to manual runtime+squashfs assembly..." >&2 + + RUNTIME="${BUILD_DIR}/runtime-x86_64" + if [ ! -f "${RUNTIME}" ]; then + curl -L -o "${RUNTIME}" \ + https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64 + fi + + SQUASHFS="${BUILD_DIR}/AppDir.squashfs" + rm -f "${SQUASHFS}" + mksquashfs "${APPDIR}" "${SQUASHFS}" -root-owned -noappend -quiet + + cat "${RUNTIME}" "${SQUASHFS}" > "${OUT}" + chmod +x "${OUT}" + echo "Built ${OUT} (manual assembly)" +fi diff --git a/packaging/bundle-runtime.sh b/packaging/bundle-runtime.sh new file mode 100755 index 0000000..3a7763f --- /dev/null +++ b/packaging/bundle-runtime.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# Bundles a fully self-contained Python + GTK4 + gtk4-layer-shell + pycairo +# runtime into $1, closing over the *entire* shared-library dependency graph +# via ldd (not just evdev/python-xlib like the original build). This is for +# targets like NixOS that have no FHS /usr/bin/python3 or system GTK4 on +# PATH at all, so relying on "the host probably has this" doesn't hold. +# +# Includes the target's own ld.so + libc so it doesn't depend on the host +# glibc version either - AppRun invokes that ld.so explicitly with +# --library-path instead of a normal dynamic-linked exec. +set -euo pipefail + +RT="$1" +mkdir -p "${RT}/lib" "${RT}/bin" "${RT}/typelibs" + +PYBIN="$(python3 -c 'import sys; print(sys.executable)')" +STDLIB="$(python3 -c 'import sysconfig; print(sysconfig.get_path("stdlib"))')" + +echo "Bundling python3 (${PYBIN}) + stdlib (${STDLIB})..." +cp "${PYBIN}" "${RT}/bin/python3" +mkdir -p "${RT}/python-lib" +cp -r "${STDLIB}" "${RT}/python-lib/" + +DIST_PKGS="/usr/lib/python3/dist-packages" +SEEDS=( + "${PYBIN}" + "${DIST_PKGS}/gi/_gi.cpython-313-x86_64-linux-gnu.so" + "${DIST_PKGS}/gi/_gi_cairo.cpython-313-x86_64-linux-gnu.so" + "${DIST_PKGS}/cairo/_cairo.cpython-313-x86_64-linux-gnu.so" + "/usr/lib/x86_64-linux-gnu/libgtk-4.so.1" + "/usr/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0" +) + +echo "Copying gi, cairo, evdev, Xlib Python packages..." +cp -r "${DIST_PKGS}/gi" "${RT}/" +cp -r "${DIST_PKGS}/cairo" "${RT}/" +cp -r "${DIST_PKGS}/evdev" "${RT}/" +cp -r "${DIST_PKGS}/Xlib" "${RT}/" +for so in "${DIST_PKGS}"/evdev/*.so; do + SEEDS+=("$so") +done + +echo "Resolving full shared-library closure via ldd..." +declare -A SEEN +QUEUE=("${SEEDS[@]}") +while [ "${#QUEUE[@]}" -gt 0 ]; do + f="${QUEUE[0]}" + QUEUE=("${QUEUE[@]:1}") + realf="$(readlink -f "$f")" + fname="$(basename "$f")" + realname="$(basename "$realf")" + + # ldd/DT_NEEDED reference libraries by their SONAME (e.g. + # libgirepository-1.0.so.1), which on Debian is often just a symlink to a + # fully-versioned real file (libgirepository-1.0.so.1.0.0). readlink -f + # collapses that symlink, so without this alias the dynamic linker can't + # find the file under the name it's actually asked for at runtime. + if [ "$fname" != "$realname" ] && [ ! -e "${RT}/lib/${fname}" ]; then + ln -s "$realname" "${RT}/lib/${fname}" + fi + + [ -n "${SEEN[$realf]:-}" ] && continue + SEEN["$realf"]=1 + cp -n "$realf" "${RT}/lib/${realname}" 2>/dev/null || true + + for tok in $(ldd "$realf" 2>/dev/null | grep -o '/[^ ]*'); do + QUEUE+=("$tok") + done +done +echo "Bundled ${#SEEN[@]} shared libraries." + +echo "Resolving full typelib closure via GIRepository dependency graph..." +TYPELIB_DIR="/usr/lib/x86_64-linux-gnu/girepository-1.0" +# Hardcoding the namespace list by hand previously missed transitive deps +# (Graphene-1.0, freetype2-2.0, xlib-2.0 were absent, breaking `import gi` +# on Gtk.init() with "Typelib file for namespace 'Graphene' not found"). +# Instead ask GIRepository itself what Gtk/GdkX11/GdkWayland/Gtk4LayerShell +# transitively require, so the list can't silently drift from reality. +NAMESPACES="$(python3 -c ' +import gi +from gi.repository import GIRepository +r = GIRepository.Repository.get_default() +roots = [("Gtk", "4.0"), ("GdkX11", "4.0"), ("GdkWayland", "4.0"), ("Gtk4LayerShell", "1.0")] +seen = {} +stack = [] +for ns, ver in roots: + r.require(ns, ver, 0) + seen[ns] = ver + stack.append(ns) +i = 0 +while i < len(stack): + ns = stack[i] + i += 1 + for d in r.get_immediate_dependencies(ns): + dep_ns, dep_ver = d.rsplit("-", 1) + if dep_ns not in seen: + r.require(dep_ns, dep_ver, 0) + seen[dep_ns] = dep_ver + stack.append(dep_ns) +for ns, ver in sorted(seen.items()): + print(f"{ns}-{ver}") +' 2>/dev/null)" + +for name in ${NAMESPACES}; do + src="${TYPELIB_DIR}/${name}.typelib" + [ -f "$src" ] && cp "$src" "${RT}/typelibs/" +done +echo "Bundled typelibs: ${NAMESPACES}" + +echo "Runtime bundle assembled in ${RT}" diff --git a/packaging/icon-128.png b/packaging/icon-128.png new file mode 100644 index 0000000..018acc7 Binary files /dev/null and b/packaging/icon-128.png differ diff --git a/packaging/icon-256.png b/packaging/icon-256.png new file mode 100644 index 0000000..a895fc7 Binary files /dev/null and b/packaging/icon-256.png differ diff --git a/packaging/icon-64.png b/packaging/icon-64.png new file mode 100644 index 0000000..03d98fd Binary files /dev/null and b/packaging/icon-64.png differ diff --git a/packaging/icon.png b/packaging/icon.png new file mode 100644 index 0000000..34e633e Binary files /dev/null and b/packaging/icon.png differ diff --git a/packaging/korin-timer.desktop b/packaging/korin-timer.desktop new file mode 100644 index 0000000..d950196 --- /dev/null +++ b/packaging/korin-timer.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Korin Timer +Comment=2-step title switching timer for Elsword +Exec=AppRun +Icon=titletimer +Categories=Game;Utility; +Terminal=false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..45e0b6b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# PyGObject (Gtk4) and gi.repository.Gtk4LayerShell come from your distro's +# system packages, NOT from pip - they wrap C libraries tied to your GTK +# install. On Arch/CachyOS: +# sudo pacman -S python-gobject gtk4 gtk4-layer-shell +# On Debian/Ubuntu: +# sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-gtk4layershell-1.0 +# +# Only these are real pip dependencies: +evdev>=1.7 +python-xlib>=0.33 diff --git a/src/titletimer/__init__.py b/src/titletimer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/titletimer/__main__.py b/src/titletimer/__main__.py new file mode 100644 index 0000000..eee8045 --- /dev/null +++ b/src/titletimer/__main__.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import argparse +import faulthandler +import logging +import os +import sys +from pathlib import Path + +# A hard crash (segfault) in the GTK/GDK/X11 C layer normally leaves no +# trace at all - no Python traceback, just the process disappearing. This +# makes faulthandler dump a Python-level stack for fatal signals (SIGSEGV +# included) straight to stderr before the process dies, which is otherwise +# the only way to point at *where* such a crash happened. +faulthandler.enable() + +# Force XWayland instead of the native Wayland backend: the overlay hints in +# x11_overlay.py (always-on-top, click-through) rely on X11/EWMH, since +# GNOME's Mutter doesn't implement the wlr-layer-shell Wayland protocol. +os.environ["GDK_BACKEND"] = "x11" + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +from . import i18n +from .config import load_config, load_language, profile_path, resolve_active_profile, set_active_profile +from .drag import ModifierWatcher +from .hotkeys import HotkeyListener +from .overlay import OverlayWindow +from .settings_window import SettingsWindow + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("titletimer") + + +def _sync_dark_theme() -> None: + """Make our GTK chrome (the settings window) follow GNOME's system + dark-mode toggle. + + Plain GTK4 doesn't do this on its own for a non-Flatpak app: it would + need GTK_USE_PORTAL=1 plus a working xdg-desktop-portal round-trip, + which is unreliable outside a sandbox. Reading + org.gnome.desktop.interface color-scheme directly via GSettings is + simpler and doesn't depend on the portal at all - verified live: it + reports 'prefer-dark' correctly even when + Gtk.Settings:gtk-application-prefer-dark-theme still defaults to + False. No-ops on non-GNOME desktops where that schema isn't installed. + """ + schema_source = Gio.SettingsSchemaSource.get_default() + if schema_source is None or schema_source.lookup("org.gnome.desktop.interface", True) is None: + return + + interface_settings = Gio.Settings.new("org.gnome.desktop.interface") + gtk_settings = Gtk.Settings.get_default() + + def apply(*_args) -> None: + gtk_settings.set_property( + "gtk-application-prefer-dark-theme", + interface_settings.get_string("color-scheme") == "prefer-dark", + ) + + apply() + interface_settings.connect("changed::color-scheme", apply) + # Gio.Settings has no other owner once this function returns - keep a + # reference alive on gtk_settings (itself a process-wide singleton) so + # the "changed" connection isn't silently dropped by GC. + gtk_settings._titletimer_interface_settings = interface_settings + + +def _resolve_config_path(args: argparse.Namespace) -> tuple[Path, str | None]: + """Returns (config path to load, active profile name or None). + + `--config` bypasses the character-profile system entirely and points + straight at a file (useful for testing or a one-off config); the + profile name is then None, which tells SettingsWindow to hide the + profile-management UI since there's no profile concept in play. + """ + if args.config is not None: + return args.config, None + profile_name = resolve_active_profile() + set_active_profile(profile_name) + return profile_path(profile_name), profile_name + + +def _run_settings(config, config_path: Path, profile_name: str | None) -> int: + app = Gtk.Application(application_id="dev.korintimer.settings") + + def on_activate(app: Gtk.Application) -> None: + _sync_dark_theme() + window = SettingsWindow(app, config, config_path, profile_name=profile_name) + app.add_window(window) + window.present() + + app.connect("activate", on_activate) + return app.run(None) + + +def main() -> int: + parser = argparse.ArgumentParser(prog="titletimer") + parser.add_argument( + "--config", + type=Path, + default=None, + help="Direct path to a config.json, bypassing the character-profile " + "system (default: last-active profile under " + "~/.config/titletimer/profiles/)", + ) + parser.add_argument( + "--settings", + action="store_true", + help="Open only the settings editor, without the overlay.", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Log every raw key event (device, keycode, held set) - noisy, " + "for diagnosing hotkeys that don't register.", + ) + args = parser.parse_args() + + if args.debug: + logging.getLogger("titletimer").setLevel(logging.DEBUG) + + i18n.set_language(load_language()) + + config_path, profile_name = _resolve_config_path(args) + try: + config = load_config(config_path) + except (FileNotFoundError, ValueError) as exc: + logger.error(str(exc)) + return 1 + + if args.settings: + return _run_settings(config, config_path, profile_name) + + app = Gtk.Application(application_id="dev.korintimer.overlay") + + def on_activate(app: Gtk.Application) -> None: + _sync_dark_theme() + try: + overlay_window = OverlayWindow(config, config_path) + except RuntimeError as exc: + logger.error(str(exc)) + app.quit() + return + app.add_window(overlay_window) + overlay_window.present() + + def on_hotkey(held: frozenset[str], pressed_key: str) -> None: + GLib.idle_add(overlay_window.handle_hotkey, held, pressed_key) + + listener = HotkeyListener(on_hotkey) + try: + listener.start() + except RuntimeError as exc: + logger.error(str(exc)) + app.quit() + return + + modifier_watcher = ModifierWatcher(overlay_window.on_alt_change) + try: + modifier_watcher.start() + except RuntimeError as exc: + logger.error(str(exc)) + app.quit() + return + + overlay_window._hotkey_listener = listener # keep a reference alive + overlay_window._modifier_watcher = modifier_watcher # keep a reference alive + + # Settings opens right alongside the overlay - and closing it quits + # the whole app, since that's otherwise the only way to stop an + # overlay with no window chrome and no way to click through to it. + def on_settings_closed(_window) -> bool: + app.quit() + return False # don't block the window from actually closing + + settings_window = SettingsWindow( + app, + config, + config_path, + profile_name=profile_name, + on_saved=overlay_window.apply_config, + ) + app.add_window(settings_window) + settings_window.present() + settings_window.connect("close-request", on_settings_closed) + + app.connect("activate", on_activate) + return app.run(None) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/titletimer/app_icon.png b/src/titletimer/app_icon.png new file mode 100644 index 0000000..a895fc7 Binary files /dev/null and b/src/titletimer/app_icon.png differ diff --git a/src/titletimer/config.example.json b/src/titletimer/config.example.json new file mode 100644 index 0000000..c9d64de --- /dev/null +++ b/src/titletimer/config.example.json @@ -0,0 +1,25 @@ +{ + "dogma_mode": true, + "overlay": { + "anchor": "top-right", + "margin_x": 24, + "margin_y": 24, + "scale": 1.0 + }, + "titles": [ + { + "id": "night_parade", + "name": "Night Parade", + "hotkey": "KEY_F1", + "cooldown_seconds": 90, + "color": "#a64dff" + }, + { + "id": "concerto", + "name": "Concerto", + "hotkey": "KEY_F2", + "cooldown_seconds": 120, + "color": "#4da6ff" + } + ] +} diff --git a/src/titletimer/config.py b/src/titletimer/config.py new file mode 100644 index 0000000..3dcedef --- /dev/null +++ b/src/titletimer/config.py @@ -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() diff --git a/src/titletimer/drag.py b/src/titletimer/drag.py new file mode 100644 index 0000000..2d5bafc --- /dev/null +++ b/src/titletimer/drag.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import logging +import threading +from typing import Callable + +from evdev import InputDevice, categorize, ecodes, list_devices + +from .input_devices import is_keyboard as _is_keyboard + +logger = logging.getLogger(__name__) + +_ALT_KEYS = {"KEY_LEFTALT", "KEY_RIGHTALT"} +_CTRL_KEYS = {"KEY_LEFTCTRL", "KEY_RIGHTCTRL"} + + +class ModifierWatcher: + """Reports Ctrl+Alt up/down, read directly from /dev/input via evdev - + same rationale as HotkeyListener: works regardless of compositor, and + doesn't depend on the overlay window receiving any input itself (it's + normally click-through). + + Used to drive the overlay's Ctrl+Alt+drag-to-move: the overlay toggles + its own click-through state on combo down/up (see OverlayWindow), then + lets GTK's own drag gesture handle the actual move while it's held - + querying pointer position via X11 to hit-test a click-through window + was tried first but found unreliable under XWayland (query_pointer() + returned a frozen position that never updated). + + Plain Alt was tried first but collides with GNOME/Mutter's own built-in + "Alt+drag moves any window" binding + (org.gnome.desktop.wm.preferences mouse-button-modifier, upstream + default ``) - Mutter installs an exclusive passive grab for that + modifier+button combo at the X server level, so on any system still at + the GNOME default (observed: stock Fedora/Bazzite GNOME) the click never + reaches this app at all, regardless of click-through toggling. That + setting is a single exact modifier, so requiring Ctrl+Alt together can't + collide with it on any value of that setting, and isn't reserved by + wlroots compositors either. + """ + + def __init__(self, on_change: Callable[[bool], None]): + self._on_change = on_change + self._threads: list[threading.Thread] = [] + self._alt_held = False + self._ctrl_held = False + self._combo_active = False + + def start(self) -> None: + devices = [InputDevice(path) for path in list_devices()] + keyboards = [d for d in devices if _is_keyboard(d)] + if not keyboards: + raise RuntimeError( + "No keyboard-like input device found under /dev/input. " + "Make sure your user is in the 'input' group (and re-login)." + ) + for dev in keyboards: + t = threading.Thread(target=self._read_keyboard, args=(dev,), daemon=True) + t.start() + self._threads.append(t) + logger.info("Modifier watcher listening on %d keyboard(s)", len(keyboards)) + + def _read_keyboard(self, dev: InputDevice) -> None: + try: + for event in dev.read_loop(): + if event.type != ecodes.EV_KEY: + continue + key_event = categorize(event) + keycode = key_event.keycode + name = keycode[0] if isinstance(keycode, list) else keycode + if name in _ALT_KEYS: + self._alt_held = key_event.keystate != key_event.key_up + elif name in _CTRL_KEYS: + self._ctrl_held = key_event.keystate != key_event.key_up + else: + continue + combo = self._alt_held and self._ctrl_held + if combo != self._combo_active: + self._combo_active = combo + self._on_change(combo) + except OSError: + logger.warning("Input device %s disconnected", dev.path) diff --git a/src/titletimer/hotkey_capture.py b/src/titletimer/hotkey_capture.py new file mode 100644 index 0000000..49b85e1 --- /dev/null +++ b/src/titletimer/hotkey_capture.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import selectors +import threading +from typing import Callable + +from evdev import InputDevice, categorize, ecodes, list_devices + +from .input_devices import is_keyboard + +_CANCEL_KEY = "KEY_ESC" + + +def capture_next_combo(on_captured: Callable[[tuple[str, ...] | None], None]) -> None: + """Wait in a background thread for the user to press (and release) a + key combo and report it as a sorted tuple of evdev key names - Escape + pressed alone reports None (cancelled) instead. + + Tracks every key held down until they're *all* released again, using + the peak simultaneously-held set as the captured combo - so e.g. + holding Ctrl then F1 then releasing both captures ("KEY_F1", + "KEY_LEFTCTRL"), not just whichever key happened to complete the chord. + + Used by the settings window's hotkey "click to record" buttons: since + hotkeys are matched by this same evdev keycode naming (see hotkeys.py/ + state.py), capturing it directly avoids the user needing to know + evdev's naming (KEY_F1, KEY_KP1, ...) to type it in by hand. + + `on_captured` runs on this background thread, not the GTK main thread - + callers touching GTK widgets from it must marshal via GLib.idle_add. + """ + def _run() -> None: + keyboards = [d for d in (InputDevice(p) for p in list_devices()) if is_keyboard(d)] + if not keyboards: + on_captured(None) + return + sel = selectors.DefaultSelector() + for dev in keyboards: + sel.register(dev, selectors.EVENT_READ) + held: set[str] = set() + peak: set[str] = set() + try: + while True: + for key, _mask in sel.select(): + dev = key.fileobj + for raw_event in dev.read(): + if raw_event.type != ecodes.EV_KEY: + continue + key_event = categorize(raw_event) + keycode = key_event.keycode + name = keycode[0] if isinstance(keycode, list) else keycode + if key_event.keystate == key_event.key_down: + held.add(name) + peak |= held + elif key_event.keystate == key_event.key_up: + held.discard(name) + if not held and peak: + if peak == {_CANCEL_KEY}: + on_captured(None) + else: + on_captured(tuple(sorted(peak))) + return + finally: + sel.close() + for dev in keyboards: + dev.close() + + threading.Thread(target=_run, daemon=True).start() diff --git a/src/titletimer/hotkeys.py b/src/titletimer/hotkeys.py new file mode 100644 index 0000000..9879d39 --- /dev/null +++ b/src/titletimer/hotkeys.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import logging +import threading +from typing import Callable + +from evdev import InputDevice, categorize, ecodes, list_devices + +from .input_devices import inaccessible_keyboard_names as _inaccessible_keyboard_names +from .input_devices import is_keyboard as _is_keyboard + +logger = logging.getLogger(__name__) + + +class HotkeyListener: + """Reads raw key-down events from /dev/input via evdev and reports, on + every key-down, the full set of currently-held keys plus which one was + just pressed - so titles can be bound to multi-key combos (e.g. + Ctrl+F1) that still fire while an unrelated key (a movement key, most + likely) happens to also be held; see TitleTimerEngine.handle_hotkey for + the actual matching rules. + + This works regardless of which Wayland compositor is running (unlike + compositor-specific global-shortcut APIs), because it reads the input + device directly rather than going through the window system. The + user's account needs read access to /dev/input/event*, i.e. membership + in the `input` group. + """ + + def __init__(self, on_hotkey: Callable[[frozenset[str], str], None]): + self._on_hotkey = on_hotkey + self._threads: list[threading.Thread] = [] + self._stop = threading.Event() + # Shared across every keyboard-reading thread (in case someone has + # more than one keyboard plugged in) - a combo could span devices. + self._held: set[str] = set() + self._lock = threading.Lock() + + def start(self) -> None: + devices = [InputDevice(path) for path in list_devices()] + logger.debug( + "Input devices seen: %s", + [(d.path, d.name) for d in devices], + ) + missing = _inaccessible_keyboard_names() + if missing: + logger.warning( + "Kernel reports keyboard device(s) that this process can't " + "actually open (permission denied): %s. These will NOT " + "receive hotkeys. If you just added your user to the " + "'input' group, a full logout/login is required for that " + "to take effect - a new terminal in the same session isn't " + "enough.", + missing, + ) + + keyboards = [d for d in devices if _is_keyboard(d)] + if not keyboards: + raise RuntimeError( + "No keyboard-like input device found under /dev/input. " + "Make sure your user is in the 'input' group (and re-login)." + ) + logger.info( + "Listening on %d keyboard device(s): %s", + len(keyboards), [(d.path, d.name) for d in keyboards], + ) + for dev in keyboards: + t = threading.Thread(target=self._read_loop, args=(dev,), daemon=True) + t.start() + self._threads.append(t) + + def stop(self) -> None: + self._stop.set() + + def _read_loop(self, dev: InputDevice) -> None: + try: + for event in dev.read_loop(): + if self._stop.is_set(): + return + if event.type != ecodes.EV_KEY: + continue + key_event = categorize(event) + keycode = key_event.keycode + # evdev sometimes reports a list of aliases for one scancode + name = keycode[0] if isinstance(keycode, list) else keycode + with self._lock: + if key_event.keystate == key_event.key_down: + self._held.add(name) + held = frozenset(self._held) + elif key_event.keystate == key_event.key_up: + self._held.discard(name) + continue + else: + continue + logger.debug("Key down: %s (device %s), held=%s", name, dev.path, held) + self._on_hotkey(held, name) + except OSError: + logger.warning("Input device %s disconnected", dev.path) diff --git a/src/titletimer/i18n.py b/src/titletimer/i18n.py new file mode 100644 index 0000000..054156f --- /dev/null +++ b/src/titletimer/i18n.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +# Tiny in-process translation table for the settings window - the overlay +# itself has no translatable text (just the title name from config and a +# generic "12.3s" countdown, which reads fine in either language). +# +# Deliberately not a full gettext/.po setup: two languages, one UI file, +# and this keeps the whole thing readable in one place without a build +# step or bundling extra locale files into the AppImage. + +Lang = str # "de" or "en" + +_current_language: Lang = "de" + +_STRINGS: dict[str, dict[Lang, str]] = { + "settings_title": {"de": "Korin Timer - Einstellungen", "en": "Korin Timer - Settings"}, + "language_label": {"de": "Sprache:", "en": "Language:"}, + "profile_label": {"de": "Charakter:", "en": "Character:"}, + "profile_new": {"de": "Neu", "en": "New"}, + "profile_duplicate": {"de": "Duplizieren", "en": "Duplicate"}, + "profile_rename": {"de": "Umbenennen", "en": "Rename"}, + "general_header": {"de": "Allgemein", "en": "General"}, + "overlay_header": {"de": "Overlay", "en": "Overlay"}, + "dogma_mode": {"de": "Dogma-Modus", "en": "Dogma Mode"}, + "overlay_position": {"de": "Position", "en": "Position"}, + "margin_x": {"de": "Rand X", "en": "Margin X"}, + "margin_y": {"de": "Rand Y", "en": "Margin Y"}, + "scale": {"de": "Skalierung", "en": "Scale"}, + "reset_position": { + "de": "Per Strg+Alt+Drag gesetzte Position zurücksetzen", + "en": "Reset position set via Ctrl+Alt+Drag", + }, + "titles_header": {"de": "Titel", "en": "Titles"}, + "col_id": {"de": "ID", "en": "ID"}, + "col_name": {"de": "Name", "en": "Name"}, + "col_hotkey": {"de": "Hotkey", "en": "Hotkey"}, + "col_cooldown": {"de": "Cooldown (s)", "en": "Cooldown (s)"}, + "add_title": {"de": "+ Titel hinzufügen", "en": "+ Add title"}, + "close": {"de": "Schließen", "en": "Close"}, + "save": {"de": "Speichern", "en": "Save"}, + "cancel": {"de": "Abbrechen", "en": "Cancel"}, + "ok": {"de": "OK", "en": "OK"}, + "delete": {"de": "Löschen", "en": "Delete"}, + "hotkey_tooltip": { + "de": "Klicken, dann eine oder mehrere Tasten drücken und wieder loslassen " + "(z.B. Strg+F1 für eine Kombination) - Esc = abbrechen", + "en": "Click, then press and release one or more keys " + "(e.g. Ctrl+F1 for a combo) - Esc = cancel", + }, + "restart_on_repeat": {"de": "Neu triggerbar", "en": "Retriggerable"}, + "restart_on_repeat_tooltip": { + "de": "Wenn aktiv: Haupttaste während laufendem Cooldown erneut drücken " + "startet ihn neu (volle Dauer ab jetzt), statt ignoriert zu werden.", + "en": "If enabled: pressing the main hotkey again while a cooldown is " + "running restarts it (full duration from now) instead of being ignored.", + }, + "remove_title": {"de": "Titel entfernen", "en": "Remove title"}, + "pre_activation_label": {"de": "Vor-Aktivierung:", "en": "Pre-activation:"}, + "pre_hotkey_tooltip": { + "de": "Optional: zusätzliche Taste(nkombination), die nur Step 1 (Standby) " + "auslöst - z.B. eine Taste, die im Spiel ohnehin gedrückt wird, damit " + "die Haupttaste oben nur noch für Step 2 gebraucht wird.", + "en": "Optional: an additional key (combo) that only triggers step 1 " + "(standby) - e.g. a key already pressed during normal play, so the " + "main hotkey above is only needed for step 2.", + }, + "remove_pre_activation": {"de": "Vor-Aktivierung entfernen", "en": "Remove pre-activation"}, + "timeout_label": {"de": "Timeout (s):", "en": "Timeout (s):"}, + "standby_timeout_tooltip": { + "de": "Wie lange nach Step 1 Zeit für Step 2 bleibt, bevor der Titel " + "automatisch zurück auf Idle springt. 0 = kein Timeout (Standby " + "bleibt bestehen, bis Step 2 gedrückt wird oder ein anderer Titel " + "aktiviert wird).", + "en": "How long after step 1 there is time for step 2 before the title " + "automatically reverts to idle. 0 = no timeout (standby persists " + "until step 2 is pressed or another title is activated).", + }, + "remove_icon": {"de": "Icon entfernen", "en": "Remove icon"}, + "choose_key": {"de": "Taste wählen…", "en": "Choose key…"}, + "press_keys": {"de": "Taste(n) drücken…", "en": "Press key(s)…"}, + "none": {"de": "Keine", "en": "None"}, + "icon_button_label": {"de": "Icon…", "en": "Icon…"}, + "no_icon_selected": {"de": "Kein Icon ausgewählt", "en": "No icon selected"}, + "images_filter": {"de": "Bilder", "en": "Images"}, + "position_dragged": { + "de": "Per Strg+Alt+Drag gesetzt: {x}, {y} (überschreibt Anker/Rand oben)", + "en": "Set via Ctrl+Alt+Drag: {x}, {y} (overrides anchor/margin above)", + }, + "position_follows_anchor": { + "de": "Folgt Anker/Rand-Einstellung oben", + "en": "Follows anchor/margin setting above", + }, + "error_loading_profile": { + "de": "Fehler beim Laden von '{name}': {error}", + "en": "Error loading '{name}': {error}", + }, + "new_character_title": {"de": "Neuer Charakter", "en": "New character"}, + "error_profile_exists": { + "de": "Fehler: Charakter '{name}' existiert bereits.", + "en": "Error: character '{name}' already exists.", + }, + "error_generic": {"de": "Fehler: {error}", "en": "Error: {error}"}, + "profile_created": {"de": "Charakter '{name}' angelegt.", "en": "Character '{name}' created."}, + "duplicate_character_title": {"de": "Charakter duplizieren", "en": "Duplicate character"}, + "duplicate_name_suffix": {"de": "{name} Kopie", "en": "{name} copy"}, + "profile_duplicated": { + "de": "Charakter '{name}' angelegt (Kopie).", + "en": "Character '{name}' created (copy).", + }, + "rename_character_title": {"de": "Charakter umbenennen", "en": "Rename character"}, + "profile_renamed": {"de": "Umbenannt zu '{name}'.", "en": "Renamed to '{name}'."}, + "error_cannot_delete_last": { + "de": "Fehler: der letzte Charakter kann nicht gelöscht werden.", + "en": "Error: the last character cannot be deleted.", + }, + "delete_character_title": {"de": "Charakter löschen", "en": "Delete character"}, + "delete_character_confirm": { + "de": "Charakter '{name}' und alle seine Titel/Hotkeys unwiderruflich löschen?", + "en": "Permanently delete character '{name}' and all its titles/hotkeys?", + }, + "profile_deleted": {"de": "Charakter '{name}' gelöscht.", "en": "Character '{name}' deleted."}, + "error_need_one_title": { + "de": "Fehler: mindestens ein Titel nötig.", + "en": "Error: at least one title is required.", + }, + "error_empty_fields": { + "de": "Fehler: id/Name/Hotkey dürfen nicht leer sein.", + "en": "Error: id/name/hotkey must not be empty.", + }, + "error_duplicate_id": {"de": "Fehler: doppelte Titel-id.", "en": "Error: duplicate title id."}, + "error_duplicate_hotkey": { + "de": "Fehler: doppelte Hotkey-Zuordnung (Haupt- oder Vor-Aktivierungs-Taste).", + "en": "Error: duplicate hotkey assignment (main or pre-activation key).", + }, + "saved_to": {"de": "Gespeichert nach {path}", "en": "Saved to {path}"}, +} + + +def set_language(lang: Lang) -> None: + global _current_language + if lang not in ("de", "en"): + raise ValueError(f"unsupported language: {lang!r}") + _current_language = lang + + +def get_language() -> Lang: + return _current_language + + +def t(key: str, **kwargs) -> str: + entry = _STRINGS[key] + text = entry.get(_current_language, entry["de"]) + return text.format(**kwargs) if kwargs else text diff --git a/src/titletimer/input_devices.py b/src/titletimer/input_devices.py new file mode 100644 index 0000000..9760245 --- /dev/null +++ b/src/titletimer/input_devices.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path + +from evdev import InputDevice, ecodes, list_devices + + +def is_mouse(dev: InputDevice) -> bool: + keys = dev.capabilities().get(ecodes.EV_KEY, []) + rels = dev.capabilities().get(ecodes.EV_REL, []) + return ecodes.BTN_LEFT in keys and ecodes.REL_X in rels and ecodes.REL_Y in rels + + +def is_keyboard(dev: InputDevice) -> bool: + caps = dev.capabilities().get(ecodes.EV_KEY, []) + # Gaming mice (e.g. Logitech G502) commonly also advertise KEY_A/ + # KEY_SPACE (programmable buttons / media-key emulation) and would + # otherwise get opened redundantly as both "keyboard" and "mouse" by + # separate listeners, which was observed to make one of the duplicate + # opens randomly drop ("Input device disconnected"). + return ecodes.KEY_A in caps and ecodes.KEY_SPACE in caps and not is_mouse(dev) + + +def inaccessible_keyboard_names() -> list[str]: + """Best-effort list of device names the kernel flags as keyboards (a + "kbd" handler in /proc/bus/input/devices) but that this process can't + actually open. + + evdev.list_devices() silently drops any /dev/input/eventN this process + fails an os.access(R_OK | W_OK) check on - no exception, no gap in the + returned list, so a real keyboard blocked by permissions just vanishes + without a trace and whatever else happens to qualify as "keyboard-like" + (e.g. a gaming mouse's macro-key HID sub-interface) silently becomes + the only thing listened on. Observed on a Bazzite/GNOME machine: the + physical keyboard's device node wasn't covered by the desktop + session's usual per-session uaccess ACL (likely stripped by a + Steam-Input udev rule for recognized gaming-keyboard hardware IDs), + leaving it gated by the static 'input' group alone - which only takes + effect for the process's *next* login session, not a new terminal in + an already-running one. /proc/bus/input/devices is a plain readable + text file regardless of any of that, so it's used here purely to + produce an actionable diagnostic, never to open anything. + """ + try: + text = Path("/proc/bus/input/devices").read_text() + except OSError: + return [] + + accessible = {Path(p).name for p in list_devices()} + missing: list[str] = [] + name: str | None = None + for line in text.splitlines(): + if line.startswith("N: Name="): + name = line.split("=", 1)[1].strip('"') + elif line.startswith("H: Handlers="): + handlers = line.split("=", 1)[1].split() + if "kbd" not in handlers: + continue + event_handlers = [h for h in handlers if h.startswith("event")] + if event_handlers and event_handlers[0] not in accessible: + missing.append(name or event_handlers[0]) + return missing diff --git a/src/titletimer/overlay.py b/src/titletimer/overlay.py new file mode 100644 index 0000000..20efb72 --- /dev/null +++ b/src/titletimer/overlay.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import logging +import math +from pathlib import Path +from time import monotonic + +import gi + +gi.require_foreign("cairo") +gi.require_version("Gtk", "4.0") +gi.require_version("GdkPixbuf", "2.0") + +import cairo + +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402 + +logger = logging.getLogger(__name__) + +from . import x11_overlay +from .config import APP_ICON_PATH, AppConfig, persist_overlay_position +from .state import TitleRuntime, TitleState, TitleTimerEngine + +ROW_HEIGHT = 46 +ROW_GAP = 8 +ROW_WIDTH = 220 +PADDING = 12 +# Fills most of the row height (46px, minus ~2px inset the row border is +# drawn at on each side) while still leaving a small margin so it doesn't +# touch the row outline. +ICON_SIZE = 36 + +# Below this many remaining seconds, the cooldown border/bar/countdown ease +# from the title's own color towards this warning color, so the last +# moments of a cooldown read as urgent without needing a separate config +# knob - a generic progress-bar convention, not tied to any specific title. +_WARNING_COLOR = "#ff5252" +_WARNING_THRESHOLD_SECONDS = 5.0 + + +def _lerp_rgb(a: tuple[float, float, float], b: tuple[float, float, float], t: float) -> tuple[float, float, float]: + return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) + + +def _hex_to_rgba(color: str, alpha: float) -> tuple[float, float, float, float]: + color = color.lstrip("#") + r = int(color[0:2], 16) / 255 + g = int(color[2:4], 16) / 255 + b = int(color[4:6], 16) / 255 + return (r, g, b, alpha) + + +class OverlayWindow(Gtk.Window): + def __init__(self, config: AppConfig, config_path: Path): + super().__init__() + self._config = config + self._engine = TitleTimerEngine(config, on_change=self._request_redraw) + self._config_path = config_path + self._phase = 0.0 + # Loaded lazily and cached per path - _on_draw fires ~10x/sec, and a + # failed/missing icon path would otherwise retry the disk read (and + # log a warning) on every single frame. + self._icon_cache: dict[str, GdkPixbuf.Pixbuf | None] = {} + + self.set_decorated(False) + self.add_css_class("title-timer-overlay") + + display = Gdk.Display.get_default() + provider = Gtk.CssProvider() + provider.load_from_string( + "window.title-timer-overlay { background: transparent; }" + ) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + n_titles = max(1, len(config.titles)) + self._width = int(ROW_WIDTH * config.overlay.scale) + self._height = int((ROW_HEIGHT + ROW_GAP) * n_titles * config.overlay.scale) + self.set_default_size(self._width, self._height) + + self._drawing_area = Gtk.DrawingArea() + self._drawing_area.set_content_width(self._width) + self._drawing_area.set_content_height(self._height) + self._drawing_area.set_draw_func(self._on_draw) + self.set_child(self._drawing_area) + + self._xid: int | None = None + self._x: int | None = None + self._y: int | None = None + self._drag_start_x: int | None = None + self._drag_start_y: int | None = None + # Dedicated connection reused across drag-related X11 calls instead + # of opening a new socket per call. + self._drag_display = x11_overlay.connect() + + self._drag_gesture = Gtk.GestureDrag() + self._drag_gesture.set_button(Gdk.BUTTON_PRIMARY) + self._drag_gesture.connect("drag-begin", self._on_drag_begin) + self._drag_gesture.connect("drag-update", self._on_drag_update) + self._drag_gesture.connect("drag-end", self._on_drag_end) + self._drawing_area.add_controller(self._drag_gesture) + + self.connect("realize", self._on_realize) + self.connect("map", self._on_map) + + GLib.timeout_add(100, self._on_tick) + GLib.timeout_add(1000, self._on_reraise_tick) + + def _on_realize(self, _widget) -> None: + # Hotkeys are read directly from /dev/input, and the game underneath + # must keep input focus - so this must never grab keyboard/pointer + # input. GNOME/Mutter doesn't implement wlr-layer-shell, so instead + # of a layer surface this applies EWMH always-on-top + click-through + # hints over XWayland (works on GNOME, KDE, and wlroots compositors). + self._xid, self._x, self._y = x11_overlay.make_overlay( + self, self._config.overlay, self._width, self._height + ) + + def _on_map(self, _widget) -> None: + if self._xid is not None: + x11_overlay.restack_above(self._drag_display, self._xid) + # Must run on "map" - see settings_window.py's _on_map for why + # (GTK4 sets/clears _NET_WM_ICON itself around map time). This + # window is skip_taskbar so it's normally not user-visible + # anywhere an icon would show, but set for consistency/in case + # some WM's alt-tab ignores that hint. + x11_overlay.set_wm_icon(self._drag_display, self._xid, str(APP_ICON_PATH)) + + def _request_redraw(self) -> None: + self._drawing_area.queue_draw() + + def _on_tick(self) -> bool: + self._phase += 0.1 + self._engine.tick() + self._drawing_area.queue_draw() + return GLib.SOURCE_CONTINUE + + def _on_reraise_tick(self) -> bool: + # Defensive - restack_above() should already hold thanks to + # override-redirect (see x11_overlay.make_overlay), but this costs + # nothing and guards against anything else on the stack (the game, + # a notification) restacking itself above us in between. + if self._xid is not None: + x11_overlay.restack_above(self._drag_display, self._xid) + return GLib.SOURCE_CONTINUE + + def handle_hotkey(self, held: frozenset[str], pressed_key: str) -> None: + self._engine.handle_hotkey(held, pressed_key) + + def apply_config(self, new_config: AppConfig, config_path: Path) -> None: + """Pick up a config saved from the settings window without + restarting - rebuilds the timer engine (so hotkeys/dogma-mode take + effect immediately) and, if the title count or scale changed, + resizes the window in place. + + `config_path` is tracked separately because it can change too - the + settings window may have switched to a different character profile, + whose file a subsequent Ctrl+Alt-drag must persist into instead of + the previously active profile's. + + Any title mid-cooldown resets to idle, since a fresh engine has no + memory of the old one - an acceptable tradeoff for "edit settings + without restarting the whole app" rather than trying to migrate + live timer state across a config change. + """ + self._config = new_config + self._config_path = config_path + self._icon_cache.clear() + + n_titles = max(1, len(new_config.titles)) + new_width = int(ROW_WIDTH * new_config.overlay.scale) + new_height = int((ROW_HEIGHT + ROW_GAP) * n_titles * new_config.overlay.scale) + if (new_width, new_height) != (self._width, self._height): + self._width, self._height = new_width, new_height + self._drawing_area.set_content_width(self._width) + self._drawing_area.set_content_height(self._height) + if self._xid is not None: + x11_overlay.resize_window(self._drag_display, self._xid, self._width, self._height) + + self._engine = TitleTimerEngine(new_config, on_change=self._request_redraw) + self._request_redraw() + + # -- Ctrl+Alt+drag repositioning -- + # + # The overlay is normally click-through (empty XShape input region) so + # the game underneath keeps receiving clicks. Several earlier approaches + # didn't pan out: + # 1. Querying the pointer's absolute position via X11 to hit-test a + # click-through window - unreliable under XWayland, query_pointer() + # returned a frozen position that never updated (XWayland seems to + # only track the core pointer while it's over a surface actually + # receiving pointer events, which a click-through window never is). + # 2. Handing the drag off to the WM via _NET_WM_MOVERESIZE (the same + # protocol path normal titlebar dragging uses) - worked fine on + # Mutter/KDE, but the window is now override-redirect (see + # x11_overlay.make_overlay, needed for reliable always-on-top on + # COSMIC), and an override-redirect window isn't managed by any WM + # at all - nothing to hand the move off to anymore. + # 3. Plain Alt (no Ctrl) as the modifier - collided with GNOME/ + # Mutter's own built-in "Alt+drag moves any window" binding on + # systems still at its upstream default (observed: Bazzite/GNOME); + # Mutter's exclusive passive grab on that modifier+button ate the + # click before this app ever saw it, so nothing happened at all. + # + # So: ModifierWatcher (drag.py) reports Ctrl+Alt up/down from raw evdev + # - a combo that can't collide with that GNOME setting regardless of its + # value, since the setting only ever matches a single exact modifier. + # While the combo is held, the input region is temporarily set to the + # full window rect (on_alt_change, called from ModifierWatcher's + # background thread) so a click actually reaches GTK; a Gtk.GestureDrag + # then tracks the motion itself and repositions the window via + # x11_overlay.move_window() on every update - client-side tracking was + # laggy on a WM-managed window (see move_window()'s docstring) but isn't + # anymore now that the WM has no smoothing/animation to apply, since it + # doesn't manage this window at all. + + def on_alt_change(self, held: bool) -> None: + if self._xid is None: + return + x11_overlay.set_click_through( + self._drag_display, self._xid, not held, self._width, self._height + ) + + def _on_drag_begin(self, _gesture, _start_x: float, _start_y: float) -> None: + self._drag_start_x = self._x + self._drag_start_y = self._y + + def _on_drag_update(self, _gesture, offset_x: float, offset_y: float) -> None: + if self._xid is None or self._drag_start_x is None or self._drag_start_y is None: + return + self._x = self._drag_start_x + round(offset_x) + self._y = self._drag_start_y + round(offset_y) + x11_overlay.move_window(self._drag_display, self._xid, self._x, self._y) + + def _on_drag_end(self, _gesture, offset_x: float, offset_y: float) -> None: + self._on_drag_update(_gesture, offset_x, offset_y) + self._drag_start_x = None + self._drag_start_y = None + if self._x is not None and self._y is not None: + persist_overlay_position(self._config_path, self._x, self._y) + + def _on_draw(self, area, cr, width, height) -> None: + now = monotonic() + cr.set_line_width(3) + for i, rt in enumerate(self._engine.snapshot()): + y = i * (ROW_HEIGHT + ROW_GAP) + self._draw_row(cr, rt, y, width, now) + + def _draw_row(self, cr, rt: TitleRuntime, y: float, width: float, now: float) -> None: + color = rt.config.color + radius = 8 + cooldown_rgb: tuple[float, float, float] | None = None + standby_rgb: tuple[float, float, float] | None = None + + if rt.state is TitleState.STANDBY: + standby_remaining = rt.standby_remaining_seconds(now) + pulse = (math.sin(self._phase * 3) + 1) / 2 # 0..1 + base_rgb = _hex_to_rgba(color, 1.0)[:3] + + if standby_remaining is not None: + # A timeout is configured - ease towards the warning color + # as the window to press step 2 runs out, same convention + # as the cooldown bar below. + urgency = max(0.0, min(1.0, 1.0 - standby_remaining / _WARNING_THRESHOLD_SECONDS)) + warn_rgb = _hex_to_rgba(_WARNING_COLOR, 1.0)[:3] + r, g, b = standby_rgb = _lerp_rgb(base_rgb, warn_rgb, urgency) + else: + r, g, b = base_rgb + + self._outlined_stroke( + cr, 2, y + 2, width - 4, ROW_HEIGHT - 4, radius, (r, g, b, 0.5 + 0.5 * pulse) + ) + + if standby_remaining is not None: + timeout = rt.config.standby_timeout_seconds or 1.0 + bar_x, bar_y, bar_h = PADDING, y + ROW_HEIGHT - 12, 5 + bar_track_w = width - 2 * PADDING + cr.set_source_rgba(1, 1, 1, 0.08) + self._rounded_rect(cr, bar_x, bar_y, bar_track_w, bar_h, bar_h / 2) + cr.fill() + bar_fill_w = max(0.0, bar_track_w * (standby_remaining / timeout)) + if bar_fill_w > 0: + cr.set_source_rgba(r, g, b, 0.95) + self._rounded_rect(cr, bar_x, bar_y, bar_fill_w, bar_h, bar_h / 2) + cr.fill() + elif rt.state is TitleState.COOLDOWN: + remaining = rt.remaining_seconds(now) + urgency = max(0.0, min(1.0, 1.0 - remaining / _WARNING_THRESHOLD_SECONDS)) + base_rgb = _hex_to_rgba(color, 1.0)[:3] + warn_rgb = _hex_to_rgba(_WARNING_COLOR, 1.0)[:3] + r, g, b = cooldown_rgb = _lerp_rgb(base_rgb, warn_rgb, urgency) + + # Soft color wash behind the whole row, so an active cooldown + # reads as a filled "card" rather than just an outline. + cr.set_source_rgba(r, g, b, 0.12) + self._rounded_rect(cr, 2, y + 2, width - 4, ROW_HEIGHT - 4, radius) + cr.fill() + + # Border gently pulses once urgent, calm/steady otherwise. + urgent_pulse = (math.sin(self._phase * 5) + 1) / 2 if urgency > 0 else 0.0 + self._outlined_stroke( + cr, 2, y + 2, width - 4, ROW_HEIGHT - 4, radius, + (r, g, b, 0.85 + 0.15 * urgent_pulse * urgency), line_width=2.5, + ) + + # Full-width progress bar: dim track + bright fill, rounded caps. + bar_x, bar_y, bar_h = PADDING, y + ROW_HEIGHT - 12, 5 + bar_track_w = width - 2 * PADDING + cr.set_source_rgba(1, 1, 1, 0.08) + self._rounded_rect(cr, bar_x, bar_y, bar_track_w, bar_h, bar_h / 2) + cr.fill() + + fraction = rt.remaining_fraction(now) + bar_fill_w = max(0.0, bar_track_w * fraction) + if bar_fill_w > 0: + cr.set_source_rgba(r, g, b, 0.95) + self._rounded_rect(cr, bar_x, bar_y, bar_fill_w, bar_h, bar_h / 2) + cr.fill() + else: + self._outlined_stroke(cr, 2, y + 2, width - 4, ROW_HEIGHT - 4, radius, (1, 1, 1, 0.55)) + + text_x = PADDING + icon = self._get_icon(rt.config.icon) if rt.config.icon else None + if icon is not None: + icon_y = y + (ROW_HEIGHT - ICON_SIZE) / 2 + Gdk.cairo_set_source_pixbuf(cr, icon, PADDING, icon_y) + cr.paint() + text_x = PADDING + ICON_SIZE + 8 + + cr.set_source_rgba(1, 1, 1, 0.9) + cr.select_font_face("sans-serif", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + cr.set_font_size(14) + cr.move_to(text_x, y + 20) + cr.show_text(rt.config.name) + + countdown_rgb = cooldown_rgb if rt.state is TitleState.COOLDOWN else standby_rgb + countdown_seconds = ( + rt.remaining_seconds(now) if rt.state is TitleState.COOLDOWN + else rt.standby_remaining_seconds(now) + ) + if countdown_rgb is not None and countdown_seconds is not None: + text = f"{countdown_seconds:.1f}s" + cr.select_font_face("monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + cr.set_font_size(15) + text_width = cr.text_extents(text).width + cr.set_source_rgba(*countdown_rgb, 1.0) + cr.move_to(width - PADDING - text_width, y + 20) + cr.show_text(text) + + def _outlined_stroke( + self, cr, x, y, w, h, r, rgba: tuple[float, float, float, float], line_width: float = 3.0 + ) -> None: + """Stroke a rounded rect with a dark contrast outline underneath the + actual color - a plain single-color border reads fine against one + background but disappears against another (a bright border blends + into a light game background, a dark one blends into a dark one). + The dark underlay keeps an edge visible either way, same idea as a + subtitle's black outline around white text. + """ + cr.set_line_width(line_width + 1.6) + cr.set_source_rgba(0, 0, 0, 0.55) + self._rounded_rect(cr, x, y, w, h, r) + cr.stroke() + + cr.set_line_width(line_width) + cr.set_source_rgba(*rgba) + self._rounded_rect(cr, x, y, w, h, r) + cr.stroke() + + def _get_icon(self, path: str) -> GdkPixbuf.Pixbuf | None: + if path not in self._icon_cache: + try: + self._icon_cache[path] = GdkPixbuf.Pixbuf.new_from_file_at_scale( + path, ICON_SIZE, ICON_SIZE, True + ) + except GLib.Error as exc: + logger.warning("Could not load icon %r: %s", path, exc) + self._icon_cache[path] = None + return self._icon_cache[path] + + @staticmethod + def _rounded_rect(cr, x, y, w, h, r) -> None: + if w <= 0 or h <= 0: + cr.new_path() + return + cr.new_sub_path() + cr.arc(x + w - r, y + r, r, -math.pi / 2, 0) + cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2) + cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi) + cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) + cr.close_path() diff --git a/src/titletimer/settings_window.py b/src/titletimer/settings_window.py new file mode 100644 index 0000000..c5c675d --- /dev/null +++ b/src/titletimer/settings_window.py @@ -0,0 +1,822 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Callable + +import gi + +gi.require_version("Gtk", "4.0") + +from gi.repository import Gdk, Gio, GLib, Gtk # noqa: E402 + +from . import i18n, x11_overlay +from .config import ( + APP_ICON_PATH, + EXAMPLE_CONFIG_PATH, + AppConfig, + OverlayConfig, + TitleConfig, + delete_profile, + duplicate_profile, + list_profiles, + load_config, + profile_path, + rename_profile, + save_config, + save_language, + set_active_profile, +) +from .hotkey_capture import capture_next_combo +from .i18n import t + +_ANCHORS = ["top-left", "top-right", "bottom-left", "bottom-right"] +_DEFAULT_TITLE_COLOR = "#4da6ff" + +# Card look for each title row, and a bit more breathing room than the +# theme default between the header/content rows within a section. Uses a +# neutral gray at low alpha rather than a @theme_* named color - COSMIC's +# own GTK theme was observed not to define the usual Adwaita palette names, +# so a plain rgba() that just nudges the background either way (lighter on +# dark themes, darker on light ones) is the safer cross-theme choice. +_CSS = """ +.title-row { + background-color: rgba(127, 127, 127, 0.09); + border-radius: 8px; + padding: 8px 10px; +} +.title-row-header { + margin-left: 10px; + margin-right: 10px; +} +""" + + +def _prompt_name( + parent: Gtk.Window, title: str, initial: str, on_done: Callable[[str | None], None] +) -> None: + """Small modal text-entry dialog - GTK4 dropped the convenience input + dialog GTK3 had, so this replaces it for new/rename/duplicate profile + names. Calls on_done(None) on cancel, on_done(text) on confirm. + """ + dialog = Gtk.Window(transient_for=parent, modal=True, title=title, resizable=False) + dialog.set_default_size(320, -1) + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=10, + margin_top=12, + margin_bottom=12, + margin_start=12, + margin_end=12, + ) + dialog.set_child(box) + + entry = Gtk.Entry(text=initial) + entry.set_activates_default(True) + box.append(entry) + + button_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, halign=Gtk.Align.END) + box.append(button_row) + cancel_button = Gtk.Button(label=t("cancel")) + ok_button = Gtk.Button(label=t("ok")) + ok_button.add_css_class("suggested-action") + button_row.append(cancel_button) + button_row.append(ok_button) + dialog.set_default_widget(ok_button) + + def finish(result: str | None) -> None: + dialog.close() + on_done(result) + + cancel_button.connect("clicked", lambda _b: finish(None)) + ok_button.connect("clicked", lambda _b: finish(entry.get_text())) + dialog.present() + entry.grab_focus() + + +def _confirm( + parent: Gtk.Window, title: str, message: str, on_done: Callable[[bool], None] +) -> None: + dialog = Gtk.Window(transient_for=parent, modal=True, title=title, resizable=False) + dialog.set_default_size(320, -1) + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=10, + margin_top=12, + margin_bottom=12, + margin_start=12, + margin_end=12, + ) + dialog.set_child(box) + box.append(Gtk.Label(label=message, wrap=True, xalign=0)) + + button_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, halign=Gtk.Align.END) + box.append(button_row) + cancel_button = Gtk.Button(label=t("cancel")) + confirm_button = Gtk.Button(label=t("delete")) + confirm_button.add_css_class("destructive-action") + button_row.append(cancel_button) + button_row.append(confirm_button) + + def finish(result: bool) -> None: + dialog.close() + on_done(result) + + cancel_button.connect("clicked", lambda _b: finish(False)) + confirm_button.connect("clicked", lambda _b: finish(True)) + dialog.present() + + +def _color_to_hex(rgba: Gdk.RGBA) -> str: + return "#{:02x}{:02x}{:02x}".format( + round(rgba.red * 255), round(rgba.green * 255), round(rgba.blue * 255) + ) + + +def _format_hotkey(combo: tuple[str, ...]) -> str: + return " + ".join(key.removeprefix("KEY_") for key in combo) + + +class _TitleRow(Gtk.Box): + """One editable title: id/name/hotkey/cooldown/remove on the first line, + pre-activation hotkey/color/icon on the second. + """ + + def __init__(self, title: TitleConfig, on_remove): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.add_css_class("title-row") + self._icon_path: str | None = title.icon + self._hotkey: tuple[str, ...] = title.hotkey + self._pre_hotkey: tuple[str, ...] | None = title.pre_hotkey + + line1 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + line2 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.append(line1) + self.append(line2) + + self.id_entry = Gtk.Entry(text=title.id, placeholder_text="id", width_chars=8) + self.name_entry = Gtk.Entry(text=title.name, placeholder_text="Name", hexpand=True) + + self.hotkey_button = Gtk.Button() + self.hotkey_button.set_size_request(130, -1) + self.hotkey_button.set_tooltip_text(t("hotkey_tooltip")) + self.hotkey_button.connect("clicked", self._on_pick_hotkey) + self._update_hotkey_button_label() + + self.cooldown_spin = Gtk.SpinButton.new_with_range(1, 3600, 1) + self.cooldown_spin.set_digits(1) + self.cooldown_spin.set_value(title.cooldown_seconds) + + self.restart_check = Gtk.CheckButton(label=t("restart_on_repeat")) + self.restart_check.set_active(title.restart_on_repeat) + self.restart_check.set_tooltip_text(t("restart_on_repeat_tooltip")) + + remove_button = Gtk.Button(label="✕") + remove_button.add_css_class("flat") + remove_button.set_tooltip_text(t("remove_title")) + remove_button.connect("clicked", lambda _b: on_remove(self)) + + for widget in ( + self.id_entry, + self.name_entry, + self.hotkey_button, + self.cooldown_spin, + self.restart_check, + remove_button, + ): + line1.append(widget) + + line2.append(Gtk.Label(label=t("pre_activation_label"))) + + self.pre_hotkey_button = Gtk.Button() + self.pre_hotkey_button.set_size_request(130, -1) + self.pre_hotkey_button.set_tooltip_text(t("pre_hotkey_tooltip")) + self.pre_hotkey_button.connect("clicked", self._on_pick_pre_hotkey) + self._update_pre_hotkey_button_label() + line2.append(self.pre_hotkey_button) + + pre_hotkey_clear_button = Gtk.Button(label="✕") + pre_hotkey_clear_button.add_css_class("flat") + pre_hotkey_clear_button.set_tooltip_text(t("remove_pre_activation")) + pre_hotkey_clear_button.connect("clicked", self._on_clear_pre_hotkey) + line2.append(pre_hotkey_clear_button) + + line2.append(Gtk.Label(label=t("timeout_label"))) + self.standby_timeout_spin = Gtk.SpinButton.new_with_range(0, 300, 1) + self.standby_timeout_spin.set_digits(0) + self.standby_timeout_spin.set_value(title.standby_timeout_seconds or 0) + self.standby_timeout_spin.set_tooltip_text(t("standby_timeout_tooltip")) + line2.append(self.standby_timeout_spin) + + color_dialog = Gtk.ColorDialog() + self.color_button = Gtk.ColorDialogButton(dialog=color_dialog) + rgba = Gdk.RGBA() + rgba.parse(title.color or _DEFAULT_TITLE_COLOR) + self.color_button.set_rgba(rgba) + line2.append(self.color_button) + + self.icon_button = Gtk.Button() + self.icon_button.set_size_request(90, -1) + self.icon_button.connect("clicked", self._on_pick_icon) + self._update_icon_button_label() + line2.append(self.icon_button) + + icon_clear_button = Gtk.Button(label="✕") + icon_clear_button.add_css_class("flat") + icon_clear_button.set_tooltip_text(t("remove_icon")) + icon_clear_button.connect("clicked", self._on_clear_icon) + line2.append(icon_clear_button) + + def _update_hotkey_button_label(self) -> None: + self.hotkey_button.set_label(_format_hotkey(self._hotkey) if self._hotkey else t("choose_key")) + + def _on_pick_hotkey(self, _button) -> None: + self.hotkey_button.set_sensitive(False) + self.hotkey_button.set_label(t("press_keys")) + capture_next_combo(self._on_hotkey_captured) + + def _on_hotkey_captured(self, combo: tuple[str, ...] | None) -> None: + def apply() -> bool: + if combo is not None: + self._hotkey = combo + self.hotkey_button.set_sensitive(True) + self._update_hotkey_button_label() + return GLib.SOURCE_REMOVE + + # capture_next_combo calls back from its own background thread. + GLib.idle_add(apply) + + def _update_pre_hotkey_button_label(self) -> None: + self.pre_hotkey_button.set_label( + _format_hotkey(self._pre_hotkey) if self._pre_hotkey else t("none") + ) + + def _on_pick_pre_hotkey(self, _button) -> None: + self.pre_hotkey_button.set_sensitive(False) + self.pre_hotkey_button.set_label(t("press_keys")) + capture_next_combo(self._on_pre_hotkey_captured) + + def _on_pre_hotkey_captured(self, combo: tuple[str, ...] | None) -> None: + def apply() -> bool: + if combo is not None: + self._pre_hotkey = combo + self.pre_hotkey_button.set_sensitive(True) + self._update_pre_hotkey_button_label() + return GLib.SOURCE_REMOVE + + GLib.idle_add(apply) + + def _on_clear_pre_hotkey(self, _button) -> None: + self._pre_hotkey = None + self._update_pre_hotkey_button_label() + + def _update_icon_button_label(self) -> None: + self.icon_button.set_label(Path(self._icon_path).name if self._icon_path else t("icon_button_label")) + self.icon_button.set_tooltip_text(self._icon_path or t("no_icon_selected")) + + def _on_pick_icon(self, _button) -> None: + dialog = Gtk.FileDialog() + image_filter = Gtk.FileFilter() + image_filter.set_name(t("images_filter")) + image_filter.add_pixbuf_formats() + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(image_filter) + dialog.set_filters(filters) + dialog.open(self.get_root(), None, self._on_icon_chosen) + + def _on_icon_chosen(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None: + try: + file = dialog.open_finish(result) + except GLib.Error: + return # user cancelled + if file is not None: + self._icon_path = file.get_path() + self._update_icon_button_label() + + def _on_clear_icon(self, _button) -> None: + self._icon_path = None + self._update_icon_button_label() + + def to_config(self) -> TitleConfig: + return TitleConfig( + id=self.id_entry.get_text().strip(), + name=self.name_entry.get_text().strip(), + hotkey=self._hotkey, + cooldown_seconds=self.cooldown_spin.get_value(), + color=_color_to_hex(self.color_button.get_rgba()), + pre_hotkey=self._pre_hotkey, + standby_timeout_seconds=self.standby_timeout_spin.get_value() or None, + restart_on_repeat=self.restart_check.get_active(), + icon=self._icon_path, + ) + + +class SettingsWindow(Gtk.ApplicationWindow): + """GTK4 editor for config.json - character profiles, titles, hotkeys, + cooldowns, colors, dogma mode, and overlay anchor/margin/scale. + + Normally runs right alongside the overlay (see __main__.py); `on_saved`, + if given, is called with the newly saved AppConfig and its path right + after a successful save (or a profile switch/new/rename/duplicate, + which reload from disk the same way), so the running overlay can pick + up the change immediately instead of needing a restart. + + `profile_name` is the active character profile, or None if the app was + launched with an explicit `--config` path that bypasses the profile + system entirely - in that case the profile-management row is hidden, + since there's no profile concept to manage. + """ + + def __init__( + self, + app: Gtk.Application, + config: AppConfig, + config_path: Path, + profile_name: str | None = None, + on_saved: Callable[[AppConfig, Path], None] | None = None, + ): + super().__init__(application=app, title=t("settings_title")) + self._config_path = config_path + self._profile_name = profile_name + self._on_saved = on_saved + self._overlay_x = config.overlay.x + self._overlay_y = config.overlay.y + self.set_default_size(760, 860) + + self.connect("map", self._on_map) + self._setup_css() + self._build_ui() + self._populate_from_config(config) + + def _on_map(self, _widget) -> None: + # Must run on "map", not "realize" - GTK4 sets (or clears) + # _NET_WM_ICON itself around map time based on its own desktop-file + # icon-theme lookup, which finds nothing useful for an unintegrated + # AppImage run and wipes out whatever was set earlier. Same ordering + # requirement as _NET_WM_STATE_ABOVE in x11_overlay.raise_above()/ + # make_overlay(), for an analogous reason (Mutter there instead of + # GTK itself, but same "something else touches this after realize, + # so we have to go last" shape). + xid = x11_overlay.get_xid(self) + d = x11_overlay.connect() + x11_overlay.set_wm_icon(d, xid, str(APP_ICON_PATH)) + + def _setup_css(self) -> None: + # Registered once against the display, not per _build_ui() call - + # CSS class definitions are global, only the widgets carrying + # "title-row" get rebuilt on a language switch/profile reload. + provider = Gtk.CssProvider() + provider.load_from_string(_CSS) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + def _build_ui(self) -> None: + """(Re-)builds the entire widget tree from scratch. + + Called once from __init__, and again from _on_language_changed to + pick up new label/tooltip text - simplest way to re-translate + everything without hand-tracking every widget that holds + translated text. Replaces `self`'s child in place rather than + creating a new window, so external wiring (app.add_window, + "close-request") stays intact. Caller must follow up with + _populate_from_config to restore values into the fresh widgets. + """ + root = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=14, + margin_top=14, + margin_bottom=14, + margin_start=14, + margin_end=14, + ) + self.set_child(root) + + # -- General: language, character profile, dogma mode -- + general_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=8, + margin_top=8, + margin_bottom=8, + margin_start=8, + margin_end=8, + ) + general_frame = Gtk.Frame(label=t("general_header")) + general_frame.set_child(general_box) + root.append(general_frame) + + language_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + language_row.append(Gtk.Label(label=t("language_label"))) + # Language names are proper nouns, not translated by current + # language - "Deutsch"/"English" stay put either way so the option + # is always readable regardless of which one is currently active. + self._suppress_language_switch = True + self.language_dropdown = Gtk.DropDown.new_from_strings(["Deutsch", "English"]) + self.language_dropdown.set_selected(0 if i18n.get_language() == "de" else 1) + self._suppress_language_switch = False + self.language_dropdown.connect("notify::selected", self._on_language_changed) + language_row.append(self.language_dropdown) + general_box.append(language_row) + + self.profile_dropdown: Gtk.DropDown | None = None + self._suppress_profile_switch = False + if self._profile_name is not None: + general_box.append(self._build_profile_row()) + self._refresh_profile_dropdown() + self._update_window_title() + + dogma_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + dogma_row.append(Gtk.Label(label=t("dogma_mode"), hexpand=True, xalign=0)) + self.dogma_switch = Gtk.Switch() + self.dogma_switch.set_valign(Gtk.Align.CENTER) + dogma_row.append(self.dogma_switch) + general_box.append(dogma_row) + + # -- Overlay: anchor/margin/scale, drag-set position -- + overlay_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=8, + margin_top=8, + margin_bottom=8, + margin_start=8, + margin_end=8, + ) + overlay_frame = Gtk.Frame(label=t("overlay_header")) + overlay_frame.set_child(overlay_box) + root.append(overlay_frame) + + overlay_box.append(Gtk.Label(label=t("overlay_position"), xalign=0)) + position_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.anchor_dropdown = Gtk.DropDown.new_from_strings(_ANCHORS) + position_row.append(self.anchor_dropdown) + position_row.append(Gtk.Label(label=t("margin_x"))) + self.margin_x_spin = Gtk.SpinButton.new_with_range(0, 500, 1) + position_row.append(self.margin_x_spin) + position_row.append(Gtk.Label(label=t("margin_y"))) + self.margin_y_spin = Gtk.SpinButton.new_with_range(0, 500, 1) + position_row.append(self.margin_y_spin) + overlay_box.append(position_row) + + scale_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + scale_row.append(Gtk.Label(label=t("scale"), hexpand=True, xalign=0)) + self.scale_spin = Gtk.SpinButton.new_with_range(0.5, 3.0, 0.1) + self.scale_spin.set_digits(2) + scale_row.append(self.scale_spin) + overlay_box.append(scale_row) + + drag_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.position_status_label = Gtk.Label(xalign=0, hexpand=True) + self.position_status_label.add_css_class("dim-label") + drag_row.append(self.position_status_label) + reset_button = Gtk.Button(label=t("reset_position")) + reset_button.connect("clicked", self._on_reset_position) + drag_row.append(reset_button) + overlay_box.append(drag_row) + + # -- Titles -- + titles_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=6, + margin_top=8, + margin_bottom=8, + margin_start=8, + margin_end=8, + vexpand=True, + ) + titles_frame = Gtk.Frame(label=t("titles_header"), vexpand=True) + titles_frame.set_child(titles_box) + root.append(titles_frame) + + header_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + header_row.add_css_class("title-row-header") + header_row.add_css_class("dim-label") + id_header = Gtk.Label(label=t("col_id"), xalign=0, width_chars=8) + name_header = Gtk.Label(label=t("col_name"), xalign=0, hexpand=True) + hotkey_header = Gtk.Label(label=t("col_hotkey"), xalign=0) + hotkey_header.set_size_request(130, -1) + cooldown_header = Gtk.Label(label=t("col_cooldown"), xalign=0) + # Trailing spacer with no label - the data rows have a "Retriggerable" + # checkbox + remove button after the cooldown spinner that have no + # header counterpart; without this, name_header's hexpand swallows + # that width too and the hotkey/cooldown headers drift right of + # their actual columns. + trailing_spacer = Gtk.Label(label="") + trailing_spacer.set_size_request(178, -1) + for widget in (id_header, name_header, hotkey_header, cooldown_header, trailing_spacer): + header_row.append(widget) + titles_box.append(header_row) + + self._title_rows: list[_TitleRow] = [] + self._title_rows_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + scroller = Gtk.ScrolledWindow(vexpand=True) + scroller.set_child(self._title_rows_box) + titles_box.append(scroller) + + add_button = Gtk.Button(label=t("add_title")) + add_button.connect( + "clicked", + lambda _b: self._add_title_row( + TitleConfig(id="", name="", hotkey=(), cooldown_seconds=60.0) + ), + ) + titles_box.append(add_button) + + self.status_label = Gtk.Label(xalign=0) + root.append(self.status_label) + + button_row = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=6, halign=Gtk.Align.END + ) + cancel_button = Gtk.Button(label=t("close")) + cancel_button.connect("clicked", lambda _b: self.close()) + save_button = Gtk.Button(label=t("save")) + save_button.add_css_class("suggested-action") + save_button.connect("clicked", self._on_save) + button_row.append(cancel_button) + button_row.append(save_button) + root.append(button_row) + + def _on_language_changed(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._suppress_language_switch: + return + selected = dropdown.get_selected_item() + if selected is None: + return + lang = "de" if selected.get_string() == "Deutsch" else "en" + if lang == i18n.get_language(): + return + i18n.set_language(lang) + save_language(lang) + # Deferred for the same reason _load_profile defers via idle_add: + # this handler runs while GTK is still mid-emission for + # "notify::selected" on the very dropdown _build_ui is about to + # replace - doing that synchronously segfaults. + GLib.idle_add(self._reload_after_language_change) + + def _reload_after_language_change(self) -> bool: + config = load_config(self._config_path) + self._overlay_x = config.overlay.x + self._overlay_y = config.overlay.y + self._build_ui() + self._populate_from_config(config) + return GLib.SOURCE_REMOVE + + # -- character profiles -- + # + # Each character is a full, separate config.json under + # ~/.config/titletimer/profiles/ (see config.py). Switching, creating, + # renaming, duplicating or deleting one always ends by reloading + # whatever profile is now active from disk into this same window + # (_load_profile) rather than trying to merge it with in-progress edits + # - simplest mental model, and consistent with "Speichern" already being + # the only thing that persists edits at all. + + def _build_profile_row(self) -> Gtk.Box: + profile_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + profile_row.append(Gtk.Label(label=t("profile_label"))) + self.profile_dropdown = Gtk.DropDown(hexpand=True) + self.profile_dropdown.connect("notify::selected", self._on_profile_dropdown_changed) + profile_row.append(self.profile_dropdown) + + new_button = Gtk.Button(label=t("profile_new")) + new_button.connect("clicked", self._on_new_profile) + profile_row.append(new_button) + + duplicate_button = Gtk.Button(label=t("profile_duplicate")) + duplicate_button.connect("clicked", self._on_duplicate_profile) + profile_row.append(duplicate_button) + + rename_button = Gtk.Button(label=t("profile_rename")) + rename_button.connect("clicked", self._on_rename_profile) + profile_row.append(rename_button) + + delete_button = Gtk.Button(label=t("delete")) + delete_button.connect("clicked", self._on_delete_profile) + profile_row.append(delete_button) + + return profile_row + + def _update_window_title(self) -> None: + title = t("settings_title") + if self._profile_name is not None: + title += f" ({self._profile_name})" + self.set_title(title) + + def _refresh_profile_dropdown(self) -> None: + if self.profile_dropdown is None: + return + profiles = list_profiles() + self._suppress_profile_switch = True + self.profile_dropdown.set_model(Gtk.StringList.new(profiles)) + if self._profile_name in profiles: + self.profile_dropdown.set_selected(profiles.index(self._profile_name)) + self._suppress_profile_switch = False + + def _on_profile_dropdown_changed(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._suppress_profile_switch: + return + selected = dropdown.get_selected_item() + if selected is None: + return + name = selected.get_string() + if name != self._profile_name: + self._load_profile(name) + + def _load_profile(self, name: str) -> None: + """Defers to _load_profile_now via idle_add. + + Every caller here (dropdown selection, new/rename/duplicate/delete) + runs from inside a GTK signal handler that GTK itself is still + unwinding - most importantly the dropdown's own "notify::selected", + which fires while GtkDropDown is still processing the click that + selected the item. _load_profile_now replaces the dropdown's model + (among other things), and doing that synchronously while GTK is + still mid-emission for that very widget segfaults (observed: + SIGSEGV in g_object_notify_by_pspec/g_type_check_instance_is_ + fundamentally_a - a use-after-free on the just-replaced model). + Scheduling the real work for the next main-loop iteration lets GTK + finish its own dispatch first. + """ + GLib.idle_add(self._load_profile_now, name) + + def _load_profile_now(self, name: str) -> None: + """Load `name` from disk and make it the active profile: repopulate + every widget from its config, remember it as last-active, and tell + the running overlay (if any) to pick it up too. + """ + path = profile_path(name) + try: + config = load_config(path) + except (FileNotFoundError, ValueError) as exc: + self.status_label.set_text(t("error_loading_profile", name=name, error=exc)) + return + self._profile_name = name + self._config_path = path + set_active_profile(name) + self._populate_from_config(config) + self._refresh_profile_dropdown() + self._update_window_title() + if self._on_saved is not None: + self._on_saved(config, path) + + def _on_new_profile(self, _button) -> None: + _prompt_name(self, t("new_character_title"), "", self._create_profile) + + def _create_profile(self, name: str | None) -> None: + if name is None: + return + name = name.strip() + if not name: + return + if name in list_profiles(): + self.status_label.set_text(t("error_profile_exists", name=name)) + return + try: + path = profile_path(name) + except ValueError as exc: + self.status_label.set_text(t("error_generic", error=exc)) + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(EXAMPLE_CONFIG_PATH.read_text()) + self._load_profile(name) + self.status_label.set_text(t("profile_created", name=name)) + + def _on_duplicate_profile(self, _button) -> None: + _prompt_name( + self, + t("duplicate_character_title"), + t("duplicate_name_suffix", name=self._profile_name), + self._do_duplicate_profile, + ) + + def _do_duplicate_profile(self, new_name: str | None) -> None: + if new_name is None: + return + new_name = new_name.strip() + if not new_name: + return + if new_name in list_profiles(): + self.status_label.set_text(t("error_profile_exists", name=new_name)) + return + try: + duplicate_profile(self._profile_name, new_name) + except (ValueError, OSError) as exc: + self.status_label.set_text(t("error_generic", error=exc)) + return + self._load_profile(new_name) + self.status_label.set_text(t("profile_duplicated", name=new_name)) + + def _on_rename_profile(self, _button) -> None: + _prompt_name(self, t("rename_character_title"), self._profile_name, self._do_rename_profile) + + def _do_rename_profile(self, new_name: str | None) -> None: + if new_name is None: + return + new_name = new_name.strip() + if not new_name or new_name == self._profile_name: + return + if new_name in list_profiles(): + self.status_label.set_text(t("error_profile_exists", name=new_name)) + return + try: + rename_profile(self._profile_name, new_name) + except (ValueError, OSError) as exc: + self.status_label.set_text(t("error_generic", error=exc)) + return + self._load_profile(new_name) + self.status_label.set_text(t("profile_renamed", name=new_name)) + + def _on_delete_profile(self, _button) -> None: + if len(list_profiles()) <= 1: + self.status_label.set_text(t("error_cannot_delete_last")) + return + _confirm( + self, + t("delete_character_title"), + t("delete_character_confirm", name=self._profile_name), + self._do_delete_profile, + ) + + def _do_delete_profile(self, confirmed: bool) -> None: + if not confirmed: + return + removed_name = self._profile_name + delete_profile(removed_name) + remaining = list_profiles() + self._load_profile(remaining[0]) + self.status_label.set_text(t("profile_deleted", name=removed_name)) + + # -- form population / titles -- + + def _populate_from_config(self, config: AppConfig) -> None: + self._overlay_x = config.overlay.x + self._overlay_y = config.overlay.y + self.dogma_switch.set_active(config.dogma_mode) + self.anchor_dropdown.set_selected(_ANCHORS.index(config.overlay.anchor)) + self.margin_x_spin.set_value(config.overlay.margin_x) + self.margin_y_spin.set_value(config.overlay.margin_y) + self.scale_spin.set_value(config.overlay.scale) + self._update_position_status() + + for row in list(self._title_rows): + self._remove_title_row(row) + for title in config.titles: + self._add_title_row(title) + + def _add_title_row(self, title: TitleConfig) -> None: + row = _TitleRow(title, self._remove_title_row) + self._title_rows.append(row) + self._title_rows_box.append(row) + + def _remove_title_row(self, row: _TitleRow) -> None: + self._title_rows.remove(row) + self._title_rows_box.remove(row) + + def _on_reset_position(self, _button) -> None: + self._overlay_x = None + self._overlay_y = None + self._update_position_status() + + def _update_position_status(self) -> None: + if self._overlay_x is not None and self._overlay_y is not None: + self.position_status_label.set_text( + t("position_dragged", x=self._overlay_x, y=self._overlay_y) + ) + else: + self.position_status_label.set_text(t("position_follows_anchor")) + + def _on_save(self, _button) -> None: + titles = [row.to_config() for row in self._title_rows] + + if not titles: + self.status_label.set_text(t("error_need_one_title")) + return + if any(not title.id or not title.name or not title.hotkey for title in titles): + self.status_label.set_text(t("error_empty_fields")) + return + ids = [title.id for title in titles] + if len(set(ids)) != len(ids): + self.status_label.set_text(t("error_duplicate_id")) + return + all_hotkeys = [title.hotkey for title in titles] + [ + title.pre_hotkey for title in titles if title.pre_hotkey + ] + if len(set(all_hotkeys)) != len(all_hotkeys): + self.status_label.set_text(t("error_duplicate_hotkey")) + return + + new_config = AppConfig( + dogma_mode=self.dogma_switch.get_active(), + overlay=OverlayConfig( + anchor=_ANCHORS[self.anchor_dropdown.get_selected()], + margin_x=int(self.margin_x_spin.get_value()), + margin_y=int(self.margin_y_spin.get_value()), + scale=round(self.scale_spin.get_value(), 2), + x=self._overlay_x, + y=self._overlay_y, + ), + titles=titles, + ) + save_config(self._config_path, new_config) + self.status_label.set_text(t("saved_to", path=self._config_path)) + if self._on_saved is not None: + self._on_saved(new_config, self._config_path) diff --git a/src/titletimer/state.py b/src/titletimer/state.py new file mode 100644 index 0000000..784de6d --- /dev/null +++ b/src/titletimer/state.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, auto +from time import monotonic +from typing import Callable + +from .config import AppConfig, TitleConfig + + +class TitleState(Enum): + IDLE = auto() + STANDBY = auto() # step 1: active border shown, waiting for confirm press + COOLDOWN = auto() # step 2: timer running + + +@dataclass +class TitleRuntime: + config: TitleConfig + state: TitleState = TitleState.IDLE + cooldown_end: float | None = None + standby_since: float | None = None + + def remaining_fraction(self, now: float) -> float: + """1.0 = just started, 0.0 = about to expire. 0 outside COOLDOWN.""" + if self.state != TitleState.COOLDOWN or self.cooldown_end is None: + return 0.0 + total = self.config.cooldown_seconds + if total <= 0: + return 0.0 + remaining = self.cooldown_end - now + return max(0.0, min(1.0, remaining / total)) + + def remaining_seconds(self, now: float) -> float: + if self.state != TitleState.COOLDOWN or self.cooldown_end is None: + return 0.0 + return max(0.0, self.cooldown_end - now) + + def standby_remaining_seconds(self, now: float) -> float | None: + """Seconds left to press step 2 before STANDBY auto-reverts, or + None if this title is either not in STANDBY or has no timeout + configured (stays in STANDBY indefinitely).""" + timeout = self.config.standby_timeout_seconds + if self.state != TitleState.STANDBY or self.standby_since is None or timeout is None: + return None + return max(0.0, timeout - (now - self.standby_since)) + + +class TitleTimerEngine: + """Title switching with an optional pre-activation step and Dogma mutex. + + A title with no pre_hotkey configured: the main hotkey starts the + cooldown directly on a single press. + + A title *with* a pre_hotkey configured: that combo is required for step + 1 (IDLE -> STANDBY, shown as a pulsing border) - the main hotkey is + step-2-only then and does nothing while IDLE, so there's no way to + shortcut around the pre-activation combo by pressing the main hotkey + twice. The optional standby_timeout_seconds only applies to this + pre-activation flow, auto-reverting a forgotten STANDBY back to IDLE. + + Dogma mode: while any title is COOLDOWN, hotkeys for *other* titles are + ignored outright, so a running timer can't be interfered with. + """ + + def __init__(self, config: AppConfig, on_change: Callable[[], None]): + self.config = config + self.on_change = on_change + self.runtimes: dict[str, TitleRuntime] = { + t.id: TitleRuntime(config=t) for t in config.titles + } + self._by_hotkey: dict[frozenset[str], str] = { + frozenset(t.hotkey): t.id for t in config.titles + } + self._by_pre_hotkey: dict[frozenset[str], str] = { + frozenset(t.pre_hotkey): t.id for t in config.titles if t.pre_hotkey + } + + def handle_hotkey(self, held: frozenset[str], pressed_key: str) -> None: + """`held` is every key currently down (e.g. a movement key the + player is holding plus the key they just pressed); `pressed_key` is + specifically the one that just went down. + + A configured hotkey fires when its own keys are all a *subset* of + `held` - not an exact match - so a hotkey still fires while an + unrelated key (movement, usually) is also held. It only fires on + the keydown that actually completes it (`pressed_key` must be one + of its keys), so already-held extra keys don't cause spurious + re-fires when some other, unrelated key is pressed afterwards. If + more than one configured combo would match, the most specific one + (most keys) wins - e.g. Ctrl+C beats a plain C bound elsewhere. + """ + match = self._best_match(self._by_hotkey, held, pressed_key) + if match is not None: + self._advance(match) + return + + pre_match = self._best_match(self._by_pre_hotkey, held, pressed_key) + if pre_match is not None: + self._enter_standby(pre_match) + + @staticmethod + def _best_match( + table: dict[frozenset[str], str], held: frozenset[str], pressed_key: str + ) -> str | None: + candidates = [ + (combo, title_id) + for combo, title_id in table.items() + if pressed_key in combo and combo <= held + ] + if not candidates: + return None + return max(candidates, key=lambda c: len(c[0]))[1] + + def _advance(self, title_id: str) -> None: + rt = self.runtimes[title_id] + if rt.state is TitleState.IDLE: + if rt.config.pre_hotkey: + # A pre-activation combo is configured for this title, so + # the main hotkey is step-2-only - it must never itself + # trigger step 1, or the main hotkey pressed twice in a row + # would work as an (unwanted) shortcut around the + # pre-activation combo. + return + # No pre-activation configured: a single press starts the + # cooldown directly, no standby step in between. + self._start_cooldown(title_id) + elif rt.state is TitleState.STANDBY: + self._start_cooldown(title_id) + elif rt.config.restart_on_repeat: + self._start_cooldown(title_id) # COOLDOWN, but re-triggering is allowed + # else: COOLDOWN and re-triggering not enabled - ignore the press + + def _start_cooldown(self, title_id: str) -> None: + rt = self.runtimes[title_id] + if self.config.dogma_mode and self._any_cooldown_except(title_id): + return + rt.state = TitleState.COOLDOWN + rt.cooldown_end = monotonic() + rt.config.cooldown_seconds + rt.standby_since = None + self.on_change() + + def _enter_standby(self, title_id: str) -> None: + rt = self.runtimes[title_id] + if rt.state is not TitleState.IDLE: + return # pre_hotkey only ever does step 1, never step 2 + if self.config.dogma_mode and self._any_cooldown_except(title_id): + return + self._clear_other_standbys(keep=title_id) + rt.state = TitleState.STANDBY + rt.standby_since = monotonic() + self.on_change() + + def tick(self) -> None: + now = monotonic() + changed = False + for rt in self.runtimes.values(): + if rt.state is TitleState.COOLDOWN and rt.cooldown_end is not None and now >= rt.cooldown_end: + rt.state = TitleState.IDLE + rt.cooldown_end = None + changed = True + elif rt.state is TitleState.STANDBY and rt.config.standby_timeout_seconds is not None: + if rt.standby_since is not None and now - rt.standby_since >= rt.config.standby_timeout_seconds: + rt.state = TitleState.IDLE + rt.standby_since = None + changed = True + if changed: + self.on_change() + + def snapshot(self) -> list[TitleRuntime]: + return list(self.runtimes.values()) + + def _any_cooldown_except(self, title_id: str) -> bool: + return any( + rt.state is TitleState.COOLDOWN + for tid, rt in self.runtimes.items() + if tid != title_id + ) + + def _clear_other_standbys(self, keep: str) -> None: + for tid, rt in self.runtimes.items(): + if tid != keep and rt.state is TitleState.STANDBY: + rt.state = TitleState.IDLE + rt.standby_since = None diff --git a/src/titletimer/x11_overlay.py b/src/titletimer/x11_overlay.py new file mode 100644 index 0000000..83eed93 --- /dev/null +++ b/src/titletimer/x11_overlay.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +import glob +import logging +import os +import stat + +import gi + +gi.require_version("GdkX11", "4.0") +from gi.repository import GdkX11 # noqa: E402 + +from Xlib import X, Xatom, display, error, xauth +from Xlib.ext import randr, shape +from Xlib.support import connect as xlib_connect + +from .config import OverlayConfig + +logger = logging.getLogger(__name__) + +# X11-auth-cookie filename patterns seen in the wild, checked before the +# generic fallback below - fastest path when one of these actually matches. +_KNOWN_AUTH_GLOBS = ( + ".mutter-Xwaylandauth*", # GNOME/Mutter + "xauth_*", # some KDE/Plasma, sddm setups + ".Xauthority*", + "*Xauthority*", +) + + +def _candidate_auth_files() -> list[str]: + """Every file under $XDG_RUNTIME_DIR that could plausibly be an X11 + auth cookie, most-likely-first. + + Known naming conventions (Mutter, KDE, ...) are tried first, but the + exact name varies enough across desktop environments/distros that this + also falls back to trying every small regular file directly in the + runtime dir - a wrong guess there just fails to connect (cheap, no + other effect), so being permissive here is safe. + """ + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + seen: set[str] = set() + candidates: list[str] = [] + + for pattern in _KNOWN_AUTH_GLOBS: + for path in glob.glob(os.path.join(runtime_dir, pattern)): + if path not in seen: + seen.add(path) + candidates.append(path) + + try: + entries = os.listdir(runtime_dir) + except OSError: + entries = [] + for name in entries: + path = os.path.join(runtime_dir, name) + if path in seen: + continue + try: + st = os.stat(path) + except OSError: + continue + # X11 auth files are small regular files (a handful of cookie + # entries, typically well under 1 KiB) - this also naturally + # excludes the many sockets/dirs living alongside them in a + # typical $XDG_RUNTIME_DIR (pipewire, dbus, gvfs, systemd, ...). + if stat.S_ISREG(st.st_mode) and st.st_size <= 4096: + seen.add(path) + candidates.append(path) + + return candidates + + +def _connect_with_raw_auth(auth_name: bytes, auth_data: bytes) -> display.Display: + """Connect using an explicit MIT-MAGIC-COOKIE-1 value, bypassing + python-xlib's own Xauthority.get_best_auth() lookup entirely (there's + no public API for this - Display() always does its own file lookup + internally - so this swaps out the lookup function for the duration of + the call). + """ + original_get_auth = xlib_connect.get_auth + xlib_connect.get_auth = lambda *a, **k: (auth_name, auth_data) + try: + return display.Display() + finally: + xlib_connect.get_auth = original_get_auth + + +def _raw_cookie_from_file(path: str) -> tuple[bytes, bytes] | None: + """First MIT-MAGIC-COOKIE-1 entry in an Xauthority-format file, if any - + read directly, ignoring the family/hostname/display-number matching + Xauthority.get_best_auth() normally does. + + That matching was observed to fail ("no xauthority details available") + against a real, valid, Mutter-written auth file on a Bazzite/GNOME + machine - the same file and code that work fine on a NixOS/GNOME + machine - most likely a hostname-format mismatch specific to that + system. For a personal single-user desktop session there's essentially + always exactly one relevant entry in the file anyway, so skipping the + match and using whichever cookie is there is safe. + """ + try: + au = xauth.Xauthority(path) + except error.XauthError: + return None + for _family, _addr, _num, name, data in au.entries: + if name == b"MIT-MAGIC-COOKIE-1": + return name, data + return None + + +def connect() -> display.Display: + """Open a connection to the X server (XWayland), robust to setups where + $XAUTHORITY isn't exported in the process environment even though a + valid auth cookie exists. + + Observed on GNOME/Mutter (both a NixOS and a Bazzite machine): whether + the auth cookie's path ends up in $XAUTHORITY depends on how the + session launched the process - not guaranteed, e.g. when starting the + AppImage by double-click from a file manager instead of a terminal. + python-xlib's plain Display() only tries $XAUTHORITY/~/.Xauthority and + gives up, so this tries a broad set of candidate auth files afterwards + (see _candidate_auth_files) - deliberately not tied to one desktop + environment's naming convention, since this needs to work across + distros/DEs the app was never specifically tested on. For each + candidate, both python-xlib's own lookup (via $XAUTHORITY) and a raw + read of the file's first cookie (see _raw_cookie_from_file) are tried, + since the former alone was observed to fail against a real, valid auth + file on one machine (Bazzite) while working fine on another (NixOS). + """ + try: + return display.Display() + except error.DisplayConnectionError: + pass + + tried = [] + for path in _candidate_auth_files(): + tried.append(path) + os.environ["XAUTHORITY"] = path + try: + return display.Display() + except error.DisplayConnectionError: + pass + + cookie = _raw_cookie_from_file(path) + if cookie is not None: + try: + return _connect_with_raw_auth(*cookie) + except error.DisplayConnectionError: + continue + + display_name = os.environ.get("DISPLAY", "") + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + logger.error( + "Could not connect to X server (DISPLAY=%s). Tried %d candidate " + "auth file(s) under %s: %s", + display_name, len(tried), runtime_dir, tried, + ) + raise RuntimeError( + f"Could not connect to the X server on DISPLAY={display_name}. " + f"Tried {len(tried)} candidate auth file(s) under {runtime_dir} " + "without success. If none of those was the right one, set " + "XAUTHORITY explicitly before starting the AppImage." + ) + + +def _corner_position( + anchor: str, margin_x: int, margin_y: int, win_w: int, win_h: int, + mon_x: int, mon_y: int, mon_w: int, mon_h: int, +) -> tuple[int, int]: + if anchor == "top-left": + return mon_x + margin_x, mon_y + margin_y + if anchor == "top-right": + return mon_x + mon_w - win_w - margin_x, mon_y + margin_y + if anchor == "bottom-left": + return mon_x + margin_x, mon_y + mon_h - win_h - margin_y + if anchor == "bottom-right": + return mon_x + mon_w - win_w - margin_x, mon_y + mon_h - win_h - margin_y + raise ValueError(f"unknown anchor: {anchor}") + + +def _primary_monitor_geometry(d) -> tuple[int, int, int, int]: + """Geometry (in root-window/X11-screen pixel coordinates) of the RandR + primary output - i.e. what `xrandr --query` marks "primary". + + Multi-monitor setups with an offset secondary display (e.g. a monitor + placed lower than the primary one) mean the combined X11 screen size is + NOT the same rectangle as any single monitor - anchoring against the + combined screen can place the window in the dead strip between two + offset monitors, where nothing is actually displayed. GDK4 also dropped + the "primary monitor" concept from GdkMonitor entirely, so this goes + straight through RandR via python-xlib instead. + """ + root = d.screen().root + resources = root.xrandr_get_screen_resources() + primary_output = root.xrandr_get_output_primary().output + + candidates = [primary_output] + [o for o in resources.outputs if o != primary_output] + for output in candidates: + info = d.xrandr_get_output_info(output, resources.config_timestamp) + if info.crtc: + crtc = d.xrandr_get_crtc_info(info.crtc, resources.config_timestamp) + return crtc.x, crtc.y, crtc.width, crtc.height + raise RuntimeError("no active RandR output found") + + +def set_click_through(d, xid: int, click_through: bool, width: int, height: int) -> None: + """Toggle the window's XShape input region between empty (click-through, + the normal state) and the full window rect (so it can receive real GDK + button/motion events for dragging). + + Earlier this toggled based on a query_pointer() hit-test instead, but + under this XWayland setup query_pointer() was observed to return a + frozen, never-updating position - XWayland only seems to track the core + pointer position while it's over a surface actually receiving pointer + events, which a click-through (empty input region) window never does. + Toggling the input region itself sidesteps that: while temporarily + "solid", the window gets real events from GDK/GTK directly, no polling + needed. + """ + window = d.create_resource_object("window", xid) + rects = [] if click_through else [(0, 0, width, height)] + window.shape_rectangles(shape.SO.Set, shape.SK.Input, X.Unsorted, 0, 0, rects) + d.flush() + + +def resize_window(d, xid: int, width: int, height: int) -> None: + """Resize an already-mapped overlay window - e.g. after a live config + change adds/removes titles or changes the scale, without restarting. + """ + window = d.create_resource_object("window", xid) + window.configure(width=width, height=height) + d.flush() + + +def move_window(d, xid: int, x: int, y: int) -> None: + """Move an already-mapped overlay window to an absolute root-window + position - drives Ctrl+Alt+drag ourselves. + + This used to hand the move off to the window manager via + _NET_WM_MOVERESIZE instead (the same protocol path normal titlebar + dragging uses) because tracking the drag client-side here - repeated + ConfigureWindow calls, even coalesced to one per motion event - was + visibly laggy: the window trailed behind the pointer and never quite + caught up, most likely because Mutter smooths/animates position changes + it didn't itself initiate as an interactive move. + + Now that the window is override-redirect (see make_overlay), that + smoothing is moot - an override-redirect window bypasses the WM + entirely, so _NET_WM_MOVERESIZE has no manager to hand off to anymore + (confirmed: window kept getting shoved back behind other windows on + COSMIC's cosmic-comp even with periodic restack_above() calls, most + likely because it only honors raise requests from the currently + focused/managed window - this overlay must never take focus). With no + WM smoothing left to fight, a plain per-motion-event ConfigureWindow + tracks the pointer directly. + """ + window = d.create_resource_object("window", xid) + window.configure(x=x, y=y) + d.flush() + + +def restack_above(d, xid: int) -> None: + """Raise this window to the top of the stacking order. + + Only reliable because the window is override-redirect (see + make_overlay): a ConfigureWindow stack_mode request against a normal, + WM-managed window is subject to SubstructureRedirect - the WM decides + whether/where it actually lands, and on COSMIC's cosmic-comp it kept + landing behind the game again regardless (even via the EWMH + _NET_WM_STATE_ABOVE ClientMessage, and even when re-sent every second - + both approaches tried and abandoned here). An override-redirect window + is by definition exempt from that redirection, so the X server applies + this immediately and unconditionally - no WM policy can second-guess + it. Called once at map time and then periodically (see overlay.py's + reraise timer) as a defensive measure, since it's cheap and this is the + property the whole always-on-top guarantee rests on. + """ + window = d.create_resource_object("window", xid) + window.configure(stack_mode=X.Above) + d.flush() + + +def get_xid(gtk_window) -> int: + """X11 window id of a realized Gtk.Window - call from a "realize" + handler or later, never before (get_surface() has nothing to return + until then). + """ + surface = gtk_window.get_surface() + if not isinstance(surface, GdkX11.X11Surface): + raise RuntimeError( + "Window is not backed by X11. Set GDK_BACKEND=x11 " + "(forces routing through XWayland) before starting the app." + ) + return surface.get_xid() + + +def set_wm_icon(d, xid: int, icon_path: str) -> None: + """Set _NET_WM_ICON directly instead of relying on desktop-file + + icon-theme lookup: GTK4 removed gtk_window_set_icon*() entirely, and an + AppImage run standalone (no "Install"/appimaged integration) has no + .desktop file placed anywhere a WM could resolve titletimer.desktop's + Icon=titletimer from in the first place - observed live: the taskbar + fell back to a generic gear icon instead of the app's own. _NET_WM_ICON + is the older, lower-level EWMH property every tested WM still reads + directly off the window itself, no icon theme or desktop file involved. + + Loads one bundled source image and derives several sizes from it at + runtime (rather than bundling each size as a separate file) - the WM + picks whichever representation fits best. Capped at 128x128 and written + as one ChangeProperty call per size (PropModeAppend after the first) + rather than one call for the whole lot - a single call for everything + up to 256x256 came within a hair of (and on a first attempt, exceeded) + the X server's default max-request-length and silently broke the + connection (process died with no Python traceback, not even a segfault + faulthandler could catch - the fault is in the X protocol layer, below + what a signal handler sees). + """ + from gi.repository import GdkPixbuf + + window = d.create_resource_object("window", xid) + net_wm_icon = d.intern_atom("_NET_WM_ICON") + base = GdkPixbuf.Pixbuf.new_from_file(icon_path) + + for i, size in enumerate((16, 32, 48, 64, 128)): + pix = base.scale_simple(size, size, GdkPixbuf.InterpType.BILINEAR) + pixels = pix.get_pixels() + stride = pix.get_rowstride() + channels = pix.get_n_channels() + chunk = [size, size] + for row in range(size): + row_start = row * stride + for col in range(size): + offset = row_start + col * channels + r, g, b = pixels[offset], pixels[offset + 1], pixels[offset + 2] + a = pixels[offset + 3] if channels == 4 else 255 + chunk.append((a << 24) | (r << 16) | (g << 8) | b) + mode = X.PropModeReplace if i == 0 else X.PropModeAppend + window.change_property(net_wm_icon, Xatom.CARDINAL, 32, chunk, mode=mode) + d.flush() + + +def make_overlay( + gtk_window, overlay: OverlayConfig, width: int, height: int, click_through: bool = True +) -> tuple[int, int, int]: + """Turn a realized Gtk.Window (X11 backend, i.e. running under XWayland) + into an always-on-top, taskbar-skipping, click-through overlay positioned + in a screen corner. + + This uses raw X11/EWMH via python-xlib instead of wlr-layer-shell, + because GNOME's Mutter (unlike Sway/Hyprland) does not implement the + Layer Shell Wayland protocol. XWayland + EWMH hints work on GNOME, KDE + and wlroots compositors alike, since XWayland is present almost + everywhere. + """ + xid = get_xid(gtk_window) + d = connect() + window = d.create_resource_object("window", xid) + + # Make the window override-redirect: the WM never manages it (no + # decorations, no reparenting, no say over its stacking order at all). + # Reported on COSMIC (cosmic-comp): the overlay kept getting pushed + # behind the game despite the always-on-top hints below plus a + # once-a-second re-raise (both the _NET_WM_STATE_ABOVE ClientMessage and + # a plain restack request) - most plausible explanation is a + # focus-stealing guard that only lets the currently focused/managed + # window win a restack, which this overlay must never be (it can't take + # focus without breaking the game underneath). Override-redirect + # sidesteps that guesswork entirely: restack_above()'s ConfigureWindow + # is exempt from SubstructureRedirect by definition, so the X server + # applies it unconditionally regardless of what any given WM's stacking + # policy would otherwise decide. Must be set before mapping - a WM + # decides whether to manage a window at MapRequest time, and this runs + # from the "realize" handler, which fires before GTK maps the window. + # Trade-off: _NET_WM_MOVERESIZE no longer has a manager to hand + # dragging off to, so Ctrl+Alt+drag is now tracked client-side instead + # (see move_window()). + window.change_attributes(override_redirect=1) + + # A real WM ignores all of this for an override-redirect window - these + # are set anyway on the off chance some other X11-aware tool (a pager, a + # task switcher) inspects unmanaged windows too; costs nothing either way. + net_wm_window_type = d.intern_atom("_NET_WM_WINDOW_TYPE") + utility = d.intern_atom("_NET_WM_WINDOW_TYPE_UTILITY") + window.change_property(net_wm_window_type, Xatom.ATOM, 32, [utility]) + + net_wm_state = d.intern_atom("_NET_WM_STATE") + above = d.intern_atom("_NET_WM_STATE_ABOVE") + skip_taskbar = d.intern_atom("_NET_WM_STATE_SKIP_TASKBAR") + skip_pager = d.intern_atom("_NET_WM_STATE_SKIP_PAGER") + sticky = d.intern_atom("_NET_WM_STATE_STICKY") + window.change_property( + net_wm_state, Xatom.ATOM, 32, [above, skip_taskbar, skip_pager, sticky] + ) + + # Pin to every virtual desktop/workspace, not just whichever one happens + # to be active when the window is created - otherwise the overlay can + # silently end up on a workspace the user isn't currently looking at + # (observed: window correctly placed and mapped, but on desktop 1 while + # the user was on desktop 0). STICKY above is the modern hint for this; + # _NET_WM_DESKTOP = -1 (all-ones) is the older, more broadly honored one + # - setting both covers more window managers. + net_wm_desktop = d.intern_atom("_NET_WM_DESKTOP") + window.change_property(net_wm_desktop, Xatom.CARDINAL, 32, [0xFFFFFFFF]) + + if overlay.x is not None and overlay.y is not None: + x, y = overlay.x, overlay.y + else: + mon_x, mon_y, mon_w, mon_h = _primary_monitor_geometry(d) + x, y = _corner_position( + overlay.anchor, overlay.margin_x, overlay.margin_y, + width, height, mon_x, mon_y, mon_w, mon_h, + ) + window.configure(x=x, y=y, stack_mode=X.Above) + + if click_through: + window.shape_rectangles(shape.SO.Set, shape.SK.Input, X.Unsorted, 0, 0, []) + + d.sync() + return xid, x, y diff --git a/tests/test_branding.py b/tests/test_branding.py new file mode 100644 index 0000000..680e846 --- /dev/null +++ b/tests/test_branding.py @@ -0,0 +1,41 @@ +"""Regression checks for the user-visible Korin Timer product name.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class BrandingTests(unittest.TestCase): + def test_desktop_entry_uses_korin_timer(self) -> None: + desktop_entry = (ROOT / "packaging" / "korin-timer.desktop").read_text() + + self.assertIn("Name=Korin Timer", desktop_entry) + self.assertIn("Comment=2-step title switching timer for Elsword", desktop_entry) + + def test_appimage_has_korin_timer_filename(self) -> None: + build_script = (ROOT / "packaging" / "build-appimage.sh").read_text() + + self.assertIn('OUT="${BUILD_DIR}/Korin-Timer.AppImage"', build_script) + + def test_settings_titles_use_korin_timer(self) -> None: + translations = (ROOT / "src" / "titletimer" / "i18n.py").read_text() + + self.assertIn('"settings_title": {"de": "Korin Timer - Einstellungen",', translations) + self.assertIn('"en": "Korin Timer - Settings"}', translations) + + def test_gtk_application_ids_use_korin_timer(self) -> None: + main_module = (ROOT / "src" / "titletimer" / "__main__.py").read_text() + + self.assertIn('application_id="dev.korintimer.settings"', main_module) + self.assertIn('application_id="dev.korintimer.overlay"', main_module) + + def test_overlay_registers_the_cairo_foreign_converter(self) -> None: + overlay_module = (ROOT / "src" / "titletimer" / "overlay.py").read_text() + + self.assertIn('gi.require_foreign("cairo")', overlay_module) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gitea_actions_workflow.py b/tests/test_gitea_actions_workflow.py new file mode 100644 index 0000000..38d61c9 --- /dev/null +++ b/tests/test_gitea_actions_workflow.py @@ -0,0 +1,26 @@ +"""Regression checks for the AppImage Gitea Actions workflow.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".gitea" / "workflows" / "build-appimage.yaml" + + +class GiteaActionsWorkflowTests(unittest.TestCase): + def test_build_workflow_creates_and_uploads_the_appimage(self) -> None: + workflow = WORKFLOW.read_text() + + self.assertIn("on:", workflow) + self.assertIn("workflow_dispatch:", workflow) + self.assertIn("runs-on: ubuntu-latest", workflow) + self.assertIn("container: node:24-trixie", workflow) + self.assertIn("python3-gi-cairo", workflow) + self.assertIn("./packaging/build-appimage.sh", workflow) + self.assertIn("actions/upload-artifact@v3", workflow) + self.assertIn("path: build/Korin-Timer.AppImage", workflow) + + +if __name__ == "__main__": + unittest.main()