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
This commit is contained in:
pax 2026-04-08 19:44:00 -05:00
parent 06f8f3d752
commit 095942c524
2 changed files with 194 additions and 111 deletions

View File

@ -0,0 +1,178 @@
"""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",
]

View File

@ -796,124 +796,29 @@ class FullscreenPreview(QMainWindow):
self._ui_visible = self._toolbar.isVisible() or self._video._controls_bar.isVisible()
return super().eventFilter(obj, event)
# Hyprland helpers — moved to popout/hyprland.py in commit 13. These
# methods are now thin shims around the module-level functions so
# the existing call sites in this file (`_fit_to_content`,
# `_enter_fullscreen`, `closeEvent`) keep working byte-for-byte.
# Commit 14's adapter rewrite drops the shims and calls the
# hyprland module directly.
def _hyprctl_get_window(self) -> dict | None:
"""Get the Hyprland window info for the popout window."""
import os, subprocess, json
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
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") == self.windowTitle():
return c
except Exception:
pass
return None
"""Shim → `popout.hyprland.get_window`."""
from . import hyprland
return hyprland.get_window(self.windowTitle())
def _hyprctl_resize(self, w: int, h: int) -> None:
"""Ask Hyprland to resize this window and lock aspect ratio. No-op on other WMs or tiled.
Behavior is gated by two independent env vars (see core/config.py):
- BOORU_VIEWER_NO_HYPR_RULES: skip the 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).
"""
import os, subprocess
from ...core.config import hypr_rules_enabled, popout_aspect_lock_enabled
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
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 = self._hyprctl_get_window()
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
try:
subprocess.Popen(
["hyprctl", "--batch", " ; ".join(cmds)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
pass
"""Shim → `popout.hyprland.resize`."""
from . import hyprland
hyprland.resize(self.windowTitle(), w, h)
def _hyprctl_resize_and_move(
self, w: int, h: int, x: int, y: int, win: dict | None = None
) -> None:
"""Atomically resize and move this window 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)
see core/config.py.
`win` may be passed in by the caller to skip the
`_hyprctl_get_window()` subprocess call. The address is the only
thing we actually need from it; cutting the per-fit subprocess
count from three to one removes ~6ms of GUI-thread blocking
every time `_fit_to_content` runs.
"""
import os, subprocess
from ...core.config import hypr_rules_enabled, popout_aspect_lock_enabled
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
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 = self._hyprctl_get_window()
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
try:
subprocess.Popen(
["hyprctl", "--batch", " ; ".join(cmds)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
pass
"""Shim → `popout.hyprland.resize_and_move`."""
from . import hyprland
hyprland.resize_and_move(self.windowTitle(), w, h, x, y, win=win)
def privacy_hide(self) -> None:
"""Cover the popout's content with a black overlay for privacy.