popout/state: implement DisplayingImage + Closing — all 62 tests pass

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
This commit is contained in:
pax 2026-04-08 19:37:31 -05:00
parent 527cb3489b
commit 4fb17151b1

View File

@ -661,8 +661,21 @@ class StateMachine:
content_h=event.height, content_h=event.height,
), ),
] ]
# Image / GIF lands in commit 10 (DisplayingImage transitions). # Image or GIF: transition straight to DisplayingImage and
return [] # 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]: def _on_navigate_requested(self, event: NavigateRequested) -> list[Effect]:
"""**Double-load race fix (replaces 31d02d3c's upstream signal- """**Double-load race fix (replaces 31d02d3c's upstream signal-
@ -1002,9 +1015,22 @@ class StateMachine:
return [] return []
def _on_close_requested(self, event: CloseRequested) -> list[Effect]: def _on_close_requested(self, event: CloseRequested) -> list[Effect]:
# Real implementation: transitions to Closing, emits StopMedia """Esc / Q / X / closeEvent. Transition to Closing from any
# + EmitClosed. Lands in commit 10. 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 [] return []
self.state = State.CLOSING
return [StopMedia(), EmitClosed()]
__all__ = [ __all__ = [