This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
*.AppImage
|
||||||
|
docs/
|
||||||
@@ -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.
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
src/titletimer/config.example.json
|
||||||
Executable
+16
@@ -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 "$@"
|
||||||
Executable
+74
@@ -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
|
||||||
Executable
+110
@@ -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 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user