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
202 lines
5.1 KiB
Python
202 lines
5.1 KiB
Python
"""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",
|
|
]
|