refactor: extract WindowStateController from main_window.py
Move 6 geometry/splitter persistence methods into gui/window_state.py: _save_main_window_state, _restore_main_window_state, _hyprctl_apply_main_state, _hyprctl_main_window, _save_main_splitter_sizes, _save_right_splitter_sizes. Extract pure functions for Phase 2 tests: parse_geometry, format_geometry, build_hyprctl_restore_cmds, parse_splitter_sizes. Controller uses app-reference pattern (self._app). No behavior change. main_window.py: 3318 -> 3111 lines. behavior change: none
This commit is contained in:
parent
3f7981a8c6
commit
321ba8edfa
@ -58,6 +58,7 @@ from .search_state import SearchState
|
||||
from .log_handler import LogHandler
|
||||
from .async_signals import AsyncSignals
|
||||
from .info_panel import InfoPanel
|
||||
from .window_state import WindowStateController
|
||||
|
||||
log = logging.getLogger("booru")
|
||||
|
||||
@ -128,14 +129,15 @@ class BooruApp(QMainWindow):
|
||||
# Debounced save for the main window state — fires from resizeEvent
|
||||
# (and from the splitter timer's flush on close). Uses the same
|
||||
# 300ms debounce pattern as the splitter saver.
|
||||
self._window_state = WindowStateController(self)
|
||||
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)
|
||||
self._main_window_save_timer.timeout.connect(self._window_state.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._window_state.restore_main_window_state)
|
||||
|
||||
def _setup_signals(self) -> None:
|
||||
Q = Qt.ConnectionType.QueuedConnection
|
||||
@ -422,7 +424,7 @@ class BooruApp(QMainWindow):
|
||||
self._right_splitter_save_timer = QTimer(self)
|
||||
self._right_splitter_save_timer.setSingleShot(True)
|
||||
self._right_splitter_save_timer.setInterval(300)
|
||||
self._right_splitter_save_timer.timeout.connect(self._save_right_splitter_sizes)
|
||||
self._right_splitter_save_timer.timeout.connect(self._window_state.save_right_splitter_sizes)
|
||||
right.splitterMoved.connect(
|
||||
lambda *_: self._right_splitter_save_timer.start()
|
||||
)
|
||||
@ -450,7 +452,7 @@ class BooruApp(QMainWindow):
|
||||
self._main_splitter_save_timer = QTimer(self)
|
||||
self._main_splitter_save_timer.setSingleShot(True)
|
||||
self._main_splitter_save_timer.setInterval(300)
|
||||
self._main_splitter_save_timer.timeout.connect(self._save_main_splitter_sizes)
|
||||
self._main_splitter_save_timer.timeout.connect(self._window_state.save_main_splitter_sizes)
|
||||
self._splitter.splitterMoved.connect(
|
||||
lambda *_: self._main_splitter_save_timer.start()
|
||||
)
|
||||
@ -1674,215 +1676,6 @@ class BooruApp(QMainWindow):
|
||||
|
||||
self._run_async(_dl)
|
||||
|
||||
def _save_main_splitter_sizes(self) -> None:
|
||||
"""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()
|
||||
if len(sizes) >= 2 and all(s > 0 for s in sizes):
|
||||
self._db.set_setting(
|
||||
"main_splitter_sizes", ",".join(str(s) for s in sizes)
|
||||
)
|
||||
|
||||
def _save_right_splitter_sizes(self) -> None:
|
||||
"""Persist the right splitter sizes (preview / dl_progress / info).
|
||||
|
||||
Skipped while the popout is open — the popout temporarily collapses
|
||||
the preview pane and gives the info panel the full right column,
|
||||
and we don't want that transient layout persisted as the user's
|
||||
preferred state.
|
||||
"""
|
||||
if getattr(self, '_popout_active', False):
|
||||
return
|
||||
sizes = self._right_splitter.sizes()
|
||||
if len(sizes) == 3 and sum(sizes) > 0:
|
||||
self._db.set_setting(
|
||||
"right_splitter_sizes", ",".join(str(s) for s in sizes)
|
||||
)
|
||||
|
||||
def _hyprctl_main_window(self) -> dict | None:
|
||||
"""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
|
||||
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):
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _save_main_window_state(self) -> None:
|
||||
"""Persist the main window's last mode and (separately) the last
|
||||
known floating geometry.
|
||||
|
||||
Two settings keys are used:
|
||||
- main_window_was_floating ("1" / "0"): the *last* mode the window
|
||||
was in (floating or tiled). Updated on every save.
|
||||
- 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:
|
||||
win = self._hyprctl_main_window()
|
||||
if win is None:
|
||||
# Non-Hyprland fallback: just track Qt's frameGeometry as
|
||||
# floating. There's no real tiled concept off-Hyprland.
|
||||
g = self.frameGeometry()
|
||||
self._db.set_setting(
|
||||
"main_window_floating_geometry",
|
||||
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:
|
||||
pass
|
||||
|
||||
def _restore_main_window_state(self) -> None:
|
||||
"""One-shot restore of saved floating geometry and last mode.
|
||||
|
||||
Called from __init__ via QTimer.singleShot(0, ...) so it fires on the
|
||||
next event-loop iteration — by which time the window has been shown
|
||||
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.
|
||||
"""
|
||||
from ..core.config import hypr_rules_enabled
|
||||
if not hypr_rules_enabled():
|
||||
return
|
||||
# Migration: clear obsolete keys from earlier schemas so they can't
|
||||
# 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
|
||||
parts = floating_geo.split(",")
|
||||
if len(parts) != 4:
|
||||
return
|
||||
try:
|
||||
x, y, w, h = (int(p) for p in parts)
|
||||
except ValueError:
|
||||
return
|
||||
# Seed Qt with the floating geometry — even if we're going to leave
|
||||
# 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)
|
||||
import os
|
||||
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
|
||||
return
|
||||
# Slight delay so the window is registered before we try to find
|
||||
# its address. The popout uses the same pattern.
|
||||
QTimer.singleShot(
|
||||
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:
|
||||
"""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
|
||||
from ..core.config import hypr_rules_enabled
|
||||
if not hypr_rules_enabled():
|
||||
return
|
||||
win = self._hyprctl_main_window()
|
||||
if not win:
|
||||
return
|
||||
addr = win.get("address")
|
||||
if not addr:
|
||||
return
|
||||
cur_floating = bool(win.get("floating"))
|
||||
cmds: list[str] = []
|
||||
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 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:
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["hyprctl", "--batch", " ; ".join(cmds)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def _open_preview_in_default(self) -> None:
|
||||
# The preview is shared across tabs but its right-click menu used
|
||||
# to read browse-tab grid/posts unconditionally and then fell back
|
||||
@ -3285,9 +3078,9 @@ class BooruApp(QMainWindow):
|
||||
self._main_window_save_timer.stop()
|
||||
if hasattr(self, '_right_splitter_save_timer') and self._right_splitter_save_timer.isActive():
|
||||
self._right_splitter_save_timer.stop()
|
||||
self._save_main_splitter_sizes()
|
||||
self._save_right_splitter_sizes()
|
||||
self._save_main_window_state()
|
||||
self._window_state.save_main_splitter_sizes()
|
||||
self._window_state.save_right_splitter_sizes()
|
||||
self._window_state.save_main_window_state()
|
||||
|
||||
# Cleanly shut the shared httpx pools down BEFORE stopping the loop
|
||||
# so the connection pool / keepalive sockets / TLS state get released
|
||||
|
||||
293
booru_viewer/gui/window_state.py
Normal file
293
booru_viewer/gui/window_state.py
Normal file
@ -0,0 +1,293 @@
|
||||
"""Main-window geometry and splitter persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .main_window import BooruApp
|
||||
|
||||
log = logging.getLogger("booru")
|
||||
|
||||
|
||||
# -- Pure functions (tested in tests/gui/test_window_state.py) --
|
||||
|
||||
|
||||
def parse_geometry(s: str) -> tuple[int, int, int, int] | None:
|
||||
"""Parse ``"x,y,w,h"`` into a 4-tuple of ints, or *None* on bad input."""
|
||||
if not s:
|
||||
return None
|
||||
parts = s.split(",")
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
try:
|
||||
vals = tuple(int(p) for p in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
return vals # type: ignore[return-value]
|
||||
|
||||
|
||||
def format_geometry(x: int, y: int, w: int, h: int) -> str:
|
||||
"""Format geometry ints into the ``"x,y,w,h"`` DB string."""
|
||||
return f"{x},{y},{w},{h}"
|
||||
|
||||
|
||||
def parse_splitter_sizes(s: str, expected: int) -> list[int] | None:
|
||||
"""Parse ``"a,b,..."`` into a list of *expected* non-negative ints.
|
||||
|
||||
Returns *None* when the string is empty, has the wrong count, contains
|
||||
non-numeric values, any value is negative, or every value is zero (an
|
||||
all-zero splitter is a transient state that should not be persisted).
|
||||
"""
|
||||
if not s:
|
||||
return None
|
||||
parts = s.split(",")
|
||||
if len(parts) != expected:
|
||||
return None
|
||||
try:
|
||||
sizes = [int(p) for p in parts]
|
||||
except ValueError:
|
||||
return None
|
||||
if any(v < 0 for v in sizes):
|
||||
return None
|
||||
if all(v == 0 for v in sizes):
|
||||
return None
|
||||
return sizes
|
||||
|
||||
|
||||
def build_hyprctl_restore_cmds(
|
||||
addr: str,
|
||||
x: int,
|
||||
y: int,
|
||||
w: int,
|
||||
h: int,
|
||||
want_floating: bool,
|
||||
cur_floating: bool,
|
||||
) -> list[str]:
|
||||
"""Build the ``hyprctl --batch`` command list to restore window state.
|
||||
|
||||
When *want_floating* is True, ensures the window is floating then
|
||||
resizes/moves. When False, primes Hyprland's per-window floating cache
|
||||
by briefly toggling to floating (wrapped in ``no_anim``), then ends on
|
||||
tiled so a later mid-session float-toggle picks up the saved dimensions.
|
||||
"""
|
||||
cmds: list[str] = []
|
||||
if want_floating:
|
||||
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}")
|
||||
else:
|
||||
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")
|
||||
return cmds
|
||||
|
||||
|
||||
# -- Controller --
|
||||
|
||||
|
||||
class WindowStateController:
|
||||
"""Owns main-window geometry persistence and Hyprland IPC."""
|
||||
|
||||
def __init__(self, app: BooruApp) -> None:
|
||||
self._app = app
|
||||
|
||||
# -- Splitter persistence --
|
||||
|
||||
def save_main_splitter_sizes(self) -> None:
|
||||
"""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._app._splitter.sizes()
|
||||
if len(sizes) >= 2 and all(s > 0 for s in sizes):
|
||||
self._app._db.set_setting(
|
||||
"main_splitter_sizes", ",".join(str(s) for s in sizes)
|
||||
)
|
||||
|
||||
def save_right_splitter_sizes(self) -> None:
|
||||
"""Persist the right splitter sizes (preview / dl_progress / info).
|
||||
|
||||
Skipped while the popout is open -- the popout temporarily collapses
|
||||
the preview pane and gives the info panel the full right column,
|
||||
and we don't want that transient layout persisted as the user's
|
||||
preferred state.
|
||||
"""
|
||||
if getattr(self._app, '_popout_active', False):
|
||||
return
|
||||
sizes = self._app._right_splitter.sizes()
|
||||
if len(sizes) == 3 and sum(sizes) > 0:
|
||||
self._app._db.set_setting(
|
||||
"right_splitter_sizes", ",".join(str(s) for s in sizes)
|
||||
)
|
||||
|
||||
# -- Hyprland IPC --
|
||||
|
||||
def hyprctl_main_window(self) -> dict | None:
|
||||
"""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.
|
||||
"""
|
||||
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):
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# -- Window state save / restore --
|
||||
|
||||
def save_main_window_state(self) -> None:
|
||||
"""Persist the main window's last mode and (separately) the last
|
||||
known floating geometry.
|
||||
|
||||
Two settings keys are used:
|
||||
- main_window_was_floating ("1" / "0"): the *last* mode the window
|
||||
was in (floating or tiled). Updated on every save.
|
||||
- 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:
|
||||
win = self.hyprctl_main_window()
|
||||
if win is None:
|
||||
# Non-Hyprland fallback: just track Qt's frameGeometry as
|
||||
# floating. There's no real tiled concept off-Hyprland.
|
||||
g = self._app.frameGeometry()
|
||||
self._app._db.set_setting(
|
||||
"main_window_floating_geometry",
|
||||
format_geometry(g.x(), g.y(), g.width(), g.height()),
|
||||
)
|
||||
self._app._db.set_setting("main_window_was_floating", "1")
|
||||
return
|
||||
floating = bool(win.get("floating"))
|
||||
self._app._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._app._db.set_setting(
|
||||
"main_window_floating_geometry", format_geometry(x, y, w, h)
|
||||
)
|
||||
# When tiled, intentionally do NOT touch floating_geometry --
|
||||
# preserve the last good floating dimensions.
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def restore_main_window_state(self) -> None:
|
||||
"""One-shot restore of saved floating geometry and last mode.
|
||||
|
||||
Called from __init__ via QTimer.singleShot(0, ...) so it fires on the
|
||||
next event-loop iteration -- by which time the window has been shown
|
||||
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.
|
||||
"""
|
||||
from ..core.config import hypr_rules_enabled
|
||||
if not hypr_rules_enabled():
|
||||
return
|
||||
# Migration: clear obsolete keys from earlier schemas so they can't
|
||||
# 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._app._db.get_setting(stale):
|
||||
self._app._db.set_setting(stale, "")
|
||||
|
||||
floating_geo = self._app._db.get_setting("main_window_floating_geometry")
|
||||
was_floating = self._app._db.get_setting_bool("main_window_was_floating")
|
||||
if not floating_geo:
|
||||
return
|
||||
geo = parse_geometry(floating_geo)
|
||||
if geo is None:
|
||||
return
|
||||
x, y, w, h = geo
|
||||
# Seed Qt with the floating geometry -- even if we're going to leave
|
||||
# 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._app.setGeometry(x, y, w, h)
|
||||
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
|
||||
return
|
||||
# Slight delay so the window is registered before we try to find
|
||||
# its address. The popout uses the same pattern.
|
||||
QTimer.singleShot(
|
||||
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:
|
||||
"""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.
|
||||
|
||||
Skipped entirely when BOORU_VIEWER_NO_HYPR_RULES is set.
|
||||
"""
|
||||
from ..core.config import hypr_rules_enabled
|
||||
if not hypr_rules_enabled():
|
||||
return
|
||||
win = self.hyprctl_main_window()
|
||||
if not win:
|
||||
return
|
||||
addr = win.get("address")
|
||||
if not addr:
|
||||
return
|
||||
cur_floating = bool(win.get("floating"))
|
||||
cmds = build_hyprctl_restore_cmds(addr, x, y, w, h, floating, cur_floating)
|
||||
if not cmds:
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["hyprctl", "--batch", " ; ".join(cmds)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
Loading…
x
Reference in New Issue
Block a user