pax cec93545ad 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.
2026-04-15 17:47:36 -05:00

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 the helpers in `popout/hyprland.py`.
"""
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",
]