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:
parent
33293dfbae
commit
72150fc98b
44
README.md
44
README.md
@ -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+
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 tile→close→reopen→float
|
||||||
|
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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user