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:
parent
06f8f3d752
commit
095942c524
178
booru_viewer/gui/popout/hyprland.py
Normal file
178
booru_viewer/gui/popout/hyprland.py
Normal 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",
|
||||
]
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user