popout/state: implement Fullscreen flag + F11 round-trip

FullscreenToggled in any non-Closing state flips state.fullscreen.

Enter (fullscreen=False → True):
- Snapshot state.viewport into state.pre_fullscreen_viewport
- Emit EnterFullscreen effect (adapter calls self.showFullScreen())

Exit (fullscreen=True → False):
- Restore state.viewport from state.pre_fullscreen_viewport
- Clear state.pre_fullscreen_viewport
- Emit ExitFullscreen effect (adapter calls self.showNormal() then
  defers a FitWindowToContent on the next event-loop tick — matching
  the current QTimer.singleShot(0, ...) pattern)

This makes the 705e6c6 F11 round-trip viewport preservation
structural. The fix in the legacy code wrote the current Hyprland
window state into _viewport inside _enter_fullscreen so the F11
exit could restore it. The state machine version is equivalent: the
viewport snapshot at the moment of entering is the source of truth
for restoration. Whether the user got there via Super+drag (no Qt
moveEvent on Wayland), nav, or external resize, the snapshot
captures the viewport AS IT IS RIGHT NOW.

The interaction with HyprlandDriftDetected (commit 8): the adapter
will dispatch a HyprlandDriftDetected event before FullscreenToggled
during enter, so any drift between the last dispatched rect and
current Hyprland geometry is absorbed into viewport BEFORE the
snapshot. That's how the state machine handles the "user dragged
the popout, then immediately pressed F11" case.

Tests passing after this commit (62 total → 47 pass, 15 fail):

  - test_invariant_f11_round_trip_restores_pre_fullscreen_viewport

Phase A (16 tests) still green.

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

Test cases for commit 8 (persistent viewport + drift events):
  - WindowMoved updates viewport center, preserves long_side
  - WindowResized updates viewport long_side from new max(w,h)
  - HyprlandDriftDetected rebuilds viewport from rect
  - Persistent viewport doesn't drift across navs (already passing)
This commit is contained in:
pax 2026-04-08 19:34:52 -05:00
parent 664d4e9cda
commit d75076c14b

View File

@ -832,9 +832,53 @@ class StateMachine:
return []
def _on_fullscreen_toggled(self, event: FullscreenToggled) -> list[Effect]:
# Real implementation: enter snapshots viewport into
# pre_fullscreen_viewport. Exit restores. Lands in commit 7.
return []
"""**F11 round-trip viewport preservation (705e6c6 made
structural).**
Enter (current state: not fullscreen):
- Snapshot `viewport` into `pre_fullscreen_viewport`
- Set `fullscreen = True`
- Emit `EnterFullscreen` effect
Exit (current state: fullscreen):
- Restore `viewport` from `pre_fullscreen_viewport`
- Clear `pre_fullscreen_viewport`
- Set `fullscreen = False`
- Emit `ExitFullscreen` effect (which causes the adapter
to defer a FitWindowToContent on the next event-loop
tick matching the current QTimer.singleShot(0, ...)
pattern at popout/window.py:1023)
The viewport snapshot at the moment of entering is the key.
Whether the user got to that position via Super+drag (no Qt
moveEvent on Wayland), nav (which doesn't update viewport
unless drift is detected), or external resize, the
`pre_fullscreen_viewport` snapshot captures the viewport
AS IT IS RIGHT NOW. F11 exit restores it exactly.
The 705e6c6 commit fixed this in the legacy code by
explicitly writing the current Hyprland window state into
`_viewport` inside `_enter_fullscreen` the state machine
version is structurally equivalent. The adapter's
EnterFullscreen handler reads the current Hyprland geometry
and dispatches a `HyprlandDriftDetected` event before the
FullscreenToggled, which updates `viewport` to current
reality, then FullscreenToggled snapshots that into
`pre_fullscreen_viewport`.
Valid in every non-Closing state. Closing drops it (handled
at the dispatch entry).
"""
if not self.fullscreen:
self.pre_fullscreen_viewport = self.viewport
self.fullscreen = True
return [EnterFullscreen()]
# Exiting fullscreen
if self.pre_fullscreen_viewport is not None:
self.viewport = self.pre_fullscreen_viewport
self.pre_fullscreen_viewport = None
self.fullscreen = False
return [ExitFullscreen()]
def _on_window_moved(self, event: WindowMoved) -> list[Effect]:
# Real implementation: updates state.viewport from rect (move