popout/effects: split effect descriptors into sibling module

Pure refactor: moves the 14 effect dataclasses + the Effect union type
from `state.py` into a new sibling `effects.py` module. `state.py`
imports them at the top and re-exports them via `__all__`, so the
public API of `state.py` is unchanged — every existing import in the
test suite (and any future caller) keeps working without modification.

Two reasons for the split:

1. **Conceptual clarity.** State.py is the dispatch + transition
   logic; effects.py is the data shapes the adapter consumes.
   Splitting matches the architecture target in
   docs/POPOUT_ARCHITECTURE.md and makes the effect surface
   discoverable in one file.

2. **Import-purity gate stays in place for both modules.**
   effects.py inherits the same hard constraint as state.py: no
   PySide6, mpv, httpx, or any module that imports them. Verified
   by running both modules through a meta_path import blocker that
   refuses those packages — both import cleanly.

State.py still imports from effects.py via the standard
`from .effects import LoadImage, LoadVideo, ...` block. The dispatch
handlers continue to instantiate effect descriptors inline; only the
class definitions moved.

Files changed:
  - NEW: booru_viewer/gui/popout/effects.py (~190 lines)
  - MOD: booru_viewer/gui/popout/state.py (effect dataclasses
    removed, import block added — net ~150 lines removed)

Tests passing after this commit: 65 / 65 (no change).
Phase A (16 tests in tests/core/) still green.

Test cases for commit 13 (hyprland.py extraction):
  - import popout.hyprland and call helpers
  - app launches with the shimmed window.py still using the helpers
This commit is contained in:
pax 2026-04-08 19:41:57 -05:00
parent 3ade3a71c1
commit 06f8f3d752
2 changed files with 222 additions and 159 deletions

View File

@ -0,0 +1,201 @@
"""Effect descriptors for the popout state machine.
Pure-Python frozen dataclasses describing what the Qt-side adapter
should do in response to a state machine dispatch. The state machine
in `popout/state.py` returns a list of these from each `dispatch()`
call; the adapter pattern-matches by type and applies them in order.
**Hard constraint**: this module MUST NOT import anything from
PySide6, mpv, httpx, subprocess, or any module that does. Same purity
gate as `state.py` the test suite imports both directly without
standing up a QApplication.
The effect types are documented in detail in
`docs/POPOUT_ARCHITECTURE.md` "Effects" section.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Union
# ----------------------------------------------------------------------
# Media-control effects
# ----------------------------------------------------------------------
@dataclass(frozen=True)
class LoadImage:
"""Display a static image or animated GIF. The adapter routes by
`is_gif`: True ImageViewer.set_gif, False set_image.
"""
path: str
is_gif: bool
@dataclass(frozen=True)
class LoadVideo:
"""Hand a path or URL to mpv via `VideoPlayer.play_file`. If
`referer` is set, the adapter passes it to play_file's per-file
referrer option (current behavior at media/video_player.py:343-347).
"""
path: str
info: str
referer: Optional[str] = None
@dataclass(frozen=True)
class StopMedia:
"""Clear both surfaces (image viewer and video player). Used on
navigation away from current media and on close.
"""
@dataclass(frozen=True)
class ApplyMute:
"""Push `state.mute` to mpv. Adapter calls
`self._video.is_muted = value` which goes through VideoPlayer's
setter (which already handles the lazy-mpv case via _pending_mute
as defense in depth).
"""
value: bool
@dataclass(frozen=True)
class ApplyVolume:
"""Push `state.volume` to mpv via the existing
`VideoPlayer.volume = value` setter (which writes through the
slider widget, which is the persistent storage).
"""
value: int
@dataclass(frozen=True)
class ApplyLoopMode:
"""Push `state.loop_mode` to mpv via the existing
`VideoPlayer.loop_state = value` setter.
"""
value: int # LoopMode.value, kept as int for cross-process portability
@dataclass(frozen=True)
class SeekVideoTo:
"""Adapter calls `mpv.seek(target_ms / 1000.0, 'absolute')`. Note
the use of plain 'absolute' (keyframe seek), not 'absolute+exact'
matches the current slider behavior at video_player.py:405. The
seek pin behavior is independent: the slider shows
`state.seek_target_ms` while in SeekingVideo, regardless of mpv's
keyframe-rounded actual position.
"""
target_ms: int
@dataclass(frozen=True)
class TogglePlay:
"""Toggle mpv's `pause` property. Adapter calls
`VideoPlayer._toggle_play()`.
"""
# ----------------------------------------------------------------------
# Window/geometry effects
# ----------------------------------------------------------------------
@dataclass(frozen=True)
class FitWindowToContent:
"""Compute the new window rect for the given content aspect using
`state.viewport` and dispatch it to Hyprland (or `setGeometry()`
on non-Hyprland). The adapter delegates the rect math + dispatch
to `popout/hyprland.py`'s helper, which lands in commit 13.
"""
content_w: int
content_h: int
@dataclass(frozen=True)
class EnterFullscreen:
"""Adapter calls `self.showFullScreen()`."""
@dataclass(frozen=True)
class ExitFullscreen:
"""Adapter calls `self.showNormal()` then defers a
FitWindowToContent on the next event-loop tick (matching the
current `QTimer.singleShot(0, ...)` pattern at
popout/window.py:1023).
"""
# ----------------------------------------------------------------------
# Outbound signal effects
# ----------------------------------------------------------------------
@dataclass(frozen=True)
class EmitNavigate:
"""Tell main_window to navigate to the next/previous post.
Adapter emits `self.navigate.emit(direction)`.
"""
direction: int
@dataclass(frozen=True)
class EmitPlayNextRequested:
"""Tell main_window the video ended in Loop=Next mode. Adapter
emits `self.play_next_requested.emit()`.
"""
@dataclass(frozen=True)
class EmitClosed:
"""Tell main_window the popout is closing. Fired on entry to
Closing state. Adapter emits `self.closed.emit()`.
"""
# Type alias for the union of all effects.
Effect = Union[
LoadImage,
LoadVideo,
StopMedia,
ApplyMute,
ApplyVolume,
ApplyLoopMode,
SeekVideoTo,
TogglePlay,
FitWindowToContent,
EnterFullscreen,
ExitFullscreen,
EmitNavigate,
EmitPlayNextRequested,
EmitClosed,
]
__all__ = [
"LoadImage",
"LoadVideo",
"StopMedia",
"ApplyMute",
"ApplyVolume",
"ApplyLoopMode",
"SeekVideoTo",
"TogglePlay",
"FitWindowToContent",
"EnterFullscreen",
"ExitFullscreen",
"EmitNavigate",
"EmitPlayNextRequested",
"EmitClosed",
"Effect",
]

View File

@ -32,6 +32,23 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Optional, Union from typing import Optional, Union
from .effects import (
ApplyLoopMode,
ApplyMute,
ApplyVolume,
Effect,
EmitClosed,
EmitNavigate,
EmitPlayNextRequested,
EnterFullscreen,
ExitFullscreen,
FitWindowToContent,
LoadImage,
LoadVideo,
SeekVideoTo,
StopMedia,
TogglePlay,
)
from .viewport import Viewport from .viewport import Viewport
@ -320,165 +337,10 @@ Event = Union[
# Effects # Effects
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# #
# Effects are descriptors of what the adapter should do. The dispatcher # Effect descriptors live in the sibling `effects.py` module — see the
# returns a list of these from each `dispatch()` call. The adapter # import block at the top of this file. They're re-exported here via
# pattern-matches by type and applies them in order. # `__all__` so callers can `from booru_viewer.gui.popout.state import
# LoadImage` without needing to know the file split.
# -- Media-control effects --
@dataclass(frozen=True)
class LoadImage:
"""Display a static image or animated GIF. The adapter routes by
`is_gif`: True ImageViewer.set_gif, False set_image.
"""
path: str
is_gif: bool
@dataclass(frozen=True)
class LoadVideo:
"""Hand a path or URL to mpv via `VideoPlayer.play_file`. If
`referer` is set, the adapter passes it to play_file's per-file
referrer option (current behavior at media/video_player.py:343-347).
"""
path: str
info: str
referer: Optional[str] = None
@dataclass(frozen=True)
class StopMedia:
"""Clear both surfaces (image viewer and video player). Used on
navigation away from current media and on close.
"""
@dataclass(frozen=True)
class ApplyMute:
"""Push `state.mute` to mpv. Adapter calls
`self._video.is_muted = value` which goes through VideoPlayer's
setter (which already handles the lazy-mpv case via _pending_mute
as defense in depth).
"""
value: bool
@dataclass(frozen=True)
class ApplyVolume:
"""Push `state.volume` to mpv via the existing
`VideoPlayer.volume = value` setter (which writes through the
slider widget, which is the persistent storage).
"""
value: int
@dataclass(frozen=True)
class ApplyLoopMode:
"""Push `state.loop_mode` to mpv via the existing
`VideoPlayer.loop_state = value` setter.
"""
value: int # LoopMode.value, kept as int for cross-process portability
@dataclass(frozen=True)
class SeekVideoTo:
"""Adapter calls `mpv.seek(target_ms / 1000.0, 'absolute')`. Note
the use of plain 'absolute' (keyframe seek), not 'absolute+exact'
matches the current slider behavior at video_player.py:405. The
seek pin behavior is independent: the slider shows
`state.seek_target_ms` while in SeekingVideo, regardless of mpv's
keyframe-rounded actual position.
"""
target_ms: int
@dataclass(frozen=True)
class TogglePlay:
"""Toggle mpv's `pause` property. Adapter calls
`VideoPlayer._toggle_play()`.
"""
# -- Window/geometry effects --
@dataclass(frozen=True)
class FitWindowToContent:
"""Compute the new window rect for the given content aspect using
`state.viewport` and dispatch it to Hyprland (or `setGeometry()`
on non-Hyprland). The adapter delegates the rect math + dispatch
to `popout/hyprland.py`'s helper, which lands in commit 13.
"""
content_w: int
content_h: int
@dataclass(frozen=True)
class EnterFullscreen:
"""Adapter calls `self.showFullScreen()`."""
@dataclass(frozen=True)
class ExitFullscreen:
"""Adapter calls `self.showNormal()` then defers a
FitWindowToContent on the next event-loop tick (matching the
current `QTimer.singleShot(0, ...)` pattern at
popout/window.py:1023).
"""
# -- Outbound signal effects --
@dataclass(frozen=True)
class EmitNavigate:
"""Tell main_window to navigate to the next/previous post.
Adapter emits `self.navigate.emit(direction)`.
"""
direction: int
@dataclass(frozen=True)
class EmitPlayNextRequested:
"""Tell main_window the video ended in Loop=Next mode. Adapter
emits `self.play_next_requested.emit()`.
"""
@dataclass(frozen=True)
class EmitClosed:
"""Tell main_window the popout is closing. Fired on entry to
Closing state. Adapter emits `self.closed.emit()`.
"""
# Type alias for the union of all effects.
Effect = Union[
LoadImage,
LoadVideo,
StopMedia,
ApplyMute,
ApplyVolume,
ApplyLoopMode,
SeekVideoTo,
TogglePlay,
FitWindowToContent,
EnterFullscreen,
ExitFullscreen,
EmitNavigate,
EmitPlayNextRequested,
EmitClosed,
]
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------