Popout: viewport-based fit math, fix portrait>landscape ratchet

The old _fit_to_content was width-anchored with an asymmetric height
clamp, so every portrait nav back-derived a smaller width and P>L>P
loops progressively shrunk landscape. Replaced with a viewport-keyed
compute (long_side + center), symmetric across aspect flips. The
non-Hyprland branch now uses setGeometry instead of self.resize() to
stop top-left drift.
This commit is contained in:
pax 2026-04-07 21:37:25 -05:00
parent baa910ac81
commit 5a44593a6a

View File

@ -4,8 +4,9 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import NamedTuple
from PySide6.QtCore import Qt, QPointF, Signal, QTimer, Property from PySide6.QtCore import Qt, QPointF, QRect, Signal, QTimer, Property
from PySide6.QtGui import QPixmap, QPainter, QWheelEvent, QMouseEvent, QKeyEvent, QMovie, QColor from PySide6.QtGui import QPixmap, QPainter, QWheelEvent, QMouseEvent, QKeyEvent, QMovie, QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QMainWindow,
@ -19,6 +20,24 @@ _log = logging.getLogger("booru")
VIDEO_EXTENSIONS = (".mp4", ".webm", ".mkv", ".avi", ".mov") VIDEO_EXTENSIONS = (".mp4", ".webm", ".mkv", ".avi", ".mov")
class Viewport(NamedTuple):
"""Where and how large the user wants popout content to appear.
Three numbers, no aspect. Aspect is a property of the currently-
displayed post and is recomputed from actual content on every
navigation. The viewport stays put across navigations; the window
rect is a derived projection (Viewport, content_aspect) (x,y,w,h).
`long_side` is the binding edge length: for landscape it becomes
width, for portrait it becomes height. Symmetric across the two
orientations, which is the property that breaks the
width-anchor ratchet that the previous `_fit_to_content` had.
"""
center_x: float
center_y: float
long_side: float
def _is_video(path: str) -> bool: def _is_video(path: str) -> bool:
return Path(path).suffix.lower() in VIDEO_EXTENSIONS return Path(path).suffix.lower() in VIDEO_EXTENSIONS
@ -380,8 +399,93 @@ class FullscreenPreview(QMainWindow):
return None # not Hyprland return None # not Hyprland
return bool(win.get("floating")) return bool(win.get("floating"))
@staticmethod
def _compute_window_rect(
viewport: Viewport, content_aspect: float, screen
) -> tuple[int, int, int, int]:
"""Project a viewport onto a window rect for the given content aspect.
Symmetric across portrait/landscape: a 9:16 portrait and a 16:9
landscape with the same `long_side` have the same maximum edge
length. Proportional clamp shrinks both edges by the same factor
if either would exceed its 0.90-of-screen ceiling, preserving
aspect exactly. Pure function no side effects, no widget
access, all inputs explicit so it's trivial to reason about.
"""
if content_aspect >= 1.0: # landscape or square
w = viewport.long_side
h = viewport.long_side / content_aspect
else: # portrait
h = viewport.long_side
w = viewport.long_side * content_aspect
avail = screen.availableGeometry()
cap_w = avail.width() * 0.90
cap_h = avail.height() * 0.90
scale = min(1.0, cap_w / w, cap_h / h)
w *= scale
h *= scale
x = viewport.center_x - w / 2
y = viewport.center_y - h / 2
# Nudge onto screen if the projected rect would land off-edge.
x = max(avail.x(), min(x, avail.right() - w))
y = max(avail.y(), min(y, avail.bottom() - h))
return (round(x), round(y), round(w), round(h))
def _derive_viewport_for_fit(self, floating: bool | None) -> Viewport | None:
"""Build a viewport from existing state at the start of a fit call.
This is the scoped (recompute-from-current-state) approach. The
viewport isn't a persistent field on the popout — it's recomputed
per call from one of three sources, in priority order:
1. First fit after open or F11 exit: derive from the existing
`_pending_size` + `_pending_position_restore` one-shots.
These are seeded in `__init__` from the saved DB geometry
and re-armed in `_exit_fullscreen`.
2. Navigation fit on Hyprland: derive from current
hyprctl-reported window position+size, so the viewport
always reflects whatever the user has dragged the popout to.
3. Navigation fit on non-Hyprland: derive from `self.geometry()`
for the same reason.
Returns None only if every source fails (Hyprland reports no
window AND non-Hyprland geometry is invalid), in which case the
caller should fall back to the existing pixel-space code path.
"""
if self._first_fit_pending and self._pending_size and self._pending_position_restore:
pw, ph = self._pending_size
px, py = self._pending_position_restore
return Viewport(
center_x=px + pw / 2,
center_y=py + ph / 2,
long_side=float(max(pw, ph)),
)
if floating is True:
win = self._hyprctl_get_window()
if win and win.get("at") and win.get("size"):
wx, wy = win["at"]
ww, wh = win["size"]
return Viewport(
center_x=wx + ww / 2,
center_y=wy + wh / 2,
long_side=float(max(ww, wh)),
)
if floating is None:
rect = self.geometry()
if rect.width() > 0 and rect.height() > 0:
return Viewport(
center_x=rect.x() + rect.width() / 2,
center_y=rect.y() + rect.height() / 2,
long_side=float(max(rect.width(), rect.height())),
)
return None
def _fit_to_content(self, content_w: int, content_h: int, _retry: int = 0) -> None: def _fit_to_content(self, content_w: int, content_h: int, _retry: int = 0) -> None:
"""Size window to fit content. Width preserved, height from aspect ratio, clamped to screen. """Size window to fit content. Viewport-based: long_side preserved across navs.
Distinguishes "not on Hyprland" (Qt drives geometry, no aspect Distinguishes "not on Hyprland" (Qt drives geometry, no aspect
lock available) from "on Hyprland but the window isn't visible lock available) from "on Hyprland but the window isn't visible
@ -394,6 +498,15 @@ class FullscreenPreview(QMainWindow):
right shape. Now we retry with a short backoff when on Hyprland right shape. Now we retry with a short backoff when on Hyprland
and the window isn't found, capped so a real "not Hyprland" and the window isn't found, capped so a real "not Hyprland"
signal can't loop. signal can't loop.
Math is now viewport-based: a Viewport (center + long_side) is
derived from current state, then projected onto a rect for the
new content aspect via `_compute_window_rect`. This breaks the
width-anchor ratchet that the previous version had long_side
is symmetric across portrait and landscape, so navigating
PLPL doesn't permanently shrink the landscape width.
See the plan at ~/.claude/plans/ancient-growing-lantern.md
for the full derivation.
""" """
if self.isFullScreen() or content_w <= 0 or content_h <= 0: if self.isFullScreen() or content_w <= 0 or content_h <= 0:
return return
@ -416,44 +529,29 @@ class FullscreenPreview(QMainWindow):
return return
aspect = content_w / content_h aspect = content_w / content_h
screen = self.screen() screen = self.screen()
max_h = int(screen.availableGeometry().height() * 0.90) if screen else 9999 if screen is None:
max_w = screen.availableGeometry().width() if screen else 9999 return
# Starting width: prefer the pending one-shot size when set (saves us viewport = self._derive_viewport_for_fit(floating)
# from depending on self.width() during transitional Qt states like if viewport is None:
# right after showNormal(), where Qt may briefly report fullscreen # No source for a viewport (Hyprland reported no window AND
# dimensions before Hyprland confirms the windowed geometry). # Qt geometry is invalid). Bail without dispatching — clearing
if self._first_fit_pending and self._pending_size: # the one-shots would lose the saved position; leaving them
start_w = self._pending_size[0] # set lets a subsequent fit retry.
else: return
start_w = self.width() x, y, w, h = self._compute_window_rect(viewport, aspect, screen)
w = min(start_w, max_w)
h = int(w / aspect)
if h > max_h:
h = max_h
w = int(h * aspect)
# Decide target top-left:
# first fit after open with a saved position → restore it (one-shot)
# any subsequent fit → center-pin from current Hyprland position
target: tuple[int, int] | None = None
if self._first_fit_pending and self._pending_position_restore:
target = self._pending_position_restore
elif floating is True:
win = self._hyprctl_get_window()
if win and win.get("at") and win.get("size"):
cx = win["at"][0] + win["size"][0] // 2
cy = win["at"][1] + win["size"][1] // 2
target = (cx - w // 2, cy - h // 2)
if floating is True: if floating is True:
# Hyprland: hyprctl is the sole authority. Calling self.resize() # Hyprland: hyprctl is the sole authority. Calling self.resize()
# here would race with the batch below and produce visible flashing # here would race with the batch below and produce visible flashing
# when the window also has to move. # when the window also has to move.
if target is not None: self._hyprctl_resize_and_move(w, h, x, y)
self._hyprctl_resize_and_move(w, h, target[0], target[1])
else: else:
self._hyprctl_resize(w, h) # Non-Hyprland fallback: Qt drives geometry directly. Use
else: # setGeometry with the computed top-left rather than resize()
# Non-Hyprland fallback: Qt drives geometry directly. # so the window center stays put — Qt's resize() anchors
self.resize(w, h) # top-left and lets the bottom-right move, which causes the
# popout center to drift toward the upper-left of the screen
# over repeated navigations.
self.setGeometry(QRect(x, y, w, h))
self._first_fit_pending = False self._first_fit_pending = False
self._pending_position_restore = None self._pending_position_restore = None
self._pending_size = None self._pending_size = None