popout: drop in-flight-refactor language from docstrings

During the state machine extraction every comment that referenced
a specific commit in the plan (skeleton / 14a / 14b / 'future
commit') was useful — it told you which commit a line appeared
in and what was about to change. Once the refactor landed those
notes became noise: they describe history nobody needs while
reading the current code.

Rewrites keep the rationale (no-op handlers still explain WHY
they're no-ops, Loop=Next / video auto-fit still have their
explanations) and preserves the load-bearing commit 14b reference
in _dispatch_and_apply's docstring — that one actually does
protect future-you from reintroducing the bug-by-typo pattern.
This commit is contained in:
pax 2026-04-15 17:47:36 -05:00
parent 9ec034f7ef
commit cec93545ad
4 changed files with 89 additions and 125 deletions

View File

@ -114,7 +114,7 @@ class FitWindowToContent:
"""Compute the new window rect for the given content aspect using """Compute the new window rect for the given content aspect using
`state.viewport` and dispatch it to Hyprland (or `setGeometry()` `state.viewport` and dispatch it to Hyprland (or `setGeometry()`
on non-Hyprland). The adapter delegates the rect math + dispatch on non-Hyprland). The adapter delegates the rect math + dispatch
to `popout/hyprland.py`'s helper, which lands in commit 13. to the helpers in `popout/hyprland.py`.
""" """
content_w: int content_w: int

View File

@ -11,11 +11,11 @@ behind the same `HYPRLAND_INSTANCE_SIGNATURE` env var check the
legacy code used. Off-Hyprland systems no-op or return None at every legacy code used. Off-Hyprland systems no-op or return None at every
entry point. entry point.
The legacy `FullscreenPreview._hyprctl_*` methods become 1-line The popout adapter calls these helpers directly; there are no
shims that call into this module see commit 13's changes to `FullscreenPreview._hyprctl_*` shims anymore. Every env-var gate
`popout/window.py`. The shims preserve byte-for-byte call-site for opt-out (`BOORU_VIEWER_NO_HYPR_RULES`, popout-specific aspect
compatibility for the existing window.py code; commit 14's adapter lock) is implemented inside these functions so every call site
rewrite drops them in favor of direct calls. gets the same behavior.
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -16,12 +16,6 @@ becomes the forcing function that keeps this module pure.
The architecture, state diagram, invarianttransition mapping, and The architecture, state diagram, invarianttransition mapping, and
event/effect lists are documented in `docs/POPOUT_ARCHITECTURE.md`. event/effect lists are documented in `docs/POPOUT_ARCHITECTURE.md`.
This module's job is to be the executable form of that document. This module's job is to be the executable form of that document.
This is the **commit 2 skeleton**: every state, every event type, every
effect type, and the `StateMachine` class with all fields initialized.
The `dispatch` method routes events to per-event handlers that all
currently return empty effect lists. Real transitions land in
commits 4-11 of `docs/POPOUT_REFACTOR_PLAN.md`.
""" """
from __future__ import annotations from __future__ import annotations
@ -423,10 +417,6 @@ class StateMachine:
The state machine never imports Qt or mpv. It never calls into the The state machine never imports Qt or mpv. It never calls into the
adapter. The communication is one-directional: events in, effects adapter. The communication is one-directional: events in, effects
out. out.
**This is the commit 2 skeleton**: all state fields are initialized,
`dispatch` is wired but every transition handler is a stub that
returns an empty effect list. Real transitions land in commits 4-11.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@ -511,14 +501,7 @@ class StateMachine:
# and reads back the returned effects + the post-dispatch state. # and reads back the returned effects + the post-dispatch state.
def dispatch(self, event: Event) -> list[Effect]: def dispatch(self, event: Event) -> list[Effect]:
"""Process one event and return the effect list. """Process one event and return the effect list."""
**Skeleton (commit 2):** every event handler currently returns
an empty effect list. Real transitions land in commits 4-11.
Tests written in commit 3 will document what each transition
is supposed to do; they fail at this point and progressively
pass as the transitions land.
"""
# Closing is terminal — drop everything once we're done. # Closing is terminal — drop everything once we're done.
if self.state == State.CLOSING: if self.state == State.CLOSING:
return [] return []
@ -577,13 +560,13 @@ class StateMachine:
case CloseRequested(): case CloseRequested():
return self._on_close_requested(event) return self._on_close_requested(event)
case _: case _:
# Unknown event type. Returning [] keeps the skeleton # Unknown event type — defensive fall-through. The
# safe; the illegal-transition handler in commit 11 # legality check above is the real gate; in release
# will replace this with the env-gated raise. # mode illegal events log and drop, strict mode raises.
return [] return []
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Per-event stub handlers (commit 2 — all return []) # Per-event handlers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _on_open(self, event: Open) -> list[Effect]: def _on_open(self, event: Open) -> list[Effect]:
@ -594,8 +577,7 @@ class StateMachine:
on the state machine instance for the first ContentArrived on the state machine instance for the first ContentArrived
handler to consume. After Open the machine is still in handler to consume. After Open the machine is still in
AwaitingContent the actual viewport seeding from saved_geo AwaitingContent the actual viewport seeding from saved_geo
happens inside the first ContentArrived (commit 8 wires the happens inside the first ContentArrived.
actual viewport math; this commit just stashes the inputs).
No effects: the popout window is already constructed and No effects: the popout window is already constructed and
showing. The first content load triggers the first fit. showing. The first content load triggers the first fit.
@ -610,12 +592,11 @@ class StateMachine:
Snapshot the content into `current_*` fields regardless of Snapshot the content into `current_*` fields regardless of
kind so the rest of the state machine can read them. Then kind so the rest of the state machine can read them. Then
transition to LoadingVideo (video) or DisplayingImage (image, transition to LoadingVideo (video) or DisplayingImage (image)
commit 10) and emit the appropriate load + fit effects. and emit the appropriate load + fit effects.
The first-content-load one-shot consumes `saved_geo` to seed The first-content-load one-shot consumes `saved_geo` to seed
the viewport before the first fit (commit 8 wires the actual the viewport before the first fit. Every ContentArrived flips
seeding). After this commit, every ContentArrived flips
`is_first_content_load` to False the saved_geo path runs at `is_first_content_load` to False the saved_geo path runs at
most once per popout open. most once per popout open.
""" """

View File

@ -68,9 +68,8 @@ from .viewport import Viewport, _DRIFT_TOLERANCE, anchor_point
# the dispatch trace to the Ctrl+L log panel — useful but invisible # the dispatch trace to the Ctrl+L log panel — useful but invisible
# from the shell. We additionally attach a stderr StreamHandler to # from the shell. We additionally attach a stderr StreamHandler to
# the adapter logger so `python -m booru_viewer.main_gui 2>&1 | # the adapter logger so `python -m booru_viewer.main_gui 2>&1 |
# grep POPOUT_FSM` works during the commit-14a verification gate. # grep POPOUT_FSM` works from the terminal. The handler is tagged
# The handler is tagged with a sentinel attribute so re-imports # with a sentinel attribute so re-imports don't stack duplicates.
# don't stack duplicates.
import sys as _sys import sys as _sys
_fsm_log = logging.getLogger("booru.popout.adapter") _fsm_log = logging.getLogger("booru.popout.adapter")
_fsm_log.setLevel(logging.DEBUG) _fsm_log.setLevel(logging.DEBUG)
@ -146,25 +145,20 @@ class FullscreenPreview(QMainWindow):
self._stack.addWidget(self._viewer) self._stack.addWidget(self._viewer)
self._video = VideoPlayer() self._video = VideoPlayer()
# Note: two legacy VideoPlayer signal connections removed in # Two legacy VideoPlayer forwarding connections were removed
# commits 14b and 16: # during the state machine extraction — don't reintroduce:
# #
# - `self._video.play_next.connect(self.play_next_requested)` # - `self._video.play_next.connect(self.play_next_requested)`:
# (removed in 14b): the EmitPlayNextRequested effect now # the EmitPlayNextRequested effect emits play_next_requested
# emits play_next_requested via the state machine dispatch # via the state machine dispatch path. Keeping the forward
# path. Keeping the forwarding would double-emit the signal # would double-emit on every video EOF in Loop=Next mode.
# and cause main_window to navigate twice on every video
# EOF in Loop=Next mode.
# #
# - `self._video.video_size.connect(self._on_video_size)` # - `self._video.video_size.connect(self._on_video_size)`:
# (removed in 16): the dispatch path's VideoSizeKnown # the dispatch path's VideoSizeKnown handler produces
# handler emits FitWindowToContent which the apply path # FitWindowToContent which the apply path delegates to
# delegates to _fit_to_content. The legacy direct call to # _fit_to_content. The direct forwarding was a parallel
# _on_video_size → _fit_to_content was a parallel duplicate # duplicate that same-rect-skip in _fit_to_content masked
# that the same-rect skip in _fit_to_content made harmless, # but that muddied the dispatch trace.
# but it muddied the trace. The dispatch lambda below is
# wired in the same __init__ block (post state machine
# construction) and is now the sole path.
self._stack.addWidget(self._video) self._stack.addWidget(self._video)
self.setCentralWidget(central) self.setCentralWidget(central)
@ -374,17 +368,15 @@ class FullscreenPreview(QMainWindow):
else: else:
self.showFullScreen() self.showFullScreen()
# ---- State machine adapter wiring (commit 14a) ---- # ---- State machine adapter wiring ----
# Construct the pure-Python state machine and dispatch the # Construct the pure-Python state machine and dispatch the
# initial Open event with the cross-popout-session class state # initial Open event with the cross-popout-session class state
# the legacy code stashed above. The state machine runs in # the legacy code stashed above. Every Qt event handler, mpv
# PARALLEL with the legacy imperative code: every Qt event # signal, and button click below dispatches a state machine
# handler / mpv signal / button click below dispatches a state # event via `_dispatch_and_apply`, which applies the returned
# machine event AND continues to run the existing imperative # effects to widgets. The state machine is the authority for
# action. The state machine's returned effects are LOGGED at # "what to do next"; the imperative helpers below are the
# DEBUG, not applied to widgets. The legacy path stays # implementation the apply path delegates into.
# authoritative through commit 14a; commit 14b switches the
# authority to the dispatch path.
# #
# The grid_cols field is used by the keyboard nav handlers # The grid_cols field is used by the keyboard nav handlers
# for the Up/Down ±cols stride. # for the Up/Down ±cols stride.
@ -403,20 +395,17 @@ class FullscreenPreview(QMainWindow):
monitor=monitor, monitor=monitor,
)) ))
# Wire VideoPlayer's playback_restart Signal (added in commit 1) # Wire VideoPlayer's playback_restart Signal to the adapter's
# to the adapter's dispatch routing. mpv emits playback-restart # dispatch routing. mpv emits playback-restart once after each
# once after each loadfile and once after each completed seek; # loadfile and once after each completed seek; the adapter
# the adapter distinguishes by checking the state machine's # distinguishes by checking the state machine's current state
# current state at dispatch time. # at dispatch time.
self._video.playback_restart.connect(self._on_video_playback_restart) self._video.playback_restart.connect(self._on_video_playback_restart)
# Wire VideoPlayer signals to dispatch+apply via the # Wire VideoPlayer signals to dispatch+apply via the
# _dispatch_and_apply helper. NOTE: every lambda below MUST # _dispatch_and_apply helper. Every lambda below MUST call
# call _dispatch_and_apply, not _fsm_dispatch directly. Calling # _dispatch_and_apply, not _fsm_dispatch directly — see the
# _fsm_dispatch alone produces effects that never reach # docstring on _dispatch_and_apply for the historical bug that
# widgets — the bug that landed in commit 14b and broke # explains the distinction.
# video auto-fit (FitWindowToContent never applied) and
# Loop=Next play_next (EmitPlayNextRequested never applied)
# until the lambdas were fixed in this commit.
self._video.play_next.connect( self._video.play_next.connect(
lambda: self._dispatch_and_apply(VideoEofReached()) lambda: self._dispatch_and_apply(VideoEofReached())
) )
@ -465,8 +454,8 @@ class FullscreenPreview(QMainWindow):
Adapter-internal helper. Centralizes the dispatch + log path Adapter-internal helper. Centralizes the dispatch + log path
so every wire-point is one line. Returns the effect list for so every wire-point is one line. Returns the effect list for
callers that want to inspect it (commit 14a doesn't use the callers that want to inspect it; prefer `_dispatch_and_apply`
return value; commit 14b will pattern-match and apply). at wire-points so the apply step can't be forgotten.
The hasattr guard handles edge cases where Qt events might The hasattr guard handles edge cases where Qt events might
fire during __init__ (e.g. resizeEvent on the first show()) fire during __init__ (e.g. resizeEvent on the first show())
@ -488,10 +477,10 @@ class FullscreenPreview(QMainWindow):
return effects return effects
def _on_video_playback_restart(self) -> None: def _on_video_playback_restart(self) -> None:
"""mpv `playback-restart` event arrived (via VideoPlayer's """mpv `playback-restart` event arrived via VideoPlayer's
playback_restart Signal added in commit 1). Distinguish playback_restart Signal. Distinguish VideoStarted (after load)
VideoStarted (after load) from SeekCompleted (after seek) by from SeekCompleted (after seek) by the state machine's current
the state machine's current state. state.
This is the ONE place the adapter peeks at state to choose an 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 event type it's a read, not a write, and it's the price of
@ -508,42 +497,35 @@ class FullscreenPreview(QMainWindow):
# round trip. # round trip.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Commit 14b — effect application # Effect application
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# #
# The state machine's dispatch returns a list of Effect descriptors # The state machine's dispatch returns a list of Effect descriptors
# describing what the adapter should do. `_apply_effects` is the # describing what the adapter should do. `_apply_effects` is the
# single dispatch point: every wire-point that calls `_fsm_dispatch` # single dispatch point: `_dispatch_and_apply` dispatches then calls
# follows it with `_apply_effects(effects)`. The pattern-match by # this. The pattern-match by type is the architectural choke point
# type is the architectural choke point — if a new effect type is # — a new Effect type in state.py triggers the TypeError branch at
# added in state.py, the type-check below catches the missing # runtime instead of silently dropping the effect.
# handler at runtime instead of silently dropping.
# #
# Several apply handlers are deliberate no-ops in commit 14b: # A few apply handlers are intentional no-ops:
# #
# - ApplyMute / ApplyVolume / ApplyLoopMode: the legacy slot # - ApplyMute / ApplyVolume / ApplyLoopMode: the legacy slot
# connections on the popout's VideoPlayer are still active and # connections on the popout's VideoPlayer handle the user-facing
# handle the user-facing toggles directly. The state machine # toggles directly. The state machine tracks these values as the
# tracks these values for the upcoming SyncFromEmbedded path # source of truth for sync with the embedded preview; pushing
# (future commit) but doesn't push them to widgets — pushing # them back here would create a double-write hazard.
# would create a sync hazard with the embedded preview's mute
# state, which main_window pushes via direct attribute writes.
# #
# - SeekVideoTo: the legacy `_ClickSeekSlider.clicked_position → # - SeekVideoTo: `_ClickSeekSlider.clicked_position → _seek` on the
# VideoPlayer._seek` connection still handles both the mpv.seek # VideoPlayer handles both the mpv.seek call and the legacy
# call and the legacy 500ms `_seek_pending_until` pin window. # 500ms pin window. The state machine's SeekingVideo state
# The state machine's SeekingVideo state tracks the seek for # tracks the seek; the slider rendering and the seek call itself
# future authority, but the slider rendering and the seek call # live on VideoPlayer.
# itself stay legacy. Replacing this requires either modifying
# VideoPlayer's _poll loop (forbidden by the no-touch rule) or
# building a custom poll loop in the adapter.
# #
# The other effect types (LoadImage, LoadVideo, StopMedia, # Every other effect (LoadImage, LoadVideo, StopMedia,
# FitWindowToContent, EnterFullscreen, ExitFullscreen, # FitWindowToContent, EnterFullscreen, ExitFullscreen,
# EmitNavigate, EmitPlayNextRequested, EmitClosed, TogglePlay) # EmitNavigate, EmitPlayNextRequested, EmitClosed, TogglePlay)
# delegate to existing private helpers in this file. The state # delegates to a private helper in this file. The state machine
# machine becomes the official entry point for these operations; # is the entry point; the helpers are the implementation.
# the helpers stay in place as the implementation.
def _apply_effects(self, effects: list) -> None: def _apply_effects(self, effects: list) -> None:
"""Apply a list of Effect descriptors returned by dispatch. """Apply a list of Effect descriptors returned by dispatch.
@ -560,18 +542,19 @@ class FullscreenPreview(QMainWindow):
elif isinstance(e, StopMedia): elif isinstance(e, StopMedia):
self._apply_stop_media() self._apply_stop_media()
elif isinstance(e, ApplyMute): elif isinstance(e, ApplyMute):
# No-op in 14b — legacy slot handles widget update. # No-op — VideoPlayer's legacy slot owns widget update;
# State machine tracks state.mute for future authority. # the state machine keeps state.mute as the sync source
# for the embedded-preview path.
pass pass
elif isinstance(e, ApplyVolume): elif isinstance(e, ApplyVolume):
pass # same — no-op in 14b pass # same — widget update handled by VideoPlayer
elif isinstance(e, ApplyLoopMode): elif isinstance(e, ApplyLoopMode):
pass # same — no-op in 14b pass # same — widget update handled by VideoPlayer
elif isinstance(e, SeekVideoTo): elif isinstance(e, SeekVideoTo):
# No-op in 14b legacy `_seek` slot handles both # No-op — `_seek` slot on VideoPlayer handles both
# mpv.seek (now exact) and the pin window. Replacing # mpv.seek and the pin window. The state's SeekingVideo
# this requires touching VideoPlayer._poll which is # fields exist so the slider's read-path still returns
# out of scope. # the clicked position during the seek.
pass pass
elif isinstance(e, TogglePlay): elif isinstance(e, TogglePlay):
self._video._toggle_play() self._video._toggle_play()
@ -687,14 +670,14 @@ class FullscreenPreview(QMainWindow):
self._save_btn.setToolTip("Unsave from library" if saved else "Save to library (S)") self._save_btn.setToolTip("Unsave from library" if saved else "Save to library (S)")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public method interface (commit 15) # Public method interface
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# #
# The methods below replace direct underscore access from # The methods below are the only entry points main_window.py uses
# main_window.py. They wrap the existing private fields so # to drive the popout. They wrap the private fields so main_window
# main_window doesn't have to know about VideoPlayer / ImageViewer # doesn't have to know about VideoPlayer / ImageViewer /
# / QStackedWidget internals. The legacy private fields stay in # QStackedWidget internals. The private fields stay in place; these
# place — these are clean public wrappers, not a re-architecture. # are clean public wrappers, not a re-architecture.
def is_video_active(self) -> bool: def is_video_active(self) -> bool:
"""True if the popout is currently showing a video (vs image). """True if the popout is currently showing a video (vs image).
@ -1489,11 +1472,11 @@ class FullscreenPreview(QMainWindow):
return True return True
elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1: elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1:
# +/- keys are seek-relative, NOT slider-pin seeks. The # +/- keys are seek-relative, NOT slider-pin seeks. The
# state machine's SeekRequested is for slider-driven # state machine's SeekRequested models slider-driven
# seeks. The +/- keys go straight to mpv via the # seeks (target_ms known up front); relative seeks go
# legacy path; the dispatch path doesn't see them in # straight to mpv. If we ever want the dispatch path to
# 14a (commit 14b will route them through SeekRequested # own them, compute target_ms from current position and
# with a target_ms computed from current position). # route through SeekRequested.
self._video._seek_relative(1800) self._video._seek_relative(1800)
return True return True
elif key == Qt.Key.Key_Comma and self._stack.currentIndex() == 1: elif key == Qt.Key.Key_Comma and self._stack.currentIndex() == 1: