Initial commit: Korin Timer
Build AppImage / build-appimage (push) Successful in 42s

This commit is contained in:
2026-08-03 20:42:26 +02:00
commit 7edbd09538
29 changed files with 3238 additions and 0 deletions
+46
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
build/
*.AppImage
docs/
+90
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
src/titletimer/config.example.json
+16
View File
@@ -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 "$@"
+74
View File
@@ -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
+110
View File
@@ -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}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+8
View File
@@ -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
+10
View File
@@ -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
View File
+196
View File
@@ -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())
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+25
View File
@@ -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"
}
]
}
+309
View File
@@ -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()
+82
View File
@@ -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 `<Alt>`) - 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)
+68
View File
@@ -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()
+98
View File
@@ -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)
+153
View File
@@ -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
+62
View File
@@ -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
+392
View File
@@ -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()
+822
View File
@@ -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)
+184
View File
@@ -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
+420
View File
@@ -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", "<unset>")
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
+41
View File
@@ -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()
+26
View File
@@ -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()