popout/state: implement persistent viewport + drift events

Three event handlers, all updating state.viewport from rect data:

WindowMoved (Qt moveEvent, non-Hyprland only):
  Move-only update — preserve existing long_side, recompute center.
  Moves don't change size, so the viewport's "how big does the user
  want it" intent stays put while its "where does the user want it"
  intent updates.

WindowResized (Qt resizeEvent, non-Hyprland only):
  Full rebuild — long_side becomes new max(w, h), center becomes
  the rect center. Resizes change both intents.

HyprlandDriftDetected (adapter, fit-time hyprctl drift check):
  Full rebuild from rect. This is the ONLY path that captures
  Hyprland Super+drag — Wayland's xdg-toplevel doesn't expose
  absolute window position to clients, so Qt's moveEvent never
  fires for external compositor-driven movement. The adapter's
  _derive_viewport_for_fit equivalent will dispatch this event when
  it sees the current Hyprland rect drifting from the last
  dispatched rect by more than _DRIFT_TOLERANCE.

All three handlers gate on (not fullscreen) and (not Closing).
Drifts and moves while in fullscreen aren't meaningful for the
windowed viewport.

This makes the 7d19555 persistent viewport structural. The
viewport is a state field. It's only mutated by WindowMoved /
WindowResized / HyprlandDriftDetected (user action) — never by
FitWindowToContent reading and writing back its own dispatch.
The drift accumulation that the legacy code's "recompute from
current state" shortcut suffered cannot happen here because there's
no read-then-write path; viewport is the source of truth, not
derived from current rect.

Tests passing after this commit (62 total → 50 pass, 12 fail):

  - test_window_moved_updates_viewport_center_only
  - test_window_resized_updates_viewport_long_side
  - test_hyprland_drift_updates_viewport_from_rect

(The persistent-viewport-no-drift invariant test was already
passing because the previous transition handlers don't write to
viewport — the test was checking the absence of drift via the
absence of writes, which the skeleton already satisfied.)

Phase A (16 tests) still green.

Tests still failing (12, scheduled for commits 9-11):
  - mute/volume/loop persistence events (commit 9)
  - DisplayingImage content arrived branch (commit 10)
  - Closing transitions (commit 10)

Test cases for commit 9 (mute/volume/loop persistence):
  - MuteToggleRequested flips state.mute, emits ApplyMute
  - VolumeSet sets state.volume, emits ApplyVolume
  - LoopModeSet sets state.loop_mode, emits ApplyLoopMode
This commit is contained in:
pax 2026-04-08 19:35:43 -05:00
parent d75076c14b
commit a03d0e9dc8

View File

@ -881,20 +881,85 @@ class StateMachine:
return [ExitFullscreen()] return [ExitFullscreen()]
def _on_window_moved(self, event: WindowMoved) -> list[Effect]: def _on_window_moved(self, event: WindowMoved) -> list[Effect]:
# Real implementation: updates state.viewport from rect (move """Qt `moveEvent` fired (non-Hyprland only — Hyprland gates
# only — preserves long_side). Lands in commit 8. this in the adapter because Wayland doesn't expose absolute
window position to clients).
Move-only update: preserve `long_side` from the existing
viewport (moves don't change size), but recompute the center
from the new rect. If there's no existing viewport yet (first
move before any fit), build a fresh one from the rect.
Skipped during fullscreen moves while fullscreen aren't
user intent for the windowed viewport. Skipped in Closing.
"""
if self.fullscreen or self.state == State.CLOSING:
return []
x, y, w, h = event.rect
if w <= 0 or h <= 0:
return []
long_side = (
self.viewport.long_side if self.viewport is not None
else float(max(w, h))
)
self.viewport = Viewport(
center_x=x + w / 2,
center_y=y + h / 2,
long_side=long_side,
)
return [] return []
def _on_window_resized(self, event: WindowResized) -> list[Effect]: def _on_window_resized(self, event: WindowResized) -> list[Effect]:
# Real implementation: updates state.viewport from rect """Qt `resizeEvent` fired (non-Hyprland only).
# (resize — long_side becomes max(w, h)). Lands in commit 8.
Full rebuild from the rect: long_side becomes the new
max(w, h), center becomes the rect center. Resizes change
the user's intent for popout size.
Skipped during fullscreen and Closing.
"""
if self.fullscreen or self.state == State.CLOSING:
return []
x, y, w, h = event.rect
if w <= 0 or h <= 0:
return []
self.viewport = Viewport(
center_x=x + w / 2,
center_y=y + h / 2,
long_side=float(max(w, h)),
)
return [] return []
def _on_hyprland_drift_detected( def _on_hyprland_drift_detected(
self, event: HyprlandDriftDetected self, event: HyprlandDriftDetected
) -> list[Effect]: ) -> list[Effect]:
# Real implementation: rebuilds state.viewport from rect. """Hyprland-side drift detector found that the current window
# Lands in commit 8. rect differs from the last dispatched rect by more than
`_DRIFT_TOLERANCE`. The user moved or resized the window
externally (Super+drag, corner resize, window manager
intervention).
On Wayland, Qt's moveEvent / resizeEvent never fire for
external compositor-driven movement (xdg-toplevel doesn't
expose absolute position). So this event is the only path
that captures Hyprland Super+drag.
Adopt the new state as the viewport's intent: rebuild
viewport from the current rect.
Skipped during fullscreen drifts while in fullscreen
aren't meaningful for the windowed viewport.
"""
if self.fullscreen or self.state == State.CLOSING:
return []
x, y, w, h = event.rect
if w <= 0 or h <= 0:
return []
self.viewport = Viewport(
center_x=x + w / 2,
center_y=y + h / 2,
long_side=float(max(w, h)),
)
return [] return []
def _on_close_requested(self, event: CloseRequested) -> list[Effect]: def _on_close_requested(self, event: CloseRequested) -> list[Effect]: