"""Keeping the mini timer above everything else — on Wayland, for real. Wayland has no "always on top" an ordinary application can ask for: the `Qt.WindowStaysOnTopHint` flag is honoured on X11 and quietly ignored by a Wayland compositor. KWin does have the mechanism, but it lives in *window rules*, so the only honest way to float above other applications on KDE is to install one — which is what this module does, reversibly and with a backup. The rule matches this one window (title `Kadence mini`, app id `kadence`) and forces: keep above, skip the task bar, skip the switcher, skip the pager. KWin is asked to re-read its configuration over D-Bus, so it takes effect at once without logging out. Everywhere else this is a no-op and the Qt flag does the job. """ from __future__ import annotations import configparser import logging import os import shutil import uuid from pathlib import Path from PyQt6.QtDBus import QDBusConnection, QDBusInterface log = logging.getLogger(__name__) DESCRIPTION = "Kadence mini timer" WINDOW_TITLE = "Kadence mini" WINDOW_CLASS = "kadence" # KWin's rule-policy codes: 2 is "Force". _FORCE = "2" # KWin's string-match codes: 1 is "Exact Match", 2 is "Substring Match". _EXACT = "1" _SUBSTRING = "2" # Matched on the window title, as a **substring**. # # Two traps here, both found by asking KWin what it actually sees: # * Qt appends the application's display name, so the window KWin knows is # "Kadence mini — Kadence", and an exact match on "Kadence mini" never # fired; # * the app id depends on how the app was started (`kadence` from the desktop # entry, `python3` from a bare `python3 -m ...`), so requiring the class as # well would make the rule stop matching for some launches. RULE = { "Description": DESCRIPTION, "above": "true", "aboverule": _FORCE, "skiptaskbar": "true", "skiptaskbarrule": _FORCE, "skipswitcher": "true", "skipswitcherrule": _FORCE, "skippager": "true", "skippagerrule": _FORCE, "title": WINDOW_TITLE, "titlematch": _SUBSTRING, "wmclassmatch": "0", } def rules_path() -> Path: base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") return Path(base) / "kwinrulesrc" def supported() -> bool: """Only KDE has this mechanism; elsewhere the Qt flag is all there is.""" desktop = os.environ.get("XDG_CURRENT_DESKTOP", "") return "KDE" in desktop.upper() def _parser() -> configparser.RawConfigParser: parser = configparser.RawConfigParser(strict=False) # KDE keys are case-sensitive ("Description" must not become "description"). parser.optionxform = str # type: ignore[assignment] return parser def _load() -> configparser.RawConfigParser: parser = _parser() path = rules_path() if path.exists(): parser.read(path, encoding="utf-8") if not parser.has_section("General"): parser.add_section("General") return parser def _our_section(parser: configparser.RawConfigParser) -> str | None: for section in parser.sections(): if section == "General": continue if parser.get(section, "Description", fallback="") == DESCRIPTION: return section return None def _save(parser: configparser.RawConfigParser) -> None: path = rules_path() path.parent.mkdir(parents=True, exist_ok=True) # One backup, before the first time we ever touch the file. backup = path.with_suffix(path.suffix + ".kadence-backup") if path.exists() and not backup.exists(): shutil.copy2(path, backup) temp = path.with_suffix(path.suffix + ".kadence-tmp") with temp.open("w", encoding="utf-8") as handle: parser.write(handle, space_around_delimiters=False) temp.replace(path) def _reconfigure() -> None: """Ask KWin to re-read its rules; without it the change waits for a relogin.""" interface = QDBusInterface("org.kde.KWin", "/KWin", "org.kde.KWin", QDBusConnection.sessionBus()) if interface.isValid(): interface.call("reconfigure") def _wanted(position: tuple[int, int] | None) -> dict[str, str]: """The rule as it should be, with the forced position when there is one.""" rule = dict(RULE) if position is not None: # KWin stores a forced position as "x,y" — this is what places the # window on Wayland, where a client may not place itself. rule["position"] = f"{position[0]},{position[1]}" rule["positionrule"] = _FORCE return rule def installed() -> bool: try: return _our_section(_load()) is not None except (configparser.Error, OSError): return False def up_to_date(position: tuple[int, int] | None = None) -> bool: """Whether the installed rule matches what this version would write. An older build wrote an exact title match that never fired; without this check an upgrade would leave the broken rule in place forever, because the rule *exists*. The position is part of the comparison, so moving the mini timer to another corner rewrites the rule — and nothing else does. """ try: parser = _load() section = _our_section(parser) if section is None: return False wanted = _wanted(position) if any(parser.get(section, key, fallback=None) != value for key, value in wanted.items()): return False # A position left over from a previous corner must not linger. return not (position is None and parser.has_option(section, "position")) except (configparser.Error, OSError): return False def apply(position: tuple[int, int] | None = None) -> tuple[bool, str]: """Install (or refresh) the rule, optionally forcing a position.""" if not supported(): return False, "Keeping a window above others this way needs KWin (KDE)." try: parser = _load() section = _our_section(parser) or str(uuid.uuid4()) if not parser.has_section(section): parser.add_section(section) for key in ("position", "positionrule"): parser.remove_option(section, key) for key, value in _wanted(position).items(): parser.set(section, key, value) listed = [item for item in parser.get("General", "rules", fallback="").split(",") if item] if section not in listed: listed.append(section) parser.set("General", "rules", ",".join(listed)) parser.set("General", "count", str(len(listed))) _save(parser) except (configparser.Error, OSError) as exc: log.warning("could not write the KWin rule: %s", exc) return False, f"Could not write {rules_path()}: {exc}" _reconfigure() if position is not None: return True, "KWin keeps the mini timer above other windows, in the corner you chose." return True, "KWin will now keep the mini timer above other windows." def remove() -> tuple[bool, str]: """Take the rule back out, leaving the rest of the file alone.""" try: parser = _load() section = _our_section(parser) if section is None: return True, "No KWin rule to remove." parser.remove_section(section) listed = [ item for item in parser.get("General", "rules", fallback="").split(",") if item and item != section ] parser.set("General", "rules", ",".join(listed)) parser.set("General", "count", str(len(listed))) _save(parser) except (configparser.Error, OSError) as exc: return False, f"Could not update {rules_path()}: {exc}" _reconfigure() return True, "Removed the KWin rule; the mini timer floats only as far as Qt can ask."