From 45e6042ebba29cc538c38a2b70f7f2082a6ffbf1 Mon Sep 17 00:00:00 2001 From: pax Date: Wed, 8 Apr 2026 19:50:40 -0500 Subject: [PATCH] popout/window: wire eventFilter to StateMachine.dispatch (parallel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 | -> | 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 --- booru_viewer/gui/popout/window.py | 197 ++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/booru_viewer/gui/popout/window.py b/booru_viewer/gui/popout/window.py index 0a7f01f..8bfbff0 100644 --- a/booru_viewer/gui/popout/window.py +++ b/booru_viewer/gui/popout/window.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from pathlib import Path from PySide6.QtCore import Qt, QRect, QTimer, Signal @@ -14,9 +15,39 @@ from PySide6.QtWidgets import ( from ..media.constants import _is_video from ..media.image_viewer import ImageViewer from ..media.video_player import VideoPlayer +from .state import ( + CloseRequested, + ContentArrived, + FullscreenToggled, + LoopMode, + LoopModeSet, + MediaKind, + MuteToggleRequested, + NavigateRequested, + Open, + SeekCompleted, + SeekRequested, + State, + StateMachine, + TogglePlayRequested, + VideoEofReached, + VideoSizeKnown, + VideoStarted, + VolumeSet, + WindowMoved, + WindowResized, +) from .viewport import Viewport, _DRIFT_TOLERANCE +# Adapter logger — separate from the popout's main `booru` logger so +# the dispatch trace can be filtered independently. Format: every +# dispatch call logs at DEBUG with the event name, state transition, +# and effect list. The user filters by `POPOUT_FSM` substring to see +# only the state machine activity during the manual sweep. +_fsm_log = logging.getLogger("booru.popout.adapter") + + ## Overlay styling for the popout's translucent toolbar / controls bar ## now lives in the bundled themes (themes/*.qss). The widgets get their ## object names set in code (FullscreenPreview / VideoPlayer) so theme QSS @@ -262,6 +293,116 @@ class FullscreenPreview(QMainWindow): else: self.showFullScreen() + # ---- State machine adapter wiring (commit 14a) ---- + # Construct the pure-Python state machine and dispatch the + # initial Open event with the cross-popout-session class state + # the legacy code stashed above. The state machine runs in + # PARALLEL with the legacy imperative 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. + # + # The grid_cols field is used by the keyboard nav handlers + # for the Up/Down ±cols stride. + self._state_machine = StateMachine() + self._state_machine.grid_cols = grid_cols + saved_geo_tuple = None + if FullscreenPreview._saved_geometry: + sg = FullscreenPreview._saved_geometry + saved_geo_tuple = (sg.x(), sg.y(), sg.width(), sg.height()) + self._fsm_dispatch(Open( + saved_geo=saved_geo_tuple, + saved_fullscreen=bool(FullscreenPreview._saved_fullscreen), + monitor=monitor, + )) + + # Wire VideoPlayer's playback_restart Signal (added in commit 1) + # to the adapter's dispatch routing. mpv emits playback-restart + # once after each loadfile and once after each completed seek; + # the adapter distinguishes by checking the state machine's + # current state at dispatch time. + self._video.playback_restart.connect(self._on_video_playback_restart) + # Wire video EOF (already connected to play_next_requested + # signal above) — additionally dispatch VideoEofReached. + self._video.play_next.connect( + lambda: self._fsm_dispatch(VideoEofReached()) + ) + # Wire video size known. + self._video.video_size.connect( + lambda w, h: self._fsm_dispatch(VideoSizeKnown(width=w, height=h)) + ) + # Wire seek slider clicks → SeekRequested. + self._video._seek_slider.clicked_position.connect( + lambda v: self._fsm_dispatch(SeekRequested(target_ms=v)) + ) + # Wire mute button → MuteToggleRequested. Dispatch BEFORE the + # legacy _toggle_mute runs (which mutates VideoPlayer state) + # so the dispatch reflects the user-intent edge. + self._video._mute_btn.clicked.connect( + lambda: self._fsm_dispatch(MuteToggleRequested()) + ) + # Wire volume slider → VolumeSet. + self._video._vol_slider.valueChanged.connect( + lambda v: self._fsm_dispatch(VolumeSet(value=v)) + ) + # Wire loop button → LoopModeSet. Dispatched AFTER the legacy + # cycle so the new value is what we send. + self._video._loop_btn.clicked.connect( + lambda: self._fsm_dispatch( + LoopModeSet(mode=LoopMode(self._video.loop_state)) + ) + ) + + def _fsm_dispatch(self, event) -> list: + """Dispatch an event to the state machine and log the result. + + Adapter-internal helper. Centralizes the dispatch + log path + so every wire-point is one line. Returns the effect list for + callers that want to inspect it (commit 14a doesn't use the + return value; commit 14b will pattern-match and apply). + + The hasattr guard handles edge cases where Qt events might + fire during __init__ (e.g. resizeEvent on the first show()) + before the state machine has been constructed. After + construction the guard is always True. + """ + if not hasattr(self, "_state_machine"): + return [] + old_state = self._state_machine.state + effects = self._state_machine.dispatch(event) + new_state = self._state_machine.state + _fsm_log.debug( + "POPOUT_FSM %s | %s -> %s | effects=%s", + type(event).__name__, + old_state.name, + new_state.name, + [type(e).__name__ for e in effects], + ) + return effects + + def _on_video_playback_restart(self) -> None: + """mpv `playback-restart` event arrived (via VideoPlayer's + playback_restart Signal added in commit 1). Distinguish + VideoStarted (after load) from SeekCompleted (after seek) by + the state machine's current state. + + This is the ONE place the adapter peeks at state to choose an + event type — it's a read, not a write, and it's the price of + having a single mpv event mean two different things. + """ + if not hasattr(self, "_state_machine"): + return + if self._state_machine.state == State.LOADING_VIDEO: + self._fsm_dispatch(VideoStarted()) + elif self._state_machine.state == State.SEEKING_VIDEO: + self._fsm_dispatch(SeekCompleted()) + # Other states: drop. The state machine's release-mode + # legality check would also drop it; this saves the dispatch + # round trip. + _saved_geometry = None # remembers window size/position across opens _saved_fullscreen = False _current_tags: dict[str, list[str]] = {} @@ -390,6 +531,35 @@ class FullscreenPreview(QMainWindow): """ self._info_label.setText(info) ext = Path(path).suffix.lower() + + # State machine dispatch (parallel — legacy code below stays + # authoritative through commit 14a). + if _is_video(path): + kind = MediaKind.VIDEO + elif ext == ".gif": + kind = MediaKind.GIF + else: + kind = MediaKind.IMAGE + # Detect streaming URL → set referer for the dispatch payload. + # This matches the per-file referrer the legacy play_file path + # already sets at media/video_player.py:343-347. + referer = None + if path.startswith(("http://", "https://")): + try: + from urllib.parse import urlparse + from ...core.cache import _referer_for + referer = _referer_for(urlparse(path)) + except Exception: + pass + self._fsm_dispatch(ContentArrived( + path=path, + info=info, + kind=kind, + width=width, + height=height, + referer=referer, + )) + if _is_video(path): self._viewer.clear() self._video.stop() @@ -735,30 +905,43 @@ class FullscreenPreview(QMainWindow): self._show_overlay() return True elif key in (Qt.Key.Key_Escape, Qt.Key.Key_Q): + self._fsm_dispatch(CloseRequested()) self.close() return True elif key in (Qt.Key.Key_Left, Qt.Key.Key_H): + self._fsm_dispatch(NavigateRequested(direction=-1)) self.navigate.emit(-1) return True elif key in (Qt.Key.Key_Right, Qt.Key.Key_L): + self._fsm_dispatch(NavigateRequested(direction=1)) self.navigate.emit(1) return True elif key in (Qt.Key.Key_Up, Qt.Key.Key_K): + self._fsm_dispatch(NavigateRequested(direction=-self._grid_cols)) self.navigate.emit(-self._grid_cols) return True elif key in (Qt.Key.Key_Down, Qt.Key.Key_J): + self._fsm_dispatch(NavigateRequested(direction=self._grid_cols)) self.navigate.emit(self._grid_cols) return True elif key == Qt.Key.Key_F11: + self._fsm_dispatch(FullscreenToggled()) if self.isFullScreen(): self._exit_fullscreen() else: self._enter_fullscreen() return True elif key == Qt.Key.Key_Space and self._stack.currentIndex() == 1: + self._fsm_dispatch(TogglePlayRequested()) self._video._toggle_play() return True elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1: + # +/- keys are seek-relative, NOT slider-pin seeks. The + # state machine's SeekRequested is for slider-driven + # seeks. The +/- keys go straight to mpv via the + # legacy path; the dispatch path doesn't see them in + # 14a (commit 14b will route them through SeekRequested + # with a target_ms computed from current position). self._video._seek_relative(1800) return True elif key == Qt.Key.Key_Comma and self._stack.currentIndex() == 1: @@ -768,9 +951,11 @@ class FullscreenPreview(QMainWindow): # Horizontal tilt navigates between posts on either stack tilt = event.angleDelta().x() if tilt > 30: + self._fsm_dispatch(NavigateRequested(direction=-1)) self.navigate.emit(-1) return True if tilt < -30: + self._fsm_dispatch(NavigateRequested(direction=1)) self.navigate.emit(1) return True # Vertical wheel adjusts volume on the video stack only @@ -778,6 +963,7 @@ class FullscreenPreview(QMainWindow): delta = event.angleDelta().y() if delta: vol = max(0, min(100, self._video.volume + (5 if delta > 0 else -5))) + self._fsm_dispatch(VolumeSet(value=vol)) self._video.volume = vol self._show_overlay() return True @@ -978,6 +1164,10 @@ class FullscreenPreview(QMainWindow): center_y=rect.y() + rect.height() / 2, long_side=float(max(rect.width(), rect.height())), ) + # Parallel state machine dispatch for the same event. + self._fsm_dispatch(WindowResized(rect=( + rect.x(), rect.y(), rect.width(), rect.height(), + ))) def moveEvent(self, event) -> None: super().moveEvent(event) @@ -1005,6 +1195,10 @@ class FullscreenPreview(QMainWindow): center_y=rect.y() + rect.height() / 2, long_side=self._viewport.long_side, ) + # Parallel state machine dispatch for the same event. + self._fsm_dispatch(WindowMoved(rect=( + rect.x(), rect.y(), rect.width(), rect.height(), + ))) def showEvent(self, event) -> None: super().showEvent(event) @@ -1023,6 +1217,9 @@ class FullscreenPreview(QMainWindow): def closeEvent(self, event) -> None: from PySide6.QtWidgets import QApplication + # Parallel state machine dispatch — Closing is terminal in + # the state machine, every subsequent dispatch will be a no-op. + self._fsm_dispatch(CloseRequested()) # Save window state for next open FullscreenPreview._saved_fullscreen = self.isFullScreen() if not self.isFullScreen():