"""Main-window geometry and splitter persistence.""" from __future__ import annotations import json import logging import os import subprocess from typing import TYPE_CHECKING 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 self._app._popout_ctrl.is_active: 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: # hyprctl unavailable (non-Hyprland session), timed out, # or produced invalid JSON. Caller treats None as # "no Hyprland-visible main window" and falls back to # Qt's own geometry tracking. 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: # Geometry persistence is best-effort; swallowing here # beats crashing closeEvent over a hyprctl timeout or a # setting-write race. Next save attempt will retry. 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. from PySide6.QtCore import QTimer 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