**Commit 14b of the pre-emptive 14a/14b split.**
Adds the effect application path. The state machine becomes the
single source of truth for the popout's media transitions, navigation,
fullscreen toggle, and close lifecycle. The legacy imperative paths
that 14a left in place are removed where the dispatch+apply chain
now produces the same side effects.
Architectural shape:
Qt event → _fsm_dispatch(Event) → list[Effect] → _apply_effects()
↓
pattern-match by type
↓
calls existing private helpers
(_fit_to_content, _enter_fullscreen,
_video.play_file, etc.)
The state machine doesn't try to reach into Qt or mpv directly; it
returns descriptors and the adapter dispatches them to the existing
implementation methods. The private helpers stay in place as the
implementation; the state machine becomes their official caller.
What's fully authoritative via dispatch+apply:
- Navigate keys + wheel tilt → NavigateRequested → EmitNavigate
- F11 → FullscreenToggled → EnterFullscreen / ExitFullscreen
- Space → TogglePlayRequested → TogglePlay
- closeEvent → CloseRequested → StopMedia + EmitClosed
- set_media → ContentArrived → LoadImage|LoadVideo + FitWindowToContent
- mpv playback-restart → VideoStarted | SeekCompleted (state-aware)
- mpv eof-reached + Loop=Next → VideoEofReached → EmitPlayNextRequested
- mpv video-params → VideoSizeKnown → FitWindowToContent
What's deliberately no-op apply in 14b (state machine TRACKS but
doesn't drive):
- ApplyMute / ApplyVolume / ApplyLoopMode: legacy slot connections
on the popout's VideoPlayer still handle the user-facing toggles.
Pushing state.mute/volume/loop_mode would create a sync hazard
with the embedded preview's mute state, which main_window pushes
via direct attribute writes at popout open. The state machine
fields are still updated for the upcoming SyncFromEmbedded path
in a future commit; the apply handlers are intentionally empty.
- SeekVideoTo: the legacy `_ClickSeekSlider.clicked_position →
VideoPlayer._seek` connection still handles both the mpv.seek
call (now exact, per the 609066c drag-back fix) and the legacy
500ms `_seek_pending_until` pin window. Replacing this requires
modifying VideoPlayer._poll which is forbidden by the state
machine refactor's no-touch rule on media/video_player.py.
Removed duplicate legacy emits (would have caused real bugs):
- self.navigate.emit(±N) in eventFilter arrow keys + wheel tilt
→ EmitNavigate effect
- self.closed.emit() and self._video.stop() in closeEvent
→ StopMedia + EmitClosed effects
- self._video.play_next.connect(self.play_next_requested)
signal-to-signal forwarding → EmitPlayNextRequested effect
- self._enter_fullscreen() / self._exit_fullscreen() direct calls
→ EnterFullscreen / ExitFullscreen effects
- self._video._toggle_play() direct call → TogglePlay effect
- set_media body's load logic → LoadImage / LoadVideo effects
The Esc/Q handler now only calls self.close() and lets closeEvent
do the dispatch + apply. Two reasons:
1. Geometry persistence (FullscreenPreview._saved_geometry /
_saved_fullscreen) is adapter-side concern and must run BEFORE
self.closed is emitted, because main_window's
_on_fullscreen_closed handler reads those class fields. Saving
geometry inside closeEvent before dispatching CloseRequested
gets the order right.
2. The state machine sees the close exactly once. Two-paths
(Esc/Q → dispatch + close() → closeEvent → re-dispatch) would
require the dispatch entry's CLOSING-state guard to silently
absorb the second event — works but more confusing than just
having one dispatch site.
The closeEvent flow now is:
1. Save FullscreenPreview._saved_fullscreen and _saved_geometry
(adapter-side, before dispatch)
2. Remove the QApplication event filter
3. Dispatch CloseRequested → effects = [StopMedia, EmitClosed]
4. Apply effects → stop media, emit self.closed
5. super().closeEvent(event) → Qt window close
Verification:
- Phase A test suite (16 tests in tests/core/) still passes
- State machine tests (65 in tests/gui/popout/test_state.py) still pass
- Total: 81 / 81 automated tests green
- Imports clean
**The 11 manual scenarios are NOT verified by automated tests.**
The user must run the popout interactively and walk through each
scenario before this commit can be considered fully verified:
1. P↔L navigation cycles drift toward corner
2. Super+drag externally then nav
3. Corner-resize externally then nav
4. F11 same-aspect round-trip
5. F11 across-aspect round-trip
6. First-open from saved geometry
7. Restart persistence across app sessions
8. Rapid Right-arrow spam
9. Uncached video click
10. Mute toggle before mpv exists
11. Seek mid-playback (already verified by the 14a + drag-back-fix
sweep)
**If ANY scenario fails after this commit:** immediate `git revert
HEAD`, do not fix in place. The 14b apply layer is bounded enough
that any regression can be diagnosed by inspecting the apply handler
for the relevant effect type, but the in-place-fix temptation should
be resisted — bisect-safety requires a clean revert.
Test cases for commit 15:
- main_window.popout calls become method calls instead of direct
underscore access (open_post / sync_video_state / get_video_state /
set_toolbar_visibility)
- Method-cluster sweep from REFACTOR_INVENTORY.md still passes
Follow-up to commit 14a. The booru logger has only the in-app
QTextEdit LogHandler attached (main_window.py:436-440), so the
POPOUT_FSM dispatch trace from the state machine adapter only
reaches the Ctrl+L log panel — invisible from the shell.
Adds a stderr StreamHandler attached directly to the
`booru.popout.adapter` logger so:
python -m booru_viewer.main_gui 2>&1 | grep POPOUT_FSM
works during the commit-14a verification gate. The user can capture
the dispatch trace per scenario and compare it to the legacy path's
actions before commit 14b switches authority.
The handler is tagged with a `_is_popout_fsm_stderr` sentinel
attribute so re-imports of window.py don't stack duplicate
handlers (defensive — module-level code only runs once per process,
but the check costs nothing).
Format: `[HH:MM:SS.mmm] POPOUT_FSM <event> | <old> -> <new> | effects=[...]`
The millisecond precision matters for the seek scenario where the
race window is sub-100ms.
Propagation to the parent booru logger is left enabled, so dispatch
trace lines also continue to land in the in-app log panel for the
user who prefers Ctrl+L.
Tests still pass (81 / 81). No behavior change to widgets — this
only affects log output routing.
**Commit 14a of the pre-emptive 14a/14b split.**
Adds the popout's pure-Python state machine as a parallel side
channel to the legacy imperative event handling. The state machine
runs alongside the existing code: every Qt event handler / mpv
signal / button click below dispatches a state machine event AND
continues to run the existing imperative action. The state machine's
returned effects are LOGGED at DEBUG, not applied to widgets.
**The legacy path stays authoritative through commit 14a; commit
14b switches the authority to the dispatch path.**
This is the bisect-safe-by-construction split the refactor plan
called for. 197 lines added, 0 removed. No widget side effects from
the dispatch path. App is byte-identical from the user's perspective.
Wired wire-points (every Qt event the state machine cares about):
__init__:
- Constructs StateMachine, sets grid_cols
- Dispatches Open(saved_geo, saved_fullscreen, monitor) using
the class-level cross-popout-session state
- Connects VideoPlayer.playback_restart Signal (added in
commit 1) to _on_video_playback_restart, which routes to
VideoStarted (LoadingVideo) or SeekCompleted (SeekingVideo)
based on current state machine state
- Connects VideoPlayer.play_next → VideoEofReached dispatch
- Connects VideoPlayer.video_size → VideoSizeKnown dispatch
- Connects VideoPlayer._seek_slider.clicked_position → SeekRequested
- Connects VideoPlayer._mute_btn.clicked → MuteToggleRequested
- Connects VideoPlayer._vol_slider.valueChanged → VolumeSet
- Connects VideoPlayer._loop_btn.clicked → LoopModeSet
set_media:
- Detects MediaKind from is_video / .gif suffix
- Builds referer for streaming URLs
- Dispatches ContentArrived(path, info, kind, width, height, referer)
BEFORE the legacy imperative load path runs
eventFilter (key + wheel):
- Esc/Q → CloseRequested
- Left/H → NavigateRequested(-1)
- Right/L → NavigateRequested(+1)
- Up/K → NavigateRequested(-grid_cols)
- Down/J → NavigateRequested(+grid_cols)
- F11 → FullscreenToggled
- Space (video) → TogglePlayRequested
- Wheel horizontal tilt → NavigateRequested(±1)
- Wheel vertical (video) → VolumeSet(new_value)
- Period/Comma keys (relative seek) explicitly NOT dispatched —
they go straight to mpv via the legacy path. The state
machine's SeekRequested is for slider-driven seeks; commit 14b
will route the relative-seek keys through SeekRequested with
a target_ms computed from current position.
resizeEvent (non-Hyprland branch):
- WindowResized(rect) dispatched after the legacy viewport update
moveEvent (non-Hyprland branch):
- WindowMoved(rect) dispatched after the legacy viewport update
closeEvent:
- CloseRequested dispatched at entry
The _fsm_dispatch helper centralizes the dispatch + log path so every
wire-point is one line. Logs at DEBUG level via a new
`booru.popout.adapter` logger:
POPOUT_FSM <event_name> | <old_state> -> <new_state> | effects=[...]
Filter the log output by `POPOUT_FSM` substring to see only the
state machine activity during the manual sweep.
The _on_video_playback_restart helper is the ONE place the adapter
peeks at state machine state to choose between two event types
(VideoStarted vs SeekCompleted from the same mpv playback-restart
event). It's a read, not a write — the state machine's dispatch
remains the only mutation point.
Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green.
**Verification gate (next):**
Before commit 14b lands, the user runs the popout in their own
interactive Hyprland session and walks through the 11 race scenarios:
1. P↔L navigation cycles drift toward corner
2. Super+drag externally then nav
3. Corner-resize externally then nav
4. F11 same-aspect round-trip
5. F11 across-aspect round-trip
6. First-open from saved geometry
7. Restart persistence across app sessions
8. Rapid Right-arrow spam
9. Uncached video click
10. Mute toggle before mpv exists
11. Seek mid-playback
For each scenario, capture the POPOUT_FSM log lines and verify the
state machine's dispatch sequence matches what the legacy path
actually did. Any discrepancy is a state machine logic bug that
must be fixed in state.py BEFORE 14b lands and switches authority
to the dispatch path. Fix in state.py, not in window.py — state.py
is still the source of truth.
The bisect-safe property: even if the user finds a discrepancy
during the sweep, this commit DOES NOT change app behavior. App is
fully functional through the legacy path. The dispatch path is
diagnostic-only.
Test cases for commit 14b:
- Each effect type pattern-matches to a real widget action
- Manual 11-scenario sweep with the dispatch path authoritative
Pure refactor: moves the three Hyprland IPC helpers
(_hyprctl_get_window, _hyprctl_resize, _hyprctl_resize_and_move)
out of FullscreenPreview's class body and into a new sibling
hyprland.py module. The class methods become 1-line shims that
call the module functions, preserving byte-for-byte call-site
compatibility for the existing window.py code (_fit_to_content,
_enter_fullscreen, closeEvent all keep using self._hyprctl_*).
The module-level functions take the window title as a parameter
instead of reading it from self.windowTitle(), so they're cleanly
testable without a Qt instance.
Two reasons for the split:
1. **Architecture target.** docs/POPOUT_ARCHITECTURE.md calls for
popout/hyprland.py as a separate module so the upcoming Qt
adapter rewrite (commit 14) can call the helpers through a clean
import surface — no FullscreenPreview self-reference required.
2. **Single source of Hyprland IPC.** Both the legacy window.py
methods and (soon) the adapter's effect handler can call the same
functions. The state machine refactor's FitWindowToContent effect
resolves to a hyprland.resize_and_move call without going through
the legacy class methods.
The shims live in window.py for one commit only — commit 14's
adapter rewrite drops them in favor of direct calls to
popout.hyprland.* from the effect application path.
Files changed:
- NEW: booru_viewer/gui/popout/hyprland.py (~180 lines)
- MOD: booru_viewer/gui/popout/window.py (~120 lines removed,
~20 lines of shims added)
Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green.
Smoke test:
- FullscreenPreview class still imports cleanly
- All three _hyprctl_* shim methods present
- Shim source code references hyprland module
- App expected to launch without changes (popout open / fit / close
all go through the shims, which delegate to the module functions
with the same byte-for-byte semantics as the legacy methods)
Test cases for commit 14 (window.py adapter rewrite):
- Replace eventFilter imperative branches with dispatch calls
- Apply effects from dispatch returns to widgets
- Manual 11-scenario sweep
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`.