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:
pax 2026-04-10 14:39:37 -05:00
parent 3f7981a8c6
commit 321ba8edfa
2 changed files with 302 additions and 216 deletions

View File

@ -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 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:
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

View 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