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
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)
PlayingVideo + SeekRequested → SeekingVideo, stash target_ms, emit
SeekVideoTo. SeekingVideo + SeekRequested replaces the target (user
clicked again, latest seek wins). SeekingVideo + SeekCompleted →
PlayingVideo.
The slider pin behavior is the read-path query
`compute_slider_display_ms(mpv_pos_ms)` already implemented at the
skeleton stage: while in SeekingVideo, returns `seek_target_ms`;
otherwise returns `mpv_pos_ms`. The Qt-side adapter's poll timer
asks the state machine for the slider display value on every tick
and writes whatever it gets back to the slider widget.
**This replaces 96a0a9d's 500ms _seek_pending_until timestamp window
at the popout layer.** The state machine has no concept of wall-clock
time. The SeekingVideo state lasts exactly until mpv signals the seek
is done, via the playback_restart Signal added in commit 1. The
adapter distinguishes load-restart from seek-restart by checking
the state machine's current state (LoadingVideo → VideoStarted;
SeekingVideo → SeekCompleted).
The pre-commit-1 probe verified that mpv emits playback-restart
exactly once per load and exactly once per seek (3 events for 1
load + 2 seeks), so the dispatch routing is unambiguous.
VideoPlayer's internal _seek_pending_until field stays in place as
defense in depth — the state machine refactor's prompt explicitly
forbids touching media/video_player.py beyond the playback_restart
Signal addition. The popout layer no longer depends on it.
Tests passing after this commit (62 total → 46 pass, 16 fail):
- test_playing_video_seek_requested_transitions_and_pins
- test_seeking_video_completed_returns_to_playing
- test_seeking_video_seek_requested_replaces_target
- test_invariant_seek_pin_uses_compute_slider_display_ms (RACE FIX!)
Phase A (16 tests) still green.
Tests still failing (16, scheduled for commits 7-11):
- F11 round-trip (commit 7)
- 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 7 (Fullscreen flag + F11 round-trip):
- dispatch FullscreenToggled in any media state, assert flag flipped
- F11 enter snapshots viewport into pre_fullscreen_viewport
- F11 exit restores viewport from pre_fullscreen_viewport
NavigateRequested in any media state (DisplayingImage / LoadingVideo /
PlayingVideo / SeekingVideo) transitions to AwaitingContent and emits
[StopMedia, EmitNavigate]. NavigateRequested in AwaitingContent itself
(rapid Right-arrow spam, second nav before main_window has delivered
the next post) emits EmitNavigate alone — no StopMedia, because
there's nothing to stop.
This is the structural fix for the double-load race that 31d02d3c
fixed upstream by removing the explicit _on_post_activated call after
_grid._select. The popout-layer fix is independent and stronger: even
if upstream signal chains misfire, the state machine never produces
two Load effects for the same navigation cycle. The state machine's
LoadVideo / LoadImage effects only fire from ContentArrived, and
ContentArrived is delivered exactly once per main_window-side post
activation.
The Open event handler also lands here. Stashes saved_geo,
saved_fullscreen, monitor on the state machine instance for the
first ContentArrived to consume. The actual viewport seeding from
saved_geo lives in commit 8 — this commit just stores the inputs.
Tests passing after this commit (62 total → 42 pass, 20 fail):
- test_awaiting_open_stashes_saved_geo
- test_awaiting_navigate_emits_navigate_only
- test_displaying_image_navigate_stops_and_emits
- test_loading_video_navigate_stops_and_emits
- test_playing_video_navigate_stops_and_emits
- test_seeking_video_navigate_stops_and_emits
- test_invariant_double_navigate_no_double_load (RACE FIX!)
Plus several illegal-transition cases for nav-from-now-valid-states.
Phase A (16 tests in tests/core/) still green.
Tests still failing (20, scheduled for commits 6-11):
- SeekingVideo entry/exit (commit 6)
- F11 round-trip (commit 7)
- 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 6 (SeekingVideo + slider pin):
- PlayingVideo + SeekRequested → SeekingVideo + SeekVideoTo effect
- SeekingVideo + SeekRequested replaces seek_target_ms
- SeekingVideo + SeekCompleted → PlayingVideo
- test_invariant_seek_pin_uses_compute_slider_display_ms
First batch of real transitions. The EOF race fix is the headline —
this commit replaces fda3b10b's 250ms _eof_ignore_until timestamp
window with a structural transition that drops VideoEofReached in
every state except PlayingVideo.
Transitions implemented:
ContentArrived(VIDEO) in any state →
LoadingVideo, [LoadVideo, FitWindowToContent]
Snapshots current_path/info/kind/width/height. Flips
is_first_content_load to False (the saved_geo seeding lands in
commit 8). Image and GIF kinds are still stubbed — they get
DisplayingImage in commit 10.
LoadingVideo + VideoStarted →
PlayingVideo, [ApplyMute, ApplyVolume, ApplyLoopMode]
The persistence effects fire on PlayingVideo entry, pushing the
state machine's persistent values into mpv. This is the
structural replacement for VideoPlayer._pending_mute's lazy-mpv
replay (the popout layer now owns mute as truth; VideoPlayer's
internal _pending_mute stays as defense in depth, untouched).
PlayingVideo + VideoEofReached →
Loop=NEXT: [EmitPlayNextRequested]
Loop=ONCE: [] (mpv keep_open=yes pauses naturally)
Loop=LOOP: [] (mpv loop-file=inf handles internally)
*Anything* + VideoEofReached (not in PlayingVideo) →
[], state unchanged
**THIS IS THE EOF RACE FIX.** The fda3b10b commit added a 250ms
timestamp window inside VideoPlayer to suppress eof events
arriving from a previous file's stop. The state machine subsumes
that by only accepting eof in PlayingVideo. In LoadingVideo
(where the race lives), VideoEofReached is structurally invalid
and gets dropped at the dispatch boundary. No window. No
timestamp. No race.
LoadingVideo / PlayingVideo + VideoSizeKnown →
[FitWindowToContent(w, h)]
mpv reports new dimensions; refit. Same effect for both states
because the only difference is "did the user see a frame yet"
(which doesn't matter for window sizing).
PlayingVideo + TogglePlayRequested →
[TogglePlay]
Space key / play button. Only valid in PlayingVideo — toggling
play during a load or seek would race with mpv's own state
machine.
Tests passing after this commit (62 total → 35 pass, 27 fail):
- test_loading_video_started_transitions_to_playing
- test_loading_video_eof_dropped (RACE FIX!)
- test_loading_video_size_known_emits_fit
- test_playing_video_eof_loop_next_emits_play_next
- test_playing_video_eof_loop_once_pauses
- test_playing_video_eof_loop_loop_no_op
- test_playing_video_size_known_refits
- test_playing_video_toggle_play_emits_toggle
- test_invariant_eof_race_loading_video_drops_stale_eof (RACE FIX!)
- test_awaiting_content_arrived_video_transitions_to_loading
- test_awaiting_content_arrived_video_emits_persistence_effects
Plus several illegal-transition cases for the (LoadingVideo, *)
events that this commit makes meaningfully invalid.
Phase A (16 tests in tests/core/) still green.
Tests still failing (27, scheduled for commits 5-11):
- Open / NavigateRequested handlers (commit 5)
- DisplayingImage transitions (commit 10)
- SeekingVideo transitions (commit 6)
- Closing transitions (commit 10)
- Persistent viewport / drift events (commit 8)
- mute/volume/loop persistence events (commit 9)
- F11 round-trip (commit 7)
Test cases for commit 5 (Navigating + AwaitingContent + double-load):
- dispatch NavigateRequested in PlayingVideo → AwaitingContent
- second NavigateRequested in AwaitingContent doesn't re-stop
- test_invariant_double_navigate_no_double_load
Lays down the data shapes for the popout state machine ahead of any
transition logic. Pure Python — does not import PySide6, mpv, httpx,
subprocess, or any module that does. The Phase B test suite (commit 3)
will exercise this purity by importing it directly without standing
up a QApplication; the test suite is the forcing function that keeps
the file pure as transitions land in commits 4-11.
Module structure follows docs/POPOUT_ARCHITECTURE.md exactly.
States (6, target ≤10):
AwaitingContent — popout exists, no current media (initial OR mid-nav)
DisplayingImage — static image or GIF on screen
LoadingVideo — set_media called for video, awaiting first frame
PlayingVideo — video active (paused or playing)
SeekingVideo — user-initiated seek pending
Closing — closeEvent received, terminal
Events (17, target ≤20):
Open / ContentArrived / NavigateRequested
VideoStarted / VideoEofReached / VideoSizeKnown
SeekRequested / SeekCompleted
MuteToggleRequested / VolumeSet / LoopModeSet / TogglePlayRequested
FullscreenToggled
WindowMoved / WindowResized / HyprlandDriftDetected
CloseRequested
Effects (14, target ≤15):
LoadImage / LoadVideo / StopMedia
ApplyMute / ApplyVolume / ApplyLoopMode
SeekVideoTo / TogglePlay
FitWindowToContent / EnterFullscreen / ExitFullscreen
EmitNavigate / EmitPlayNextRequested / EmitClosed
Frozen dataclasses for events and effects, Enum for State / MediaKind /
LoopMode. Dispatch uses Python 3.10+ structural pattern matching to
route by event type.
StateMachine fields cover the full inventory:
- Lifecycle: state, is_first_content_load
- Persistent (orthogonal): fullscreen, mute, volume, loop_mode
- Geometry: viewport, pre_fullscreen_viewport, last_dispatched_rect
- Seek: seek_target_ms
- Content snapshot: current_path, current_info, current_kind,
current_width, current_height
- Open-event payload: saved_geo, saved_fullscreen, monitor
- Nav: grid_cols
Read-path query implemented even at the skeleton stage:
compute_slider_display_ms(mpv_pos_ms) returns seek_target_ms while
in SeekingVideo, mpv_pos_ms otherwise. This is the structural
replacement for the 500ms _seek_pending_until timestamp window —
no timestamp, just the SeekingVideo state.
Every per-event handler is a stub that returns []. Real transitions
land in commits 4-11 (priority order: PlayingVideo + LoadingVideo +
EOF race fix → Navigating + AwaitingContent + double-load fix →
SeekingVideo + slider pin → Fullscreen + F11 → viewport + drift →
mute/volume/loop persistence → DisplayingImage + Closing → illegal
transition handler).
Closing is treated as terminal at the dispatch entry — once we're
there, every event returns [] regardless of type. Same property the
current closeEvent has implicitly.
Verification:
- Phase A test suite (16 tests) still passes
- state.py imports cleanly with PySide6/mpv/httpx blocked at the
meta_path level (purity gate)
- StateMachine() constructs with all fields initialized to sensible
defaults
- Stub dispatch returns [] for every event type
- 6 states / 17 events / 14 effects all under budget (≤10/≤20/≤15)
Test cases for state machine tests (Prompt 3 commit 3):
- Construct StateMachine, assert initial state == AwaitingContent
- Assert is_first_content_load is True at construction
- Assert all stub dispatches return []
- Assert compute_slider_display_ms returns mpv_pos when not seeking
Two related improvements to the Ctrl+P privacy screen flow.
1. Resume video on un-hide
Pre-fix: Ctrl+P paused any playing video in the embedded preview and
the popout, but the second Ctrl+P only hid the privacy overlay — the
videos stayed paused. The user had to manually click Play to resume.
Fix: in _toggle_privacy's privacy-off branch, mirror the privacy-on
pause logic with resume() calls on the embedded preview's video player
and the popout's video. Unconditional resume — if the user manually
paused before Ctrl+P, the auto-resume on un-hide is a tiny annoyance,
but the common case (privacy hides → user comes back → video should
be playing again) wins.
2. Popout privacy uses an in-place overlay instead of hide()
Pre-fix attempt: privacy-on called self._fullscreen_window.hide() and
privacy-off called .show(). On Wayland (Hyprland) the hide→show round
trip drops the window's position because the compositor unmaps the
window on hide and remaps it at the default tile position on show.
A first attempt at restoring the position via a deferred
hyprctl_resize_and_move dispatch in privacy_show didn't take — by
the time the dispatch landed, the window had already been re-tiled
and the move was gated by `if not win.get("floating"): return`.
Cleaner fix: don't hide the popout window at all. FullscreenPreview
gains its own _privacy_overlay (a black QWidget child of central,
parallel to the existing toolbar / controls bar children) that
privacy_hide raises over the media stack. The popout window stays
mapped, position is preserved automatically because nothing moves,
and the overlay covers the content visually.
privacy_hide / privacy_show methods live in FullscreenPreview, not
in main_window — popout-internal state belongs to the popout module.
_toggle_privacy in main_window just calls them. This also makes
adding more popout-side privacy state later (e.g. fullscreen save)
a one-method change inside the popout class.
Also added a _popout_was_visible flag in BooruApp._toggle_privacy so
privacy-off only restores the popout if it was actually visible at
privacy-on time. Without the gate, privacy-off would inappropriately
re-show a popout the user had closed before triggering privacy.
Verified manually:
- popout open + drag to non-default pos + Ctrl+P + Ctrl+P → popout
still at the dragged position, content visible again
- popout open + video playing + Ctrl+P + Ctrl+P → video resumes
- popout closed + Ctrl+P + Ctrl+P → popout stays closed
- embedded preview video + Ctrl+P + Ctrl+P → resumes
- Ctrl+P with no video on screen → no errors
Two related preservation bugs around the popout's F11 fullscreen
toggle, both surfaced during the post-refactor verification sweep.
1. ImageViewer zoom/pan loss on resize
ImageViewer.resizeEvent unconditionally called _fit_to_view() on every
resize event. F11 enter resizes the widget to the full screen, F11
exit resizes it back to the windowed size — both fired _fit_to_view,
clobbering any explicit user zoom and offset. Same problem for manual
window drags and splitter moves.
Fix: in resizeEvent, compute the previous-size fit-to-view zoom from
event.oldSize() and compare to current _zoom. Only re-fit if the user
was at fit-to-view at the previous size (within a 0.001 epsilon —
tighter than any wheel/key zoom step). Otherwise leave _zoom and
_offset alone.
The first-resize case (no valid oldSize, e.g. initial layout) still
defaults to fit, matching the original behavior for fresh widgets.
2. Popout window position lost on F11 round-trip
FullscreenPreview._enter_fullscreen captured _windowed_geometry but
the F11-exit restore goes through `_viewport` (the persistent center +
long_side that drives _fit_to_content). The drift detection in
_derive_viewport_for_fit only updates _viewport when
_last_dispatched_rect is set AND a fit is being computed — neither
path catches the "user dragged the popout with Super+drag and then
immediately pressed F11" sequence:
- Hyprland Super+drag does NOT fire Qt's moveEvent (xdg-toplevel
doesn't expose absolute screen position to clients on Wayland),
so Qt-side drift detection is dead on Hyprland.
- The Hyprland-side drift detection in _derive_viewport_for_fit
only fires inside a fit, and no fit is triggered between a drag
and F11.
- Result: _viewport still holds whatever it had before the drag —
typically the saved-from-last-session geometry seeded by the
first-fit one-shot at popout open.
When F11 exits, the deferred _fit_to_content reads the stale viewport
and restores the popout to the *previously seeded* position instead of
where the user actually had it.
Fix: in _enter_fullscreen, after capturing _windowed_geometry, also
write the current windowed state into self._viewport directly. The
viewport then holds the actual pre-fullscreen position regardless of
how it got there (drag, drag+nav, drag+F11, etc.), and F11 exit's
restore reads it correctly.
Bundled into one commit because both fixes are "F11 round-trip should
preserve where the user was" — the image fix preserves content state
(zoom/pan), the popout fix preserves window state (position). Same
theme, related root cause class. Bisecting one without the other
would be misleading.
Verified manually:
- image: scroll-zoom + drag pan + F11 + F11 → zoom and pan preserved
- image: untouched zoom + F11 + F11 → still fits to view
- image: scroll-zoom + manual window resize → zoom preserved
- popout: Super+drag to a new position + F11 + F11 → lands at the
dragged position, not at the saved-from-last-session position
- popout: same sequence on a video post → same result (videos don't
have zoom/pan, but the window-position fix applies to all media)
Step 6 of the gui/app.py + gui/preview.py structural refactor — the
biggest single move in the sequence. The entire 1046-line popout
window class moves to its own module under popout/, alongside the
viewport NamedTuple it depends on. The popout overlay styling
documentation comment that lived above the class moves with it
since it's about the popout, not about ImagePreview.
Address-only adjustment: the lazy `from ..core.config import` lines
inside `_hyprctl_resize` and `_hyprctl_resize_and_move` become
`from ...core.config import` because the new module sits one package
level deeper. Same target module, different relative-import depth —
no behavior change.
preview.py grows another re-export shim so app.py's two lazy
`from .preview import FullscreenPreview` call sites (in
_open_fullscreen_preview and _on_fullscreen_closed) keep working
unchanged. Shim removed in commit 14, where the call sites move
to the canonical `from .popout.window import FullscreenPreview`.
Step 2 of the gui/app.py + gui/preview.py structural refactor. Pure
move: the popout viewport NamedTuple and the drift-tolerance constant
are now in their own module under popout/. preview.py grows another
re-export shim line so FullscreenPreview's method bodies (which
reference Viewport and _DRIFT_TOLERANCE by bare name) keep working
unchanged. Shim removed in commit 14. See docs/REFACTOR_PLAN.md.