Group B of the popout viewport work. The v0.2.2 viewport compute swap
fixed the big aspect-ratio failures (width-anchor ratchet, asymmetric
clamps, manual-resize destruction) but kept a minor "recompute from
current state every nav" shortcut that accumulated 1-2px of downward
drift across long navigation sessions. This commit replaces that
shortcut with a true persistent viewport that's only updated by
explicit user action, not by reading our own dispatch output back.
The viewport (center_x, center_y, long_side) is now stored as a
field on FullscreenPreview, seeded from `_pending_*` on first fit
after open or F11 exit, and otherwise preserved across navigations.
External moves/resizes are detected via a `_last_dispatched_rect`
cache: at the start of each fit, the current `hyprctl clients -j`
position is compared against the last rect we dispatched, and if
they differ by more than `_DRIFT_TOLERANCE` (2px) the user is
treated as having moved the window externally and the viewport
adopts the new state. Sub-pixel rounding stays inside the tolerance
and the viewport stays put.
`_exit_fullscreen` is simplified — no more re-arming the
`_first_fit_pending` one-shots. The persistent viewport already
holds the pre-fullscreen center+long_side (fullscreen entry/exit
runs no fits, so nothing overwrites it), and the deferred fit after
`showNormal()` reads it directly. Side benefit: this fixes the
legacy F11-walks-toward-saved-top-left bug 1f as a free byproduct.
## The moveEvent/resizeEvent gate (load-bearing — Group B v1 broke
## without it)
First implementation of Group B added moveEvent/resizeEvent handlers
to capture user drags/resizes into the persistent viewport on the
non-Hyprland Qt path. They were guarded with a `_applying_dispatch`
reentrancy flag set around the dispatch call. **This broke every
navigation, F11 round-trip, and external drag on Hyprland**, sending
the popout to the top-left corner.
Two interacting reasons:
1. On Wayland (Hyprland included), `self.geometry()` returns
`QRect(0, 0, w, h)` for top-level windows. xdg-toplevel doesn't
expose absolute screen position to clients, and Qt6's wayland
plugin reflects that by always reporting `x=0, y=0`. So the
handlers wrote viewport center = `(w/2, h/2)` — small positive
numbers far from the actual screen center.
2. The `_applying_dispatch` reentrancy guard works for the
synchronous non-Hyprland `setGeometry()` path (moveEvent fires
inside the try-block) but does NOT work for the async hyprctl
dispatch path. `subprocess.Popen` returns instantly, the
`try/finally` clears the guard, THEN Hyprland processes the
dispatch and sends a configure event back to Qt, THEN Qt fires
moveEvent — at which point the guard is already False. So the
guard couldn't suppress the bogus updates that Wayland's
geometry handling produces.
Fix: gate both moveEvent and resizeEvent's viewport-update branches
with `if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): return` at
the top. On Hyprland, the cur-vs-last-dispatched comparison in
`_derive_viewport_for_fit` is the sole external-drag detector,
which is what it was designed to be. The non-Hyprland branch stays
unchanged so X11/Windows users still get drag-and-resize tracking
via Qt events (where `self.geometry()` is reliable).
## Verification
All seven manual tests pass on the user's Hyprland session:
1. Drift fix (P↔L navigation cycles): viewport stays constant, no
walking toward any corner
2. Super+drag externally then nav: new dragged position picked up
by the cur-vs-last-dispatched comparison and preserved
3. Corner-resize externally then nav: same — comparison branch
adopts the new long_side
4. F11 same-aspect round-trip: window lands at pre-fullscreen center
5. F11 across-aspect round-trip: window lands at pre-fullscreen
center with the new aspect's shape
6. First-open from saved geometry: works (untouched first-fit path)
7. Restart persistence across app sessions: works (untouched too)
## Files
`booru_viewer/gui/preview.py` only. ~239 added, ~65 removed:
- `_DRIFT_TOLERANCE = 2` constant at module top
- `_viewport`, `_last_dispatched_rect`, `_applying_dispatch` fields
in `FullscreenPreview.__init__`
- `_build_viewport_from_current` helper (extracted from old
`_derive_viewport_for_fit`)
- `_derive_viewport_for_fit` rewritten with three branches:
first-fit seed, defensive build, persistent + drift check
- `_fit_to_content` wraps dispatch with `_applying_dispatch` guard,
caches `_last_dispatched_rect` after dispatch
- `_exit_fullscreen` simplified (no more `_first_fit_pending`
re-arm), invalidates `_last_dispatched_rect` so the post-F11 fit
doesn't false-positive on "user moved during fullscreen"
- `moveEvent` added (gated to non-Hyprland)
- `resizeEvent` extended with viewport update (gated to non-Hyprland)