From a9ce01e6c1f60910ec6ef4acd570148189b4c337 Mon Sep 17 00:00:00 2001 From: pax Date: Wed, 8 Apr 2026 19:33:17 -0500 Subject: [PATCH] popout/state: implement Navigating + AwaitingContent + double-load fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- booru_viewer/gui/popout/state.py | 50 ++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/booru_viewer/gui/popout/state.py b/booru_viewer/gui/popout/state.py index 6fa10b2..c8cfead 100644 --- a/booru_viewer/gui/popout/state.py +++ b/booru_viewer/gui/popout/state.py @@ -609,9 +609,22 @@ class StateMachine: # ------------------------------------------------------------------ def _on_open(self, event: Open) -> list[Effect]: - # Real implementation: stash saved_geo / saved_fullscreen / - # monitor on self for the first ContentArrived to consume. - # Lands in commit 5. + """Initial popout-open event from the adapter. + + Stashes the cross-popout-session class-level state + (`_saved_geometry`, `_saved_fullscreen`, the chosen monitor) + on the state machine instance for the first ContentArrived + handler to consume. After Open the machine is still in + AwaitingContent — the actual viewport seeding from saved_geo + happens inside the first ContentArrived (commit 8 wires the + actual viewport math; this commit just stashes the inputs). + + No effects: the popout window is already constructed and + showing. The first content load triggers the first fit. + """ + self.saved_geo = event.saved_geo + self.saved_fullscreen = event.saved_fullscreen + self.monitor = event.monitor return [] def _on_content_arrived(self, event: ContentArrived) -> list[Effect]: @@ -652,9 +665,34 @@ class StateMachine: return [] def _on_navigate_requested(self, event: NavigateRequested) -> list[Effect]: - # Real implementation: emits StopMedia + EmitNavigate, - # transitions to AwaitingContent. Lands in commit 5. - return [] + """**Double-load race fix (replaces 31d02d3c's upstream signal- + chain trust fix at the popout layer).** + + From a media-bearing state (DisplayingImage / LoadingVideo / + PlayingVideo / SeekingVideo): transition to AwaitingContent + and emit `[StopMedia, EmitNavigate]`. The StopMedia clears the + current surface so mpv doesn't keep playing the previous video + during the async download wait. The EmitNavigate tells + main_window to advance selection and eventually deliver the + new content via ContentArrived. + + From AwaitingContent itself (rapid Right-arrow spam, second + nav before main_window has delivered): emit EmitNavigate + ALONE — no StopMedia, because there's nothing to stop. The + state stays AwaitingContent. **The state machine never + produces two LoadVideo / LoadImage effects for the same + navigation cycle, no matter how many NavigateRequested events + the user fires off.** That structural property is what makes + the eof race impossible at the popout layer. + """ + if self.state == State.AWAITING_CONTENT: + return [EmitNavigate(direction=event.direction)] + # Media-bearing state: clear current media + emit nav + self.state = State.AWAITING_CONTENT + return [ + StopMedia(), + EmitNavigate(direction=event.direction), + ] def _on_video_started(self, event: VideoStarted) -> list[Effect]: """LoadingVideo → PlayingVideo. Persistence effects fire here.