pax 095942c524 popout/hyprland: extract _hyprctl_* helpers with re-export shims
Pure refactor: moves the three Hyprland IPC helpers
(_hyprctl_get_window, _hyprctl_resize, _hyprctl_resize_and_move)
out of FullscreenPreview's class body and into a new sibling
hyprland.py module. The class methods become 1-line shims that
call the module functions, preserving byte-for-byte call-site
compatibility for the existing window.py code (_fit_to_content,
_enter_fullscreen, closeEvent all keep using self._hyprctl_*).

The module-level functions take the window title as a parameter
instead of reading it from self.windowTitle(), so they're cleanly
testable without a Qt instance.

Two reasons for the split:

1. **Architecture target.** docs/POPOUT_ARCHITECTURE.md calls for
   popout/hyprland.py as a separate module so the upcoming Qt
   adapter rewrite (commit 14) can call the helpers through a clean
   import surface — no FullscreenPreview self-reference required.

2. **Single source of Hyprland IPC.** Both the legacy window.py
   methods and (soon) the adapter's effect handler can call the same
   functions. The state machine refactor's FitWindowToContent effect
   resolves to a hyprland.resize_and_move call without going through
   the legacy class methods.

The shims live in window.py for one commit only — commit 14's
adapter rewrite drops them in favor of direct calls to
popout.hyprland.* from the effect application path.

Files changed:
  - NEW: booru_viewer/gui/popout/hyprland.py (~180 lines)
  - MOD: booru_viewer/gui/popout/window.py (~120 lines removed,
    ~20 lines of shims added)

Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green.

Smoke test:
- FullscreenPreview class still imports cleanly
- All three _hyprctl_* shim methods present
- Shim source code references hyprland module
- App expected to launch without changes (popout open / fit / close
  all go through the shims, which delegate to the module functions
  with the same byte-for-byte semantics as the legacy methods)

Test cases for commit 14 (window.py adapter rewrite):
  - Replace eventFilter imperative branches with dispatch calls
  - Apply effects from dispatch returns to widgets
  - Manual 11-scenario sweep
2026-04-08 19:44:00 -05:00

179 lines
5.9 KiB
Python

"""Hyprland IPC helpers for the popout window.
Module-level functions that wrap `hyprctl` for window state queries
and dispatches. Extracted from `popout/window.py` so the popout's Qt
adapter can call them through a clean import surface and so the state
machine refactor's `FitWindowToContent` effect handler has a single
place to find them.
This module DOES touch `subprocess` and `os.environ`, so it's gated
behind the same `HYPRLAND_INSTANCE_SIGNATURE` env var check the
legacy code used. Off-Hyprland systems no-op or return None at every
entry point.
The legacy `FullscreenPreview._hyprctl_*` methods become 1-line
shims that call into this module — see commit 13's changes to
`popout/window.py`. The shims preserve byte-for-byte call-site
compatibility for the existing window.py code; commit 14's adapter
rewrite drops them in favor of direct calls.
"""
from __future__ import annotations
import json
import os
import subprocess
from ...core.config import hypr_rules_enabled, popout_aspect_lock_enabled
def _on_hyprland() -> bool:
"""True if running under Hyprland (env signature present)."""
return bool(os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"))
def get_window(window_title: str) -> dict | None:
"""Return the Hyprland window dict whose `title` matches.
Returns None if not on Hyprland, if `hyprctl clients -j` fails,
or if no client matches the title. The legacy `_hyprctl_get_window`
on `FullscreenPreview` is a 1-line shim around this.
"""
if not _on_hyprland():
return None
try:
result = subprocess.run(
["hyprctl", "clients", "-j"],
capture_output=True, text=True, timeout=1,
)
for c in json.loads(result.stdout):
if c.get("title") == window_title:
return c
except Exception:
pass
return None
def resize(window_title: str, w: int, h: int) -> None:
"""Ask Hyprland to resize the popout and lock its aspect ratio.
No-op on non-Hyprland systems. Tiled windows skip the resize
(fights the layout) but still get the aspect-lock setprop if
that's enabled.
Behavior is gated by two independent env vars (see core/config.py):
- BOORU_VIEWER_NO_HYPR_RULES: skip resize and no_anim parts
- BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK: skip the keep_aspect_ratio
setprop
Either, both, or neither may be set. The aspect-ratio carve-out
means a ricer can opt out of in-code window management while
still keeping mpv playback at the right shape (or vice versa).
"""
if not _on_hyprland():
return
rules_on = hypr_rules_enabled()
aspect_on = popout_aspect_lock_enabled()
if not rules_on and not aspect_on:
return # nothing to dispatch
win = get_window(window_title)
if not win:
return
addr = win.get("address")
if not addr:
return
cmds: list[str] = []
if not win.get("floating"):
# Tiled — don't resize (fights the layout). Optionally set
# aspect lock and no_anim depending on the env vars.
if rules_on:
cmds.append(f"dispatch setprop address:{addr} no_anim 1")
if aspect_on:
cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 1")
else:
if rules_on:
cmds.append(f"dispatch setprop address:{addr} no_anim 1")
if aspect_on:
cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 0")
if rules_on:
cmds.append(f"dispatch resizewindowpixel exact {w} {h},address:{addr}")
if aspect_on:
cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 1")
if not cmds:
return
_dispatch_batch(cmds)
def resize_and_move(
window_title: str,
w: int,
h: int,
x: int,
y: int,
win: dict | None = None,
) -> None:
"""Atomically resize and move the popout via a single hyprctl batch.
Gated by BOORU_VIEWER_NO_HYPR_RULES (resize/move/no_anim parts)
and BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK (the keep_aspect_ratio
parts).
`win` may be passed in by the caller to skip the `get_window`
subprocess call. The address is the only thing we actually need
from it; threading it through cuts the per-fit subprocess count
from three to one and removes ~6ms of GUI-thread blocking every
time the popout fits to new content. The legacy
`_hyprctl_resize_and_move` on `FullscreenPreview` already used
this optimization; the module-level function preserves it.
"""
if not _on_hyprland():
return
rules_on = hypr_rules_enabled()
aspect_on = popout_aspect_lock_enabled()
if not rules_on and not aspect_on:
return
if win is None:
win = get_window(window_title)
if not win or not win.get("floating"):
return
addr = win.get("address")
if not addr:
return
cmds: list[str] = []
if rules_on:
cmds.append(f"dispatch setprop address:{addr} no_anim 1")
if aspect_on:
cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 0")
if rules_on:
cmds.append(f"dispatch resizewindowpixel exact {w} {h},address:{addr}")
cmds.append(f"dispatch movewindowpixel exact {x} {y},address:{addr}")
if aspect_on:
cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 1")
if not cmds:
return
_dispatch_batch(cmds)
def _dispatch_batch(cmds: list[str]) -> None:
"""Fire-and-forget hyprctl --batch with the given commands.
Uses `subprocess.Popen` (not `run`) so the call returns
immediately without waiting for hyprctl. The current popout code
relied on this same fire-and-forget pattern to avoid GUI-thread
blocking on every fit dispatch.
"""
try:
subprocess.Popen(
["hyprctl", "--batch", " ; ".join(cmds)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
pass
__all__ = [
"get_window",
"resize",
"resize_and_move",
]