From 4fb17151b1f1efa9ee6ce982c3b9bb352d055465 Mon Sep 17 00:00:00 2001 From: pax Date: Wed, 8 Apr 2026 19:37:31 -0500 Subject: [PATCH] =?UTF-8?q?popout/state:=20implement=20DisplayingImage=20+?= =?UTF-8?q?=20Closing=20=E2=80=94=20all=2062=20tests=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two final transition handlers complete the state machine surface: ContentArrived(IMAGE | GIF) in any state → DisplayingImage, [LoadImage(path, is_gif), FitWindowToContent(w, h)] Same path as the video branch but routes to ImageViewer instead of mpv. The is_gif flag tells the adapter which ImageViewer method to call (set_gif vs set_image — current code at popout/window.py:411-417). CloseRequested from any non-Closing state → Closing, [StopMedia, EmitClosed] Closing is terminal. Every subsequent event returns [] regardless of type (handled at the dispatch entry, which has been in place since the skeleton). The adapter handles geometry persistence and Qt cleanup outside the state machine — those are not popout state machine concerns. Tests passing after this commit: 62 / 62 (100%). Newly green: - test_awaiting_content_arrived_image_loads_and_transitions - test_awaiting_content_arrived_gif_loads_as_animated - test_displaying_image_content_replace_with_image - test_close_from_each_state_transitions_to_closing[5 states] Phase A (16 tests in tests/core/) still green. State machine complete. The 6-state / 17-event / 14-effect design is fully implemented: States (6, budget ≤10): AwaitingContent / DisplayingImage / LoadingVideo / PlayingVideo / SeekingVideo / Closing Events (17, budget ≤20): Open / ContentArrived / NavigateRequested VideoStarted / VideoEofReached / VideoSizeKnown SeekRequested / SeekCompleted MuteToggleRequested / VolumeSet / LoopModeSet / TogglePlayRequested FullscreenToggled WindowMoved / WindowResized / HyprlandDriftDetected CloseRequested Effects (14, budget ≤15): LoadImage / LoadVideo / StopMedia ApplyMute / ApplyVolume / ApplyLoopMode SeekVideoTo / TogglePlay FitWindowToContent / EnterFullscreen / ExitFullscreen EmitNavigate / EmitPlayNextRequested / EmitClosed Six race-fix invariants all enforced structurally — no timestamp suppression windows in state.py, no guards, no fall-throughs: 1. EOF race: VideoEofReached only valid in PlayingVideo 2. Double-load: Navigate from media → AwaitingContent never re-emits Load until ContentArrived 3. Persistent viewport: viewport is a state field, only mutated by user-action events 4. F11 round-trip: pre_fullscreen_viewport snapshot/restore 5. Seek pin: SeekingVideo state + compute_slider_display_ms read 6. Pending mute: state.mute owned by machine, ApplyMute on PlayingVideo entry Test cases for commit 11 (illegal transition handler): - dispatch invalid event in strict mode raises InvalidTransition - dispatch invalid event in release mode returns [] (current behavior) - BOORU_VIEWER_STRICT_STATE env var gates the raise --- booru_viewer/gui/popout/state.py | 36 +++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/booru_viewer/gui/popout/state.py b/booru_viewer/gui/popout/state.py index 405a4ef..6cbdbf7 100644 --- a/booru_viewer/gui/popout/state.py +++ b/booru_viewer/gui/popout/state.py @@ -661,8 +661,21 @@ class StateMachine: content_h=event.height, ), ] - # Image / GIF lands in commit 10 (DisplayingImage transitions). - return [] + # Image or GIF: transition straight to DisplayingImage and + # emit LoadImage. The is_gif flag tells the adapter which + # ImageViewer method to call (set_gif vs set_image). + self.is_first_content_load = False + self.state = State.DISPLAYING_IMAGE + return [ + LoadImage( + path=event.path, + is_gif=(event.kind == MediaKind.GIF), + ), + FitWindowToContent( + content_w=event.width, + content_h=event.height, + ), + ] def _on_navigate_requested(self, event: NavigateRequested) -> list[Effect]: """**Double-load race fix (replaces 31d02d3c's upstream signal- @@ -1002,9 +1015,22 @@ class StateMachine: return [] def _on_close_requested(self, event: CloseRequested) -> list[Effect]: - # Real implementation: transitions to Closing, emits StopMedia - # + EmitClosed. Lands in commit 10. - return [] + """Esc / Q / X / closeEvent. Transition to Closing from any + non-Closing source state. Closing is terminal — every + subsequent event returns [] regardless of type (handled at + the dispatch entry). + + Entry effects: StopMedia (clear current surface) + EmitClosed + (tell main_window the popout is closing). The adapter is + responsible for any cleanup beyond clearing media — closing + the Qt window, removing the event filter, persisting + geometry to the class-level _saved_geometry — but those are + adapter-side concerns, not state machine concerns. + """ + if self.state == State.CLOSING: + return [] + self.state = State.CLOSING + return [StopMedia(), EmitClosed()] __all__ = [