Add BOORU_VIEWER_NO_HYPR_RULES + BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK env vars for ricers with their own windowrules

This commit is contained in:
pax 2026-04-07 12:27:22 -05:00
parent 33293dfbae
commit 72150fc98b
4 changed files with 283 additions and 70 deletions

View File

@ -142,6 +142,50 @@ Type=Application
Categories=Graphics; Categories=Graphics;
``` ```
### Hyprland integration
I daily-drive booru-viewer on Hyprland and I've baked in my own opinions on
how the app should behave there. By default, a handful of `hyprctl` dispatches
run at runtime to:
- Restore the main window's last floating mode + dimensions on launch
- Restore the popout's position, center-pin it around its content during
navigation, and suppress F11 / fullscreen-transition flicker
- "Prime" Hyprland's per-window floating cache at startup so a mid-session
toggle to floating uses your saved dimensions
- Lock the popout's aspect ratio to its content so you can't accidentally
stretch mpv playback by dragging the popout corner
If you're a ricer with your own `windowrule`s targeting `class:^(booru-viewer)$`
and you'd rather the app keep its hands off your setup, there are two
independent opt-out env vars:
- **`BOORU_VIEWER_NO_HYPR_RULES=1`** — disables every in-code hyprctl dispatch
*except* the popout's `keep_aspect_ratio` lock. Use this if you want app-side
window management out of the way but you still want the popout to size itself
to its content.
- **`BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK=1`** — independently disables the popout's
aspect ratio enforcement. Useful if you want to drag the popout to whatever
shape you like (square, panoramic, monitor-aspect, whatever) and accept that
mpv playback will letterbox or stretch to match.
For the full hands-off experience, set both:
```ini
[Desktop Entry]
Name=booru-viewer
Exec=env BOORU_VIEWER_NO_HYPR_RULES=1 BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK=1 /path/to/booru-viewer/.venv/bin/booru-viewer
Icon=/path/to/booru-viewer/icon.png
Type=Application
Categories=Graphics;
```
Or for one-off launches from a shell:
```bash
BOORU_VIEWER_NO_HYPR_RULES=1 booru-viewer
```
### Dependencies ### Dependencies
- Python 3.11+ - Python 3.11+

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
import platform import platform
import sys import sys
from pathlib import Path from pathlib import Path
@ -10,6 +11,33 @@ APPNAME = "booru-viewer"
IS_WINDOWS = sys.platform == "win32" IS_WINDOWS = sys.platform == "win32"
def hypr_rules_enabled() -> bool:
"""Whether the in-code hyprctl dispatches that change window state
should run.
Returns False when BOORU_VIEWER_NO_HYPR_RULES is set in the environment.
Callers should skip any hyprctl `dispatch` that would mutate window
state (resize, move, togglefloating, setprop no_anim, the floating
"prime" sequence). Read-only queries (`hyprctl clients -j`) are still
fine only mutations are blocked.
The popout's keep_aspect_ratio enforcement is gated by the separate
popout_aspect_lock_enabled() it's a different concern.
"""
return not os.environ.get("BOORU_VIEWER_NO_HYPR_RULES")
def popout_aspect_lock_enabled() -> bool:
"""Whether the popout's keep_aspect_ratio setprop should run.
Returns False when BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK is set in the
environment. Independent of hypr_rules_enabled() so a ricer can free
up the popout's shape (e.g. for fixed-square or panoramic popouts)
while keeping the rest of the in-code hyprctl behavior, or vice versa.
"""
return not os.environ.get("BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK")
def data_dir() -> Path: def data_dir() -> Path:
"""Return the platform-appropriate data/cache directory.""" """Return the platform-appropriate data/cache directory."""
if IS_WINDOWS: if IS_WINDOWS:

View File

@ -278,9 +278,16 @@ class BooruApp(QMainWindow):
self._setup_ui() self._setup_ui()
self._setup_menu() self._setup_menu()
self._load_sites() self._load_sites()
# Restore window state (geometry, floating, maximized) on the next # Debounced save for the main window state — fires from resizeEvent
# event-loop iteration — by then main.py has called show() and the # (and from the splitter timer's flush on close). Uses the same
# window has been registered with the compositor. # 300ms debounce pattern as the splitter saver.
self._main_window_save_timer = QTimer(self)
self._main_window_save_timer.setSingleShot(True)
self._main_window_save_timer.setInterval(300)
self._main_window_save_timer.timeout.connect(self._save_main_window_state)
# Restore window state (geometry, floating) on the next event-loop
# iteration — by then main.py has called show() and the window has
# been registered with the compositor.
QTimer.singleShot(0, self._restore_main_window_state) QTimer.singleShot(0, self._restore_main_window_state)
def _setup_signals(self) -> None: def _setup_signals(self) -> None:
@ -1487,15 +1494,28 @@ class BooruApp(QMainWindow):
self._run_async(_dl) self._run_async(_dl)
def _save_main_splitter_sizes(self) -> None: def _save_main_splitter_sizes(self) -> None:
"""Persist the main grid/preview splitter sizes (debounced).""" """Persist the main grid/preview splitter sizes (debounced).
Refuses to save when either side is collapsed (size 0). The user can
end up with a collapsed right panel transiently e.g. while the
popout is open and the right panel is empty and persisting that
state traps them next launch with no visible preview area until they
manually drag the splitter back.
"""
sizes = self._splitter.sizes() sizes = self._splitter.sizes()
if sum(sizes) > 0: if len(sizes) >= 2 and all(s > 0 for s in sizes):
self._db.set_setting( self._db.set_setting(
"main_splitter_sizes", ",".join(str(s) for s in sizes) "main_splitter_sizes", ",".join(str(s) for s in sizes)
) )
def _hyprctl_main_window(self) -> dict | None: def _hyprctl_main_window(self) -> dict | None:
"""Look up this main window in hyprctl clients. None off Hyprland.""" """Look up this main window in hyprctl clients. None off Hyprland.
Matches by Wayland app_id (Hyprland reports it as `class`), which is
set in run() via setDesktopFileName. Title would also work but it
changes whenever the search bar updates the window title class is
constant for the lifetime of the window.
"""
import os, subprocess, json import os, subprocess, json
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
return None return None
@ -1505,67 +1525,100 @@ class BooruApp(QMainWindow):
capture_output=True, text=True, timeout=1, capture_output=True, text=True, timeout=1,
) )
for c in json.loads(result.stdout): for c in json.loads(result.stdout):
if c.get("title") == self.windowTitle(): cls = c.get("class") or c.get("initialClass")
if cls == "booru-viewer":
# Skip the popout — it shares our class but has a
# distinct title we set explicitly.
if (c.get("title") or "").endswith("Popout"):
continue
return c return c
except Exception: except Exception:
pass pass
return None return None
def _save_main_window_state(self) -> None: def _save_main_window_state(self) -> None:
"""Persist the main window's size, position, floating, maximized state. """Persist the main window's last mode and (separately) the last
known floating geometry.
On Hyprland, queries hyprctl for the real geometry (Qt's frameGeometry Two settings keys are used:
is unreliable for floating windows on Wayland). Maximized/fullscreen is - main_window_was_floating ("1" / "0"): the *last* mode the window
recorded separately so the next launch can re-maximize without was in (floating or tiled). Updated on every save.
clobbering the underlying windowed geometry. - main_window_floating_geometry ("x,y,w,h"): the position+size the
window had the *last time it was actually floating*. Only updated
when the current state is floating, so a tileclosereopenfloat
sequence still has the user's old floating dimensions to use.
This split is important because Hyprland's resizeEvent for a tiled
window reports the tile slot size saving that into the floating
slot would clobber the user's chosen floating dimensions every time
they tiled the window.
""" """
try: try:
self._db.set_setting(
"main_window_maximized",
"1" if self.isMaximized() else "0",
)
if self.isMaximized():
return # don't overwrite the windowed-mode geometry
win = self._hyprctl_main_window() win = self._hyprctl_main_window()
if win and win.get("at") and win.get("size"): if win is None:
x, y = win["at"] # Non-Hyprland fallback: just track Qt's frameGeometry as
w, h = win["size"] # floating. There's no real tiled concept off-Hyprland.
floating = 1 if win.get("floating") else 0
self._db.set_setting(
"main_window_geometry",
f"{x},{y},{w},{h},{floating}",
)
return
g = self.frameGeometry() g = self.frameGeometry()
self._db.set_setting( self._db.set_setting(
"main_window_geometry", "main_window_floating_geometry",
f"{g.x()},{g.y()},{g.width()},{g.height()},0", f"{g.x()},{g.y()},{g.width()},{g.height()}",
) )
self._db.set_setting("main_window_was_floating", "1")
return
floating = bool(win.get("floating"))
self._db.set_setting(
"main_window_was_floating", "1" if floating else "0"
)
if floating and win.get("at") and win.get("size"):
x, y = win["at"]
w, h = win["size"]
self._db.set_setting(
"main_window_floating_geometry", f"{x},{y},{w},{h}"
)
# When tiled, intentionally do NOT touch floating_geometry —
# preserve the last good floating dimensions.
except Exception: except Exception:
pass pass
def _restore_main_window_state(self) -> None: def _restore_main_window_state(self) -> None:
"""One-shot restore of saved geometry, floating and maximized state. """One-shot restore of saved floating geometry and last mode.
Called from __init__ via QTimer.singleShot(0, ...) so it fires on the Called from __init__ via QTimer.singleShot(0, ...) so it fires on the
next event-loop iteration by which time the window has been shown next event-loop iteration by which time the window has been shown
and (on Hyprland) registered with the compositor. and (on Hyprland) registered with the compositor.
Entirely skipped when BOORU_VIEWER_NO_HYPR_RULES is set that flag
means the user wants their own windowrules to handle the main
window. Even seeding Qt's geometry could fight a `windowrule = size`,
so we leave the initial Qt geometry alone too.
""" """
if self._db.get_setting_bool("main_window_maximized"): from ..core.config import hypr_rules_enabled
self.showMaximized() if not hypr_rules_enabled():
return return
saved = self._db.get_setting("main_window_geometry") # Migration: clear obsolete keys from earlier schemas so they can't
if not saved: # interfere. main_window_maximized came from a buggy version that
# used Qt's isMaximized() which lies for Hyprland tiled windows.
# main_window_geometry was the combined-format key that's now split.
for stale in ("main_window_maximized", "main_window_geometry"):
if self._db.get_setting(stale):
self._db.set_setting(stale, "")
floating_geo = self._db.get_setting("main_window_floating_geometry")
was_floating = self._db.get_setting_bool("main_window_was_floating")
if not floating_geo:
return return
parts = saved.split(",") parts = floating_geo.split(",")
if len(parts) != 5: if len(parts) != 4:
return return
try: try:
x, y, w, h, floating = (int(p) for p in parts) x, y, w, h = (int(p) for p in parts)
except ValueError: except ValueError:
return return
# Seed Qt with the size; Hyprland may ignore the position for the # Seed Qt with the floating geometry — even if we're going to leave
# initial map, but we follow up with a hyprctl batch below. # the window tiled now, this becomes the xdg-toplevel preferred size,
# which Hyprland uses when the user later toggles to floating. So
# mid-session float-toggle picks up the saved dimensions even when
# the window opened tiled.
self.setGeometry(x, y, w, h) self.setGeometry(x, y, w, h)
import os import os
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
@ -1573,24 +1626,56 @@ class BooruApp(QMainWindow):
# Slight delay so the window is registered before we try to find # Slight delay so the window is registered before we try to find
# its address. The popout uses the same pattern. # its address. The popout uses the same pattern.
QTimer.singleShot( QTimer.singleShot(
50, lambda: self._hyprctl_apply_main_state(x, y, w, h, bool(floating)) 50, lambda: self._hyprctl_apply_main_state(x, y, w, h, was_floating)
) )
def _hyprctl_apply_main_state(self, x: int, y: int, w: int, h: int, floating: bool) -> None: def _hyprctl_apply_main_state(self, x: int, y: int, w: int, h: int, floating: bool) -> None:
"""Apply saved floating/position/size to the main window via hyprctl.""" """Apply saved floating mode + geometry to the main window via hyprctl.
If floating==True, ensures the window is floating and resizes/moves it
to the saved dimensions.
If floating==False, the window is left tiled but we still "prime"
Hyprland's per-window floating cache by briefly toggling to floating,
applying the saved geometry, and toggling back. This is wrapped in
a transient `no_anim` so the toggles are instant. Without this prime,
a later mid-session togglefloating uses Hyprland's default size
(Qt's xdg-toplevel preferred size doesn't carry through). With it,
the user's saved floating dimensions are used.
Skipped entirely when BOORU_VIEWER_NO_HYPR_RULES is set that flag
means the user wants their own windowrules to govern the main
window and the app should keep its hands off.
"""
import subprocess import subprocess
from ..core.config import hypr_rules_enabled
if not hypr_rules_enabled():
return
win = self._hyprctl_main_window() win = self._hyprctl_main_window()
if not win: if not win:
return return
addr = win.get("address") addr = win.get("address")
if not addr: if not addr:
return return
cmds = [] cur_floating = bool(win.get("floating"))
if bool(win.get("floating")) != floating: cmds: list[str] = []
cmds.append(f"dispatch togglefloating address:{addr}")
if floating: if floating:
# Want floating: ensure floating, then size/move.
if not cur_floating:
cmds.append(f"dispatch togglefloating address:{addr}")
cmds.append(f"dispatch resizewindowpixel exact {w} {h},address:{addr}") cmds.append(f"dispatch resizewindowpixel exact {w} {h},address:{addr}")
cmds.append(f"dispatch movewindowpixel exact {x} {y},address:{addr}") cmds.append(f"dispatch movewindowpixel exact {x} {y},address:{addr}")
else:
# Want tiled: prime the floating cache, then end on tiled. Use
# transient no_anim so the toggles don't visibly flash through
# a floating frame.
cmds.append(f"dispatch setprop address:{addr} no_anim 1")
if not cur_floating:
cmds.append(f"dispatch togglefloating address:{addr}")
cmds.append(f"dispatch resizewindowpixel exact {w} {h},address:{addr}")
cmds.append(f"dispatch movewindowpixel exact {x} {y},address:{addr}")
cmds.append(f"dispatch togglefloating address:{addr}")
cmds.append(f"dispatch setprop address:{addr} no_anim 0")
if not cmds: if not cmds:
return return
try: try:
@ -2550,6 +2635,19 @@ class BooruApp(QMainWindow):
super().resizeEvent(event) super().resizeEvent(event)
if hasattr(self, '_privacy_overlay') and self._privacy_on: if hasattr(self, '_privacy_overlay') and self._privacy_on:
self._privacy_overlay.setGeometry(self.rect()) self._privacy_overlay.setGeometry(self.rect())
# Capture window state proactively so the saved value is always
# fresh — closeEvent's hyprctl query can fail if the compositor has
# already started unmapping. Debounced via the 300ms timer.
if hasattr(self, '_main_window_save_timer'):
self._main_window_save_timer.start()
def moveEvent(self, event) -> None:
super().moveEvent(event)
# moveEvent is unreliable on Wayland for floating windows but it
# does fire on configure for some compositors — start the save
# timer regardless. resizeEvent is the more reliable trigger.
if hasattr(self, '_main_window_save_timer'):
self._main_window_save_timer.start()
# -- Keyboard shortcuts -- # -- Keyboard shortcuts --
@ -2703,11 +2801,14 @@ class BooruApp(QMainWindow):
self._library_view.refresh() self._library_view.refresh()
def closeEvent(self, event) -> None: def closeEvent(self, event) -> None:
# Flush any pending splitter save (debounce timer might still be # Flush any pending splitter / window-state saves (debounce timers
# running if the user dragged it within the last 300ms) and capture # may still be running if the user moved/resized within the last
# the final main window state. Both must run BEFORE _db.close(). # 300ms) and capture the final state. Both must run BEFORE
# _db.close().
if self._main_splitter_save_timer.isActive(): if self._main_splitter_save_timer.isActive():
self._main_splitter_save_timer.stop() self._main_splitter_save_timer.stop()
if self._main_window_save_timer.isActive():
self._main_window_save_timer.stop()
self._save_main_splitter_sizes() self._save_main_splitter_sizes()
self._save_main_window_state() self._save_main_window_state()
self._async_loop.call_soon_threadsafe(self._async_loop.stop) self._async_loop.call_soon_threadsafe(self._async_loop.stop)
@ -2805,6 +2906,13 @@ def run() -> None:
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Set a stable Wayland app_id so Hyprland and other compositors can
# consistently identify our windows by class (not by title, which
# changes when search terms appear in the title bar). Qt translates
# setDesktopFileName into the xdg-shell app_id on Wayland.
app.setApplicationName("booru-viewer")
app.setDesktopFileName("booru-viewer")
# mpv requires LC_NUMERIC=C — Qt resets the locale in QApplication(), # mpv requires LC_NUMERIC=C — Qt resets the locale in QApplication(),
# so we must restore it after Qt init but before creating any mpv instances. # so we must restore it after Qt init but before creating any mpv instances.
import locale import locale

View File

@ -438,60 +438,93 @@ class FullscreenPreview(QMainWindow):
return None return None
def _hyprctl_resize(self, w: int, h: int) -> None: 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.""" """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 import os, subprocess
from ..core.config import hypr_rules_enabled, popout_aspect_lock_enabled
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
return 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() win = self._hyprctl_get_window()
if not win: if not win:
return return
addr = win.get("address") addr = win.get("address")
if not addr: if not addr:
return return
cmds: list[str] = []
if not win.get("floating"): if not win.get("floating"):
# Tiled — don't resize (fights the layout), just set aspect lock # Tiled — don't resize (fights the layout). Optionally set
# and disable animations to prevent flashing on later transitions. # aspect lock and no_anim depending on the env vars.
try: if rules_on:
subprocess.Popen( cmds.append(f"dispatch setprop address:{addr} no_anim 1")
["hyprctl", "--batch", if aspect_on:
f"dispatch setprop address:{addr} no_anim 1" cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 1")
f" ; dispatch setprop address:{addr} keep_aspect_ratio 1"], else:
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, if rules_on:
) cmds.append(f"dispatch setprop address:{addr} no_anim 1")
except FileNotFoundError: if aspect_on:
pass 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 return
try: try:
subprocess.Popen( subprocess.Popen(
["hyprctl", "--batch", ["hyprctl", "--batch", " ; ".join(cmds)],
f"dispatch setprop address:{addr} no_anim 1"
f" ; dispatch setprop address:{addr} keep_aspect_ratio 0"
f" ; dispatch resizewindowpixel exact {w} {h},address:{addr}"
f" ; dispatch setprop address:{addr} keep_aspect_ratio 1"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
except FileNotFoundError: except FileNotFoundError:
pass pass
def _hyprctl_resize_and_move(self, w: int, h: int, x: int, y: int) -> None: def _hyprctl_resize_and_move(self, w: int, h: int, x: int, y: int) -> None:
"""Atomically resize and move this window via a single hyprctl batch.""" """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.
"""
import os, subprocess import os, subprocess
from ..core.config import hypr_rules_enabled, popout_aspect_lock_enabled
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
return return
rules_on = hypr_rules_enabled()
aspect_on = popout_aspect_lock_enabled()
if not rules_on and not aspect_on:
return
win = self._hyprctl_get_window() win = self._hyprctl_get_window()
if not win or not win.get("floating"): if not win or not win.get("floating"):
return return
addr = win.get("address") addr = win.get("address")
if not addr: if not addr:
return 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: try:
subprocess.Popen( subprocess.Popen(
["hyprctl", "--batch", ["hyprctl", "--batch", " ; ".join(cmds)],
f"dispatch setprop address:{addr} no_anim 1"
f" ; dispatch setprop address:{addr} keep_aspect_ratio 0"
f" ; dispatch resizewindowpixel exact {w} {h},address:{addr}"
f" ; dispatch movewindowpixel exact {x} {y},address:{addr}"
f" ; dispatch setprop address:{addr} keep_aspect_ratio 1"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
except FileNotFoundError: except FileNotFoundError: