5 Commits

Author SHA1 Message Date
pax
45e6042ebb popout/window: wire eventFilter to StateMachine.dispatch (parallel)
**Commit 14a of the pre-emptive 14a/14b split.**

Adds the popout's pure-Python state machine as a parallel side
channel to the legacy imperative event handling. The state machine
runs alongside the existing code: every Qt event handler / mpv
signal / button click below dispatches a state machine event AND
continues to run the existing imperative action. The state machine's
returned effects are LOGGED at DEBUG, not applied to widgets.

**The legacy path stays authoritative through commit 14a; commit
14b switches the authority to the dispatch path.**

This is the bisect-safe-by-construction split the refactor plan
called for. 197 lines added, 0 removed. No widget side effects from
the dispatch path. App is byte-identical from the user's perspective.

Wired wire-points (every Qt event the state machine cares about):

  __init__:
    - Constructs StateMachine, sets grid_cols
    - Dispatches Open(saved_geo, saved_fullscreen, monitor) using
      the class-level cross-popout-session state
    - Connects VideoPlayer.playback_restart Signal (added in
      commit 1) to _on_video_playback_restart, which routes to
      VideoStarted (LoadingVideo) or SeekCompleted (SeekingVideo)
      based on current state machine state
    - Connects VideoPlayer.play_next → VideoEofReached dispatch
    - Connects VideoPlayer.video_size → VideoSizeKnown dispatch
    - Connects VideoPlayer._seek_slider.clicked_position → SeekRequested
    - Connects VideoPlayer._mute_btn.clicked → MuteToggleRequested
    - Connects VideoPlayer._vol_slider.valueChanged → VolumeSet
    - Connects VideoPlayer._loop_btn.clicked → LoopModeSet

  set_media:
    - Detects MediaKind from is_video / .gif suffix
    - Builds referer for streaming URLs
    - Dispatches ContentArrived(path, info, kind, width, height, referer)
      BEFORE the legacy imperative load path runs

  eventFilter (key + wheel):
    - Esc/Q → CloseRequested
    - Left/H → NavigateRequested(-1)
    - Right/L → NavigateRequested(+1)
    - Up/K → NavigateRequested(-grid_cols)
    - Down/J → NavigateRequested(+grid_cols)
    - F11 → FullscreenToggled
    - Space (video) → TogglePlayRequested
    - Wheel horizontal tilt → NavigateRequested(±1)
    - Wheel vertical (video) → VolumeSet(new_value)
    - Period/Comma keys (relative seek) explicitly NOT dispatched —
      they go straight to mpv via the legacy path. The state
      machine's SeekRequested is for slider-driven seeks; commit 14b
      will route the relative-seek keys through SeekRequested with
      a target_ms computed from current position.

  resizeEvent (non-Hyprland branch):
    - WindowResized(rect) dispatched after the legacy viewport update

  moveEvent (non-Hyprland branch):
    - WindowMoved(rect) dispatched after the legacy viewport update

  closeEvent:
    - CloseRequested dispatched at entry

The _fsm_dispatch helper centralizes the dispatch + log path so every
wire-point is one line. Logs at DEBUG level via a new
`booru.popout.adapter` logger:

    POPOUT_FSM <event_name> | <old_state> -> <new_state> | effects=[...]

Filter the log output by `POPOUT_FSM` substring to see only the
state machine activity during the manual sweep.

The _on_video_playback_restart helper is the ONE place the adapter
peeks at state machine state to choose between two event types
(VideoStarted vs SeekCompleted from the same mpv playback-restart
event). It's a read, not a write — the state machine's dispatch
remains the only mutation point.

Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green.

**Verification gate (next):**

Before commit 14b lands, the user runs the popout in their own
interactive Hyprland session and walks through the 11 race scenarios:

  1. P↔L navigation cycles drift toward corner
  2. Super+drag externally then nav
  3. Corner-resize externally then nav
  4. F11 same-aspect round-trip
  5. F11 across-aspect round-trip
  6. First-open from saved geometry
  7. Restart persistence across app sessions
  8. Rapid Right-arrow spam
  9. Uncached video click
  10. Mute toggle before mpv exists
  11. Seek mid-playback

For each scenario, capture the POPOUT_FSM log lines and verify the
state machine's dispatch sequence matches what the legacy path
actually did. Any discrepancy is a state machine logic bug that
must be fixed in state.py BEFORE 14b lands and switches authority
to the dispatch path. Fix in state.py, not in window.py — state.py
is still the source of truth.

The bisect-safe property: even if the user finds a discrepancy
during the sweep, this commit DOES NOT change app behavior. App is
fully functional through the legacy path. The dispatch path is
diagnostic-only.

Test cases for commit 14b:
  - Each effect type pattern-matches to a real widget action
  - Manual 11-scenario sweep with the dispatch path authoritative
2026-04-08 19:50:40 -05:00
pax
095942c524 popout/hyprland: extract _hyprctl_* helpers with re-export shims
Pure refactor: moves the three Hyprland IPC helpers
(_hyprctl_get_window, _hyprctl_resize, _hyprctl_resize_and_move)
out of FullscreenPreview's class body and into a new sibling
hyprland.py module. The class methods become 1-line shims that
call the module functions, preserving byte-for-byte call-site
compatibility for the existing window.py code (_fit_to_content,
_enter_fullscreen, closeEvent all keep using self._hyprctl_*).

The module-level functions take the window title as a parameter
instead of reading it from self.windowTitle(), so they're cleanly
testable without a Qt instance.

Two reasons for the split:

1. **Architecture target.** docs/POPOUT_ARCHITECTURE.md calls for
   popout/hyprland.py as a separate module so the upcoming Qt
   adapter rewrite (commit 14) can call the helpers through a clean
   import surface — no FullscreenPreview self-reference required.

2. **Single source of Hyprland IPC.** Both the legacy window.py
   methods and (soon) the adapter's effect handler can call the same
   functions. The state machine refactor's FitWindowToContent effect
   resolves to a hyprland.resize_and_move call without going through
   the legacy class methods.

The shims live in window.py for one commit only — commit 14's
adapter rewrite drops them in favor of direct calls to
popout.hyprland.* from the effect application path.

Files changed:
  - NEW: booru_viewer/gui/popout/hyprland.py (~180 lines)
  - MOD: booru_viewer/gui/popout/window.py (~120 lines removed,
    ~20 lines of shims added)

Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green.

Smoke test:
- FullscreenPreview class still imports cleanly
- All three _hyprctl_* shim methods present
- Shim source code references hyprland module
- App expected to launch without changes (popout open / fit / close
  all go through the shims, which delegate to the module functions
  with the same byte-for-byte semantics as the legacy methods)

Test cases for commit 14 (window.py adapter rewrite):
  - Replace eventFilter imperative branches with dispatch calls
  - Apply effects from dispatch returns to widgets
  - Manual 11-scenario sweep
2026-04-08 19:44:00 -05:00
pax
553e31075d Privacy screen: resume video on un-hide, popout uses in-place overlay
Two related improvements to the Ctrl+P privacy screen flow.

1. Resume video on un-hide

Pre-fix: Ctrl+P paused any playing video in the embedded preview and
the popout, but the second Ctrl+P only hid the privacy overlay — the
videos stayed paused. The user had to manually click Play to resume.

Fix: in _toggle_privacy's privacy-off branch, mirror the privacy-on
pause logic with resume() calls on the embedded preview's video player
and the popout's video. Unconditional resume — if the user manually
paused before Ctrl+P, the auto-resume on un-hide is a tiny annoyance,
but the common case (privacy hides → user comes back → video should
be playing again) wins.

2. Popout privacy uses an in-place overlay instead of hide()

Pre-fix attempt: privacy-on called self._fullscreen_window.hide() and
privacy-off called .show(). On Wayland (Hyprland) the hide→show round
trip drops the window's position because the compositor unmaps the
window on hide and remaps it at the default tile position on show.
A first attempt at restoring the position via a deferred
hyprctl_resize_and_move dispatch in privacy_show didn't take — by
the time the dispatch landed, the window had already been re-tiled
and the move was gated by `if not win.get("floating"): return`.

Cleaner fix: don't hide the popout window at all. FullscreenPreview
gains its own _privacy_overlay (a black QWidget child of central,
parallel to the existing toolbar / controls bar children) that
privacy_hide raises over the media stack. The popout window stays
mapped, position is preserved automatically because nothing moves,
and the overlay covers the content visually.

privacy_hide / privacy_show methods live in FullscreenPreview, not
in main_window — popout-internal state belongs to the popout module.
_toggle_privacy in main_window just calls them. This also makes
adding more popout-side privacy state later (e.g. fullscreen save)
a one-method change inside the popout class.

Also added a _popout_was_visible flag in BooruApp._toggle_privacy so
privacy-off only restores the popout if it was actually visible at
privacy-on time. Without the gate, privacy-off would inappropriately
re-show a popout the user had closed before triggering privacy.

Verified manually:
  - popout open + drag to non-default pos + Ctrl+P + Ctrl+P → popout
    still at the dragged position, content visible again
  - popout open + video playing + Ctrl+P + Ctrl+P → video resumes
  - popout closed + Ctrl+P + Ctrl+P → popout stays closed
  - embedded preview video + Ctrl+P + Ctrl+P → resumes
  - Ctrl+P with no video on screen → no errors
2026-04-08 16:30:37 -05:00
pax
b571c9a486 F11 round-trip: preserve image zoom/pan + popout window position
Two related preservation bugs around the popout's F11 fullscreen
toggle, both surfaced during the post-refactor verification sweep.

1. ImageViewer zoom/pan loss on resize

ImageViewer.resizeEvent unconditionally called _fit_to_view() on every
resize event. F11 enter resizes the widget to the full screen, F11
exit resizes it back to the windowed size — both fired _fit_to_view,
clobbering any explicit user zoom and offset. Same problem for manual
window drags and splitter moves.

Fix: in resizeEvent, compute the previous-size fit-to-view zoom from
event.oldSize() and compare to current _zoom. Only re-fit if the user
was at fit-to-view at the previous size (within a 0.001 epsilon —
tighter than any wheel/key zoom step). Otherwise leave _zoom and
_offset alone.

The first-resize case (no valid oldSize, e.g. initial layout) still
defaults to fit, matching the original behavior for fresh widgets.

2. Popout window position lost on F11 round-trip

FullscreenPreview._enter_fullscreen captured _windowed_geometry but
the F11-exit restore goes through `_viewport` (the persistent center +
long_side that drives _fit_to_content). The drift detection in
_derive_viewport_for_fit only updates _viewport when
_last_dispatched_rect is set AND a fit is being computed — neither
path catches the "user dragged the popout with Super+drag and then
immediately pressed F11" sequence:

  - Hyprland Super+drag does NOT fire Qt's moveEvent (xdg-toplevel
    doesn't expose absolute screen position to clients on Wayland),
    so Qt-side drift detection is dead on Hyprland.
  - The Hyprland-side drift detection in _derive_viewport_for_fit
    only fires inside a fit, and no fit is triggered between a drag
    and F11.
  - Result: _viewport still holds whatever it had before the drag —
    typically the saved-from-last-session geometry seeded by the
    first-fit one-shot at popout open.

When F11 exits, the deferred _fit_to_content reads the stale viewport
and restores the popout to the *previously seeded* position instead of
where the user actually had it.

Fix: in _enter_fullscreen, after capturing _windowed_geometry, also
write the current windowed state into self._viewport directly. The
viewport then holds the actual pre-fullscreen position regardless of
how it got there (drag, drag+nav, drag+F11, etc.), and F11 exit's
restore reads it correctly.

Bundled into one commit because both fixes are "F11 round-trip should
preserve where the user was" — the image fix preserves content state
(zoom/pan), the popout fix preserves window state (position). Same
theme, related root cause class. Bisecting one without the other
would be misleading.

Verified manually:
  - image: scroll-zoom + drag pan + F11 + F11 → zoom and pan preserved
  - image: untouched zoom + F11 + F11 → still fits to view
  - image: scroll-zoom + manual window resize → zoom preserved
  - popout: Super+drag to a new position + F11 + F11 → lands at the
    dragged position, not at the saved-from-last-session position
  - popout: same sequence on a video post → same result (videos don't
    have zoom/pan, but the window-position fix applies to all media)
2026-04-08 16:19:35 -05:00
pax
8637202110 Move FullscreenPreview from preview.py to popout/window.py (no behavior change)
Step 6 of the gui/app.py + gui/preview.py structural refactor — the
biggest single move in the sequence. The entire 1046-line popout
window class moves to its own module under popout/, alongside the
viewport NamedTuple it depends on. The popout overlay styling
documentation comment that lived above the class moves with it
since it's about the popout, not about ImagePreview.

Address-only adjustment: the lazy `from ..core.config import` lines
inside `_hyprctl_resize` and `_hyprctl_resize_and_move` become
`from ...core.config import` because the new module sits one package
level deeper. Same target module, different relative-import depth —
no behavior change.

preview.py grows another re-export shim so app.py's two lazy
`from .preview import FullscreenPreview` call sites (in
_open_fullscreen_preview and _on_fullscreen_closed) keep working
unchanged. Shim removed in commit 14, where the call sites move
to the canonical `from .popout.window import FullscreenPreview`.
2026-04-08 14:25:38 -05:00