31 Commits

Author SHA1 Message Date
pax
2cdab574ca popout: refit window with correct aspect when leaving tiled layout
behavior change: navigating to a different-aspect image/video while
tiled then un-tiling now resizes the floating window to the current
content's aspect and resets the image viewer zoom. Previously the
window restored to the old floating geometry with the wrong aspect
locked.

Stash content dims on the tiled early-return in _fit_to_content, then
detect the tiled-to-floating transition via a debounced resizeEvent
check that re-runs the fit.
2026-04-12 22:18:21 -05:00
pax
0c0dd55907 popout: increase overlay hover zone
Fixed 40px hover zone was too small on high-DPI monitors. Now scales
to ~10% of window height with a 60px floor.
2026-04-11 22:17:32 -05:00
pax
d64b1d6465 popout: make Save/Unsave from Library mutually exclusive
Context menu now shows either Save to Library or Unsave from Library
based on saved state, never both.

behavior change: popout context menu shows either Save or Unsave.
2026-04-11 22:13:23 -05:00
pax
c210c4b44a popout: fix Copy File to Clipboard, add Copy Image URL
Fixed self._state → self._state_machine (latent AttributeError when
copying video to clipboard from popout context menu).

Rewrote copy logic to use QMimeData with file URL + image data,
matching main_window's Ctrl+C. For streaming URLs, resolves to the
cached local file. Added Copy Image URL entry for the source URL.

behavior change: clipboard copy now includes file URL; new context
menu entry for URL copy; video copy no longer crashes.
2026-04-11 21:55:07 -05:00
pax
21f2fa1513 popout: accumulate scroll delta for volume control
Same hi-res scroll fix as preview_pane — accumulate angleDelta to
±120 boundaries before triggering a volume step.

behavior change
2026-04-11 20:06:26 -05:00
pax
10c2dcb8aa fix popout menu flash on wrong monitor and preview unsave button
- preview_pane: unsave button now checks self._is_saved instead of
  self._save_btn.text() == "Unsave", which stopped matching after the
  button text became a Unicode icon (✕ / ⤓)
- popout: new _exec_menu_at_button helper uses menu.popup() +
  QEventLoop blocked on aboutToHide instead of menu.exec(globalPos).
  On Hyprland the popout gets moved via hyprctl after Qt maps it and
  Qt's window-position tracking stays stale, so exec(btn.mapToGlobal)
  resolved to a global point on the wrong monitor, flashing the menu
  there before the compositor corrected it. popup() routes through a
  different positioning path that anchors correctly.
2026-04-10 22:10:27 -05:00
pax
93459dfff6 UI overhaul: icon buttons, video controls, popout anchor, layout flip, compact top bar
- Preview/popout toolbar: icon buttons (☆/★, ↓/✕, ⊘, ⊗, ⧉) with QSS
  object names (#_tb_bookmark, #_tb_save, etc.) for theme targeting
- Video controls: QPainter-drawn icons for play/pause, volume/mute;
  text labels for loop/once/next and autoplay
- Popout anchor setting: resize pivot (center/tl/tr/bl/br) controls
  which corner stays fixed on aspect change, works on all platforms
- Hyprland monitor reserved areas: reads waybar exclusive zones from
  hyprctl monitors -j for correct edge positioning
- Layout flip setting: swap grid and preview sides
- Compact top bar: AdjustToContents combos, tighter spacing, named
  containers (#_top_bar, #_nav_bar) for QSS targeting
- Reduced main window minimum size from 900x600 to 740x400
- Trimmed bundled QSS: removed 12 unused widget selectors, added
  popout overlay font-weight/size, regenerated all 12 theme files
- Updated themes/README.md with icon button reference
2026-04-10 19:58:11 -05:00
pax
d7b3c304d7 add B/S keybinds to popout, refactor toggle_save 2026-04-10 18:32:57 -05:00
pax
127ee4315c popout/window: add right-click context menu
Popout now has a full context menu matching the embedded preview:
Bookmark as (folder submenu) / Unbookmark, Save to Library (folder
submenu), Unsave, Copy File, Open in Default App, Open in Browser,
Reset View (images), and Close Popout. Signals wired to the same
main_window handlers as the embedded preview.
2026-04-10 00:27:44 -05:00
pax
8c5c2e37d3 popout/window: reorder stack switch, drop stop, fix close position
behavior change: _apply_load_video now switches the stack to the video
surface BEFORE calling play_file so mpv's first frame lands on a visible
widget instead of a cleared image viewer. Removes the redundant stop()
call — loadfile("replace") atomically replaces the current file.

Also fixes video position not surviving popout close: StopMedia (part of
CloseRequested effects) destroyed mpv's time_pos before get_video_state
could read it. Now closeEvent snapshots position_ms before dispatching
CloseRequested, and get_video_state returns the snapshot.
2026-04-09 20:51:59 -05:00
pax
0ef3643b32 popout/window: fix dispatch lambdas dropping effects (video auto-fit + Loop=Next)
The signal-connection lambdas in __init__ added by commit 14a only
called _fsm_dispatch — they never followed up with _apply_effects.
Commit 14b added the apply layer and updated the keyboard event
handlers in eventFilter to dispatch+apply, but missed the lambdas.
Result: every effect produced by an mpv-driven signal was silently
dropped.

Two user-visible regressions:

  1. Video auto-fit (and aspect ratio lock) broken in popout. The
     mpv `video-params` observer fires when mpv reports video
     dimensions, and the chain is:
       _on_video_params (mpv thread) → _pending_video_size set
       → _poll → video_size.emit(w, h)
       → connected lambda → dispatch VideoSizeKnown(w, h)
       → state machine emits FitWindowToContent(w, h)
       → adapter SHOULD apply by calling _fit_to_content
     The lambda dropped the effects, so _fit_to_content never ran
     for video loads. Image loads were unaffected because they go
     through set_media's ContentArrived dispatch (which DOES apply
     via _dispatch_and_apply in this commit) with API-known
     dimensions.

  2. Loop=Next play_next broken. The mpv eof → VideoPlayer.play_next
     → connected lambda → dispatch VideoEofReached chain produces an
     EmitPlayNextRequested effect in PlayingVideo + Loop=Next, but
     the lambda dropped the effect, so self.play_next_requested was
     never emitted, and main_window's _on_video_end_next never fired.
     The user reported the auto-fit breakage; the play_next breakage
     was the silent twin that no one noticed because Loop=Next isn't
     the default.

Both bugs landed in commit 14b. The seek pin removal in d48435d
didn't cause them but exposed the auto-fit one because the user
was paying attention to popout sizing during the slider verification.

Fix:

- Add `_dispatch_and_apply(event)` helper. The single line of
  documentation in its docstring tells future-pax: "if you're
  going to dispatch an event, go through this helper, not bare
  _fsm_dispatch." This makes the apply step impossible to forget
  for any new wire-point.

- Update all 6 signal-connection lambdas to call _dispatch_and_apply:
    play_next → VideoEofReached
    video_size → VideoSizeKnown
    clicked_position → SeekRequested
    _mute_btn.clicked → MuteToggleRequested
    _vol_slider.valueChanged → VolumeSet
    _loop_btn.clicked → LoopModeSet

- Update the rest of the dispatch sites (keyboard event handlers in
  eventFilter, the wheel-tilt navigation, the wheel-vertical volume
  scroll, _on_video_playback_restart, set_media, closeEvent, the
  Open dispatch in __init__, and the WindowResized/WindowMoved
  dispatches in resizeEvent/moveEvent) to use _dispatch_and_apply
  for consistency. The keyboard handlers were already calling
  dispatch+apply via the two-line `effects = ...; self._apply_effects(effects)`
  pattern; switching to the helper is just deduplication. The
  Open / Window* dispatches were bare _fsm_dispatch but their
  handlers return [] anyway so the apply was a no-op.

After this commit, every dispatch site in the popout adapter goes
through one helper. The only remaining `self._fsm_dispatch(...)` call
is inside the helper itself (line 437) and one reference in the
helper's docstring.

Verification:
- Phase A test suite (16 tests) still passes
- State machine tests (65 tests) still pass — none of them touch
  the adapter wiring
- 81 / 81 tests green at HEAD

Manual verification needed:
- Click an uncached video in browse → popout opens, video loads,
  popout auto-fits to video aspect, Hyprland aspect lock applies
- Click cached video → same
- Loop=Next mode + video reaches EOF → popout advances to next post
  (was silently broken since 14b)
- Image load still auto-fits (regression check — image path was
  already working via ContentArrived's immediate FitWindowToContent)
2026-04-08 21:00:27 -05:00
pax
a2b759be90 popout/window: drop refactor shims (final cleanup)
Removes the last vestiges of the legacy compatibility layer that
commits 13-15 left in place to keep the app runnable across the
authority transfer:

1. Three `_hyprctl_*` shim methods on FullscreenPreview that
   delegated to the popout/hyprland module-level functions. Commit
   13 added them to preserve byte-for-byte call-site compatibility
   while window.py still had its old imperative event handling.
   After commit 14b switched authority to the dispatch+apply path
   and commit 15 cleaned up main_window's interface, every remaining
   call site in window.py is updated to call hyprland.* directly:

     self._hyprctl_get_window()        → hyprland.get_window(self.windowTitle())
     self._hyprctl_resize(0, 0)        → hyprland.resize(self.windowTitle(), 0, 0)
     self._hyprctl_resize_and_move(...) → hyprland.resize_and_move(self.windowTitle(), ...)

   8 internal call sites updated, 3 shim methods removed.

2. The legacy `self._video.video_size.connect(self._on_video_size)`
   parallel-path connection plus the dead `_on_video_size` method.
   The dispatch lambda wired in __init__ already handles
   VideoSizeKnown → FitWindowToContent → _fit_to_content via the
   apply path. The legacy direct connection was a duplicate that
   the same-rect skip in _fit_to_content made harmless, but it
   muddied the dispatch trace and was dead weight after 14b.

A new `from . import hyprland` at the top of window.py imports the
module once at load time instead of inline-importing on every shim
call (the legacy shims used `from . import hyprland` inside each
method body to avoid import order issues during the commit-13
extraction).

After this commit, FullscreenPreview's interaction with Hyprland is:
  - Single import: `from . import hyprland`
  - Direct calls: `hyprland.get_window(self.windowTitle())` etc
  - No shim layer
  - The popout/hyprland module is the single source of Hyprland IPC
    for the popout

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

Final state of the popout state machine refactor:

- 6 states / 17 events / 14 effects (within budget 10/20/15)
- 6 race-fix invariants enforced structurally (no timestamp windows
  in state.py, no guards, no fall-throughs)
- popout/state.py + popout/effects.py: pure Python, no PySide6, no
  mpv, no httpx — verifiable via the meta_path import blocker
- popout/hyprland.py: isolated subprocess wrappers
- popout/window.py: thin Qt adapter — translates Qt events into
  state machine dispatches, applies returned effects to widgets via
  the existing private helpers
- main_window.py: zero direct popout._underscore access; all
  interaction goes through the public method surface defined in
  commit 15

Test cases / followups: none. The refactor is complete.
2026-04-08 20:35:36 -05:00
pax
ec238f3aa4 gui/main_window: replace popout internal access with public methods
Drops every direct popout._underscore access from main_window in favor
of nine new public methods on FullscreenPreview. The legacy private
fields (_video, _viewer, _stack, _bookmark_btn, etc.) stay in place —
this is a clean public wrapper layer, not a re-architecture. Going
through public methods makes the popout's interface explicit and
prevents future code from reaching into popout internals.

New public methods on FullscreenPreview:

  is_video_active() -> bool
    Replaces popout._stack.currentIndex() == 1 checks. Used to gate
    video-only operations.

  set_toolbar_visibility(*, bookmark, save, bl_tag, bl_post)
    Replaces 4-line popout._bookmark_btn.setVisible(...) etc block.
    Per-tab toolbar gating.

  sync_video_state(*, volume, mute, autoplay, loop_state)
    Replaces 4-line popout._video.volume = ... etc block. Called by
    main_window's _open_fullscreen_preview to push embedded preview
    state into the popout.

  get_video_state() -> dict
    Returns volume / mute / autoplay / loop_state / position_ms in
    one read. Replaces 5 separate popout._video.* attribute reads
    in main_window's _on_fullscreen_closed reverse sync.

  seek_video_to(ms)
    Wraps VideoPlayer.seek_to_ms (which uses 'absolute+exact' since
    the 609066c drag-back fix). Used by the seek-after-load pattern.

  connect_media_ready_once(callback)
    One-shot callback wiring with auto-disconnect. Replaces the
    manual lambda + try/except disconnect dance in main_window.

  pause_media()
    Wraps VideoPlayer.pause(). Replaces 3 sites of direct
    popout._video.pause() calls in privacy-screen / external-open
    paths.

  force_mpv_pause()
    Direct mpv.pause = True without button text update. Replaces
    the legacy popout._video._mpv.pause = True deep attribute access
    in main_window's _on_post_activated. Used to prevent the OLD
    video from reaching natural EOF during the new post's async
    download.

  stop_media()
    Stops the video and clears the image viewer. Replaces 2 sites
    of the popout._viewer.clear() + popout._video.stop() sequence
    in blacklist-removal flow.

main_window.py call sites updated:

  Line 1122-1130 (_on_post_activated):
    popout._video._mpv.pause = True → popout.force_mpv_pause()

  Line 1339-1342 (_update_fullscreen):
    4 popout._*.setVisible(...) → popout.set_toolbar_visibility(...)

  Line 1798, 1811, 2731:
    popout._video.pause() → popout.pause_media()

  Line 2151-2166 (_open_fullscreen_preview sync block):
    sv = popout._video; sv.volume = ...; ...
    + manual seek-when-ready closure
    → popout.sync_video_state(...) + popout.connect_media_ready_once(...)

  Line 2196-2207 (_on_fullscreen_closed reverse sync):
    sv = popout._video; pv.volume = sv.volume; ...; popout._stack.currentIndex...
    → popout.get_video_state() returning a dict

  Line 2393-2394, 2421-2423 (blacklist removal):
    popout._viewer.clear() + popout._video.stop()
    → popout.stop_media()

After this commit, main_window has ZERO direct popout._underscore
accesses. The popout's public method surface is the only way for
main_window to interact with the popout's internals.

The popout's public method surface is now:

  Lifecycle:
    - set_media (existing — keeps the kind, info, width, height contract)
    - update_state (existing — bookmarked/saved button labels)
    - close (Qt builtin — triggers closeEvent)

  Wiring:
    - set_post_tags
    - set_bookmark_folders_callback
    - set_folders_callback

  Privacy:
    - privacy_hide / privacy_show (existing)

  New in commit 15:
    - is_video_active
    - set_toolbar_visibility
    - sync_video_state
    - get_video_state
    - seek_video_to
    - connect_media_ready_once
    - pause_media
    - force_mpv_pause
    - stop_media

  Outbound signals (unchanged from refactor start):
    - navigate / play_next_requested / closed
    - bookmark_requested / bookmark_to_folder
    - save_to_folder / unsave_requested
    - blacklist_tag_requested / blacklist_post_requested
    - privacy_requested

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

Verification:
- Imports clean
- Pure-Python state machine + tests unchanged
- main_window's popout interaction goes through public methods only

Test cases for commit 16 (final shim cleanup):
- Drop the hyprland re-export shim methods from popout/window.py
- Have callers use popout.hyprland directly
2026-04-08 20:33:12 -05:00
pax
69d25b325e popout/window: apply effects from StateMachine, remove duplicate emits
**Commit 14b of the pre-emptive 14a/14b split.**

Adds the effect application path. The state machine becomes the
single source of truth for the popout's media transitions, navigation,
fullscreen toggle, and close lifecycle. The legacy imperative paths
that 14a left in place are removed where the dispatch+apply chain
now produces the same side effects.

Architectural shape:

  Qt event → _fsm_dispatch(Event) → list[Effect] → _apply_effects()
                                                      ↓
                                              pattern-match by type
                                                      ↓
                                       calls existing private helpers
                                       (_fit_to_content, _enter_fullscreen,
                                        _video.play_file, etc.)

The state machine doesn't try to reach into Qt or mpv directly; it
returns descriptors and the adapter dispatches them to the existing
implementation methods. The private helpers stay in place as the
implementation; the state machine becomes their official caller.

What's fully authoritative via dispatch+apply:
  - Navigate keys + wheel tilt → NavigateRequested → EmitNavigate
  - F11 → FullscreenToggled → EnterFullscreen / ExitFullscreen
  - Space → TogglePlayRequested → TogglePlay
  - closeEvent → CloseRequested → StopMedia + EmitClosed
  - set_media → ContentArrived → LoadImage|LoadVideo + FitWindowToContent
  - mpv playback-restart → VideoStarted | SeekCompleted (state-aware)
  - mpv eof-reached + Loop=Next → VideoEofReached → EmitPlayNextRequested
  - mpv video-params → VideoSizeKnown → FitWindowToContent

What's deliberately no-op apply in 14b (state machine TRACKS but
doesn't drive):
  - ApplyMute / ApplyVolume / ApplyLoopMode: legacy slot connections
    on the popout's VideoPlayer still handle the user-facing toggles.
    Pushing state.mute/volume/loop_mode would create a sync hazard
    with the embedded preview's mute state, which main_window pushes
    via direct attribute writes at popout open. The state machine
    fields are still updated for the upcoming SyncFromEmbedded path
    in a future commit; the apply handlers are intentionally empty.
  - SeekVideoTo: the legacy `_ClickSeekSlider.clicked_position →
    VideoPlayer._seek` connection still handles both the mpv.seek
    call (now exact, per the 609066c drag-back fix) and the legacy
    500ms `_seek_pending_until` pin window. Replacing this requires
    modifying VideoPlayer._poll which is forbidden by the state
    machine refactor's no-touch rule on media/video_player.py.

Removed duplicate legacy emits (would have caused real bugs):
  - self.navigate.emit(±N) in eventFilter arrow keys + wheel tilt
    → EmitNavigate effect
  - self.closed.emit() and self._video.stop() in closeEvent
    → StopMedia + EmitClosed effects
  - self._video.play_next.connect(self.play_next_requested)
    signal-to-signal forwarding → EmitPlayNextRequested effect
  - self._enter_fullscreen() / self._exit_fullscreen() direct calls
    → EnterFullscreen / ExitFullscreen effects
  - self._video._toggle_play() direct call → TogglePlay effect
  - set_media body's load logic → LoadImage / LoadVideo effects

The Esc/Q handler now only calls self.close() and lets closeEvent
do the dispatch + apply. Two reasons:

1. Geometry persistence (FullscreenPreview._saved_geometry /
   _saved_fullscreen) is adapter-side concern and must run BEFORE
   self.closed is emitted, because main_window's
   _on_fullscreen_closed handler reads those class fields. Saving
   geometry inside closeEvent before dispatching CloseRequested
   gets the order right.
2. The state machine sees the close exactly once. Two-paths
   (Esc/Q → dispatch + close() → closeEvent → re-dispatch) would
   require the dispatch entry's CLOSING-state guard to silently
   absorb the second event — works but more confusing than just
   having one dispatch site.

The closeEvent flow now is:
  1. Save FullscreenPreview._saved_fullscreen and _saved_geometry
     (adapter-side, before dispatch)
  2. Remove the QApplication event filter
  3. Dispatch CloseRequested → effects = [StopMedia, EmitClosed]
  4. Apply effects → stop media, emit self.closed
  5. super().closeEvent(event) → Qt window close

Verification:

- Phase A test suite (16 tests in tests/core/) still passes
- State machine tests (65 in tests/gui/popout/test_state.py) still pass
- Total: 81 / 81 automated tests green
- Imports clean

**The 11 manual scenarios are NOT verified by automated tests.**
The user must run the popout interactively and walk through each
scenario before this commit can be considered fully verified:

  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 (already verified by the 14a + drag-back-fix
      sweep)

**If ANY scenario fails after this commit:** immediate `git revert
HEAD`, do not fix in place. The 14b apply layer is bounded enough
that any regression can be diagnosed by inspecting the apply handler
for the relevant effect type, but the in-place-fix temptation should
be resisted — bisect-safety requires a clean revert.

Test cases for commit 15:
  - main_window.popout calls become method calls instead of direct
    underscore access (open_post / sync_video_state / get_video_state /
    set_toolbar_visibility)
  - Method-cluster sweep from REFACTOR_INVENTORY.md still passes
2026-04-08 20:25:24 -05:00
pax
35d80c32f2 popout/window: route adapter logger to stderr for terminal capture
Follow-up to commit 14a. The booru logger has only the in-app
QTextEdit LogHandler attached (main_window.py:436-440), so the
POPOUT_FSM dispatch trace from the state machine adapter only
reaches the Ctrl+L log panel — invisible from the shell.

Adds a stderr StreamHandler attached directly to the
`booru.popout.adapter` logger so:

  python -m booru_viewer.main_gui 2>&1 | grep POPOUT_FSM

works during the commit-14a verification gate. The user can capture
the dispatch trace per scenario and compare it to the legacy path's
actions before commit 14b switches authority.

The handler is tagged with a `_is_popout_fsm_stderr` sentinel
attribute so re-imports of window.py don't stack duplicate
handlers (defensive — module-level code only runs once per process,
but the check costs nothing).

Format: `[HH:MM:SS.mmm] POPOUT_FSM <event> | <old> -> <new> | effects=[...]`
The millisecond precision matters for the seek scenario where the
race window is sub-100ms.

Propagation to the parent booru logger is left enabled, so dispatch
trace lines also continue to land in the in-app log panel for the
user who prefers Ctrl+L.

Tests still pass (81 / 81). No behavior change to widgets — this
only affects log output routing.
2026-04-08 20:01:16 -05:00
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
06f8f3d752 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
2026-04-08 19:41:57 -05:00
pax
3ade3a71c1 popout/state: implement illegal transition handler (env-gated)
Adds the structural alternative to "wait for a downstream symptom and
bisect to find the bad dispatch": catch illegal transitions at the
dispatch boundary instead of letting them silently no-op.

In release mode (default — no env var set):
  - Illegal events are dropped silently
  - A `log.debug` line is emitted with the state and event type
  - dispatch returns []
  - state is unchanged
  - This is what production runs

In strict mode (BOORU_VIEWER_STRICT_STATE=1):
  - Illegal events raise InvalidTransition(state, event)
  - The exception carries both fields for the diagnostic
  - This is for development and the test suite — it makes
    programmer errors loud and immediate instead of silently
    cascading into a downstream symptom

The legality map (`_LEGAL_EVENTS_BY_STATE`) is per-state. Most events
(NavigateRequested / Mute / Volume / LoopMode / Fullscreen / window
events / Close / ContentArrived) are globally legal in any non-Closing
state. State-specific events are listed per state. Closing has an
empty legal set; the dispatch entry already drops everything from
Closing before the legality check runs.

The map distinguishes "legal-but-no-op" from "structurally invalid":

  - VideoEofReached in LoadingVideo: LEGAL. The state machine
    intentionally accepts and drops this event. It's the EOF race
    fix — the event arriving in LoadingVideo is the race scenario,
    and dropping is the structural cure. Strict mode does NOT raise.

  - VideoEofReached in SeekingVideo: LEGAL. Same reasoning — eof
    during a seek is stale.

  - VideoEofReached in AwaitingContent / DisplayingImage: ILLEGAL.
    No video is loaded; an eof event arriving here is a real bug
    in either mpv or the adapter. Strict mode raises.

The strict-mode read happens per-dispatch (`os.environ.get`), not
cached at module load, so monkeypatch.setenv in tests works
correctly. The cost is microseconds per dispatch — negligible.

Tests passing after this commit (65 total → 65 pass):

  Newly added (3):
  - test_strict_mode_raises_invalid_transition
  - test_strict_mode_does_not_raise_for_legal_events
  - test_strict_mode_legal_but_no_op_does_not_raise

  Plus the existing 62 still pass — the legality check is non-
  invasive in release mode (existing tests run without
  BOORU_VIEWER_STRICT_STATE set, so they see release-mode behavior).

Phase A (16 tests in tests/core/) still green.

The state machine logic is now COMPLETE. Every state, every event,
every effect is implemented with both happy-path transitions and
illegal-transition handling. The remaining commits (12-16) carve
the implementation into the planned file layout (effects.py split,
hyprland.py extraction) and rewire the Qt adapter.

Test cases for commit 12 (effects split):
  - Re-import after the file split still works
  - All 65 tests still pass after `from .effects import ...` change
2026-04-08 19:40:05 -05:00
pax
4fb17151b1 popout/state: implement DisplayingImage + Closing — all 62 tests pass
Two final transition handlers complete the state machine surface:

ContentArrived(IMAGE | GIF) in any state →
  DisplayingImage, [LoadImage(path, is_gif), FitWindowToContent(w, h)]
  Same path as the video branch but routes to ImageViewer instead
  of mpv. The is_gif flag tells the adapter which ImageViewer
  method to call (set_gif vs set_image — current code at
  popout/window.py:411-417).

CloseRequested from any non-Closing state →
  Closing, [StopMedia, EmitClosed]
  Closing is terminal. Every subsequent event returns [] regardless
  of type (handled at the dispatch entry, which has been in place
  since the skeleton). The adapter handles geometry persistence and
  Qt cleanup outside the state machine — those are not popout
  state machine concerns.

Tests passing after this commit: 62 / 62 (100%).

  Newly green:
  - test_awaiting_content_arrived_image_loads_and_transitions
  - test_awaiting_content_arrived_gif_loads_as_animated
  - test_displaying_image_content_replace_with_image
  - test_close_from_each_state_transitions_to_closing[5 states]

Phase A (16 tests in tests/core/) still green.

State machine complete. The 6-state / 17-event / 14-effect design
is fully implemented:

  States (6, budget ≤10):
    AwaitingContent / DisplayingImage / LoadingVideo /
    PlayingVideo / SeekingVideo / Closing

  Events (17, budget ≤20):
    Open / ContentArrived / NavigateRequested
    VideoStarted / VideoEofReached / VideoSizeKnown
    SeekRequested / SeekCompleted
    MuteToggleRequested / VolumeSet / LoopModeSet / TogglePlayRequested
    FullscreenToggled
    WindowMoved / WindowResized / HyprlandDriftDetected
    CloseRequested

  Effects (14, budget ≤15):
    LoadImage / LoadVideo / StopMedia
    ApplyMute / ApplyVolume / ApplyLoopMode
    SeekVideoTo / TogglePlay
    FitWindowToContent / EnterFullscreen / ExitFullscreen
    EmitNavigate / EmitPlayNextRequested / EmitClosed

Six race-fix invariants all enforced structurally — no timestamp
suppression windows in state.py, no guards, no fall-throughs:

  1. EOF race: VideoEofReached only valid in PlayingVideo
  2. Double-load: Navigate from media → AwaitingContent never
     re-emits Load until ContentArrived
  3. Persistent viewport: viewport is a state field, only mutated
     by user-action events
  4. F11 round-trip: pre_fullscreen_viewport snapshot/restore
  5. Seek pin: SeekingVideo state + compute_slider_display_ms read
  6. Pending mute: state.mute owned by machine, ApplyMute on
     PlayingVideo entry

Test cases for commit 11 (illegal transition handler):
  - dispatch invalid event in strict mode raises InvalidTransition
  - dispatch invalid event in release mode returns [] (current behavior)
  - BOORU_VIEWER_STRICT_STATE env var gates the raise
2026-04-08 19:37:31 -05:00
pax
527cb3489b popout/state: implement mute/volume/loop persistence
Three event handlers, all updating state fields and emitting the
corresponding Apply effect:

MuteToggleRequested:
  Flip state.mute unconditionally — independent of which media state
  we're in, independent of whether mpv exists. Emit ApplyMute. The
  persistence-on-load mechanism in _on_video_started already replays
  state.mute into the freshly-loaded video, so toggling mute before
  any video is loaded survives the load cycle.

VolumeSet:
  Set state.volume (clamped 0-100), emit ApplyVolume. Same
  persistence-on-load behavior.

LoopModeSet:
  Set state.loop_mode, emit ApplyLoopMode. Also affects what
  happens at the next EOF (PlayingVideo + VideoEofReached branches
  on state.loop_mode), so changing it during playback takes effect
  on the next eof without any other state mutation.

This commit makes the 0a68182 pending mute fix structural at the
popout layer. The state machine owns mute / volume / loop_mode as
the source of truth. The current VideoPlayer._pending_mute field
stays as defense in depth — the state machine refactor's prompt
forbids touching media/video_player.py beyond the playback_restart
Signal addition. The popout layer no longer depends on the lazy
replay because the state machine emits ApplyMute on every
PlayingVideo entry.

All four persistent fields (mute, volume, loop_mode, viewport)
are now state machine fields with single-writer ownership through
dispatch().

Tests passing after this commit (62 total → 54 pass, 8 fail):

  - test_state_field_mute_persists_across_video_loads
  - test_state_field_volume_persists_across_video_loads
  - test_state_field_loop_mode_persists
  - test_invariant_pending_mute_replayed_into_video (RACE FIX!)

Phase A (16 tests) still green.

Tests still failing (8, scheduled for commit 10):
  - DisplayingImage content arrived branch (commit 10)
  - Closing transitions (commit 10)
  - Open + first content with image kind (commit 10)

Test cases for commit 10 (DisplayingImage + Closing):
  - ContentArrived(IMAGE) → DisplayingImage + LoadImage(is_gif=False)
  - ContentArrived(GIF) → DisplayingImage + LoadImage(is_gif=True)
  - DisplayingImage + ContentArrived(IMAGE) replaces media
  - CloseRequested from each state → Closing + StopMedia + EmitClosed
2026-04-08 19:36:35 -05:00
pax
a03d0e9dc8 popout/state: implement persistent viewport + drift events
Three event handlers, all updating state.viewport from rect data:

WindowMoved (Qt moveEvent, non-Hyprland only):
  Move-only update — preserve existing long_side, recompute center.
  Moves don't change size, so the viewport's "how big does the user
  want it" intent stays put while its "where does the user want it"
  intent updates.

WindowResized (Qt resizeEvent, non-Hyprland only):
  Full rebuild — long_side becomes new max(w, h), center becomes
  the rect center. Resizes change both intents.

HyprlandDriftDetected (adapter, fit-time hyprctl drift check):
  Full rebuild from rect. This is the ONLY path that captures
  Hyprland Super+drag — Wayland's xdg-toplevel doesn't expose
  absolute window position to clients, so Qt's moveEvent never
  fires for external compositor-driven movement. The adapter's
  _derive_viewport_for_fit equivalent will dispatch this event when
  it sees the current Hyprland rect drifting from the last
  dispatched rect by more than _DRIFT_TOLERANCE.

All three handlers gate on (not fullscreen) and (not Closing).
Drifts and moves while in fullscreen aren't meaningful for the
windowed viewport.

This makes the 7d19555 persistent viewport structural. The
viewport is a state field. It's only mutated by WindowMoved /
WindowResized / HyprlandDriftDetected (user action) — never by
FitWindowToContent reading and writing back its own dispatch.
The drift accumulation that the legacy code's "recompute from
current state" shortcut suffered cannot happen here because there's
no read-then-write path; viewport is the source of truth, not
derived from current rect.

Tests passing after this commit (62 total → 50 pass, 12 fail):

  - test_window_moved_updates_viewport_center_only
  - test_window_resized_updates_viewport_long_side
  - test_hyprland_drift_updates_viewport_from_rect

(The persistent-viewport-no-drift invariant test was already
passing because the previous transition handlers don't write to
viewport — the test was checking the absence of drift via the
absence of writes, which the skeleton already satisfied.)

Phase A (16 tests) still green.

Tests still failing (12, scheduled for commits 9-11):
  - mute/volume/loop persistence events (commit 9)
  - DisplayingImage content arrived branch (commit 10)
  - Closing transitions (commit 10)

Test cases for commit 9 (mute/volume/loop persistence):
  - MuteToggleRequested flips state.mute, emits ApplyMute
  - VolumeSet sets state.volume, emits ApplyVolume
  - LoopModeSet sets state.loop_mode, emits ApplyLoopMode
2026-04-08 19:35:43 -05:00
pax
d75076c14b popout/state: implement Fullscreen flag + F11 round-trip
FullscreenToggled in any non-Closing state flips state.fullscreen.

Enter (fullscreen=False → True):
- Snapshot state.viewport into state.pre_fullscreen_viewport
- Emit EnterFullscreen effect (adapter calls self.showFullScreen())

Exit (fullscreen=True → False):
- Restore state.viewport from state.pre_fullscreen_viewport
- Clear state.pre_fullscreen_viewport
- Emit ExitFullscreen effect (adapter calls self.showNormal() then
  defers a FitWindowToContent on the next event-loop tick — matching
  the current QTimer.singleShot(0, ...) pattern)

This makes the 705e6c6 F11 round-trip viewport preservation
structural. The fix in the legacy code wrote the current Hyprland
window state into _viewport inside _enter_fullscreen so the F11
exit could restore it. The state machine version is equivalent: the
viewport snapshot at the moment of entering is the source of truth
for restoration. Whether the user got there via Super+drag (no Qt
moveEvent on Wayland), nav, or external resize, the snapshot
captures the viewport AS IT IS RIGHT NOW.

The interaction with HyprlandDriftDetected (commit 8): the adapter
will dispatch a HyprlandDriftDetected event before FullscreenToggled
during enter, so any drift between the last dispatched rect and
current Hyprland geometry is absorbed into viewport BEFORE the
snapshot. That's how the state machine handles the "user dragged
the popout, then immediately pressed F11" case.

Tests passing after this commit (62 total → 47 pass, 15 fail):

  - test_invariant_f11_round_trip_restores_pre_fullscreen_viewport

Phase A (16 tests) still green.

Tests still failing (15, scheduled for commits 8-11):
  - Persistent viewport / drift events (commit 8)
  - mute/volume/loop persistence events (commit 9)
  - DisplayingImage content arrived branch (commit 10)
  - Closing transitions (commit 10)

Test cases for commit 8 (persistent viewport + drift events):
  - WindowMoved updates viewport center, preserves long_side
  - WindowResized updates viewport long_side from new max(w,h)
  - HyprlandDriftDetected rebuilds viewport from rect
  - Persistent viewport doesn't drift across navs (already passing)
2026-04-08 19:34:52 -05:00
pax
664d4e9cda popout/state: implement SeekingVideo + slider pin
PlayingVideo + SeekRequested → SeekingVideo, stash target_ms, emit
SeekVideoTo. SeekingVideo + SeekRequested replaces the target (user
clicked again, latest seek wins). SeekingVideo + SeekCompleted →
PlayingVideo.

The slider pin behavior is the read-path query
`compute_slider_display_ms(mpv_pos_ms)` already implemented at the
skeleton stage: while in SeekingVideo, returns `seek_target_ms`;
otherwise returns `mpv_pos_ms`. The Qt-side adapter's poll timer
asks the state machine for the slider display value on every tick
and writes whatever it gets back to the slider widget.

**This replaces 96a0a9d's 500ms _seek_pending_until timestamp window
at the popout layer.** The state machine has no concept of wall-clock
time. The SeekingVideo state lasts exactly until mpv signals the seek
is done, via the playback_restart Signal added in commit 1. The
adapter distinguishes load-restart from seek-restart by checking
the state machine's current state (LoadingVideo → VideoStarted;
SeekingVideo → SeekCompleted).

The pre-commit-1 probe verified that mpv emits playback-restart
exactly once per load and exactly once per seek (3 events for 1
load + 2 seeks), so the dispatch routing is unambiguous.

VideoPlayer's internal _seek_pending_until field stays in place as
defense in depth — the state machine refactor's prompt explicitly
forbids touching media/video_player.py beyond the playback_restart
Signal addition. The popout layer no longer depends on it.

Tests passing after this commit (62 total → 46 pass, 16 fail):

  - test_playing_video_seek_requested_transitions_and_pins
  - test_seeking_video_completed_returns_to_playing
  - test_seeking_video_seek_requested_replaces_target
  - test_invariant_seek_pin_uses_compute_slider_display_ms (RACE FIX!)

Phase A (16 tests) still green.

Tests still failing (16, scheduled for commits 7-11):
  - F11 round-trip (commit 7)
  - Persistent viewport / drift events (commit 8)
  - mute/volume/loop persistence events (commit 9)
  - DisplayingImage content arrived branch (commit 10)
  - Closing transitions (commit 10)

Test cases for commit 7 (Fullscreen flag + F11 round-trip):
  - dispatch FullscreenToggled in any media state, assert flag flipped
  - F11 enter snapshots viewport into pre_fullscreen_viewport
  - F11 exit restores viewport from pre_fullscreen_viewport
2026-04-08 19:34:08 -05:00
pax
a9ce01e6c1 popout/state: implement Navigating + AwaitingContent + double-load fix
NavigateRequested in any media state (DisplayingImage / LoadingVideo /
PlayingVideo / SeekingVideo) transitions to AwaitingContent and emits
[StopMedia, EmitNavigate]. NavigateRequested in AwaitingContent itself
(rapid Right-arrow spam, second nav before main_window has delivered
the next post) emits EmitNavigate alone — no StopMedia, because
there's nothing to stop.

This is the structural fix for the double-load race that 31d02d3c
fixed upstream by removing the explicit _on_post_activated call after
_grid._select. The popout-layer fix is independent and stronger: even
if upstream signal chains misfire, the state machine never produces
two Load effects for the same navigation cycle. The state machine's
LoadVideo / LoadImage effects only fire from ContentArrived, and
ContentArrived is delivered exactly once per main_window-side post
activation.

The Open event handler also lands here. Stashes saved_geo,
saved_fullscreen, monitor on the state machine instance for the
first ContentArrived to consume. The actual viewport seeding from
saved_geo lives in commit 8 — this commit just stores the inputs.

Tests passing after this commit (62 total → 42 pass, 20 fail):

  - test_awaiting_open_stashes_saved_geo
  - test_awaiting_navigate_emits_navigate_only
  - test_displaying_image_navigate_stops_and_emits
  - test_loading_video_navigate_stops_and_emits
  - test_playing_video_navigate_stops_and_emits
  - test_seeking_video_navigate_stops_and_emits
  - test_invariant_double_navigate_no_double_load (RACE FIX!)

Plus several illegal-transition cases for nav-from-now-valid-states.

Phase A (16 tests in tests/core/) still green.

Tests still failing (20, scheduled for commits 6-11):
  - SeekingVideo entry/exit (commit 6)
  - F11 round-trip (commit 7)
  - Persistent viewport / drift events (commit 8)
  - mute/volume/loop persistence events (commit 9)
  - DisplayingImage content arrived branch (commit 10)
  - Closing transitions (commit 10)

Test cases for commit 6 (SeekingVideo + slider pin):
  - PlayingVideo + SeekRequested → SeekingVideo + SeekVideoTo effect
  - SeekingVideo + SeekRequested replaces seek_target_ms
  - SeekingVideo + SeekCompleted → PlayingVideo
  - test_invariant_seek_pin_uses_compute_slider_display_ms
2026-04-08 19:33:17 -05:00
pax
7fdc67c613 popout/state: implement PlayingVideo + LoadingVideo + EOF race fix
First batch of real transitions. The EOF race fix is the headline —
this commit replaces fda3b10b's 250ms _eof_ignore_until timestamp
window with a structural transition that drops VideoEofReached in
every state except PlayingVideo.

Transitions implemented:

  ContentArrived(VIDEO) in any state →
    LoadingVideo, [LoadVideo, FitWindowToContent]
    Snapshots current_path/info/kind/width/height. Flips
    is_first_content_load to False (the saved_geo seeding lands in
    commit 8). Image and GIF kinds are still stubbed — they get
    DisplayingImage in commit 10.

  LoadingVideo + VideoStarted →
    PlayingVideo, [ApplyMute, ApplyVolume, ApplyLoopMode]
    The persistence effects fire on PlayingVideo entry, pushing the
    state machine's persistent values into mpv. This is the
    structural replacement for VideoPlayer._pending_mute's lazy-mpv
    replay (the popout layer now owns mute as truth; VideoPlayer's
    internal _pending_mute stays as defense in depth, untouched).

  PlayingVideo + VideoEofReached →
    Loop=NEXT: [EmitPlayNextRequested]
    Loop=ONCE: [] (mpv keep_open=yes pauses naturally)
    Loop=LOOP: [] (mpv loop-file=inf handles internally)

  *Anything* + VideoEofReached (not in PlayingVideo) →
    [], state unchanged
    **THIS IS THE EOF RACE FIX.** The fda3b10b commit added a 250ms
    timestamp window inside VideoPlayer to suppress eof events
    arriving from a previous file's stop. The state machine subsumes
    that by only accepting eof in PlayingVideo. In LoadingVideo
    (where the race lives), VideoEofReached is structurally invalid
    and gets dropped at the dispatch boundary. No window. No
    timestamp. No race.

  LoadingVideo / PlayingVideo + VideoSizeKnown →
    [FitWindowToContent(w, h)]
    mpv reports new dimensions; refit. Same effect for both states
    because the only difference is "did the user see a frame yet"
    (which doesn't matter for window sizing).

  PlayingVideo + TogglePlayRequested →
    [TogglePlay]
    Space key / play button. Only valid in PlayingVideo — toggling
    play during a load or seek would race with mpv's own state
    machine.

Tests passing after this commit (62 total → 35 pass, 27 fail):

  - test_loading_video_started_transitions_to_playing
  - test_loading_video_eof_dropped (RACE FIX!)
  - test_loading_video_size_known_emits_fit
  - test_playing_video_eof_loop_next_emits_play_next
  - test_playing_video_eof_loop_once_pauses
  - test_playing_video_eof_loop_loop_no_op
  - test_playing_video_size_known_refits
  - test_playing_video_toggle_play_emits_toggle
  - test_invariant_eof_race_loading_video_drops_stale_eof (RACE FIX!)
  - test_awaiting_content_arrived_video_transitions_to_loading
  - test_awaiting_content_arrived_video_emits_persistence_effects

Plus several illegal-transition cases for the (LoadingVideo, *)
events that this commit makes meaningfully invalid.

Phase A (16 tests in tests/core/) still green.

Tests still failing (27, scheduled for commits 5-11):
  - Open / NavigateRequested handlers (commit 5)
  - DisplayingImage transitions (commit 10)
  - SeekingVideo transitions (commit 6)
  - Closing transitions (commit 10)
  - Persistent viewport / drift events (commit 8)
  - mute/volume/loop persistence events (commit 9)
  - F11 round-trip (commit 7)

Test cases for commit 5 (Navigating + AwaitingContent + double-load):
  - dispatch NavigateRequested in PlayingVideo → AwaitingContent
  - second NavigateRequested in AwaitingContent doesn't re-stop
  - test_invariant_double_navigate_no_double_load
2026-04-08 19:32:04 -05:00
pax
39816144fe popout/state: skeleton (6 states, 17 events, 14 effects, no transitions)
Lays down the data shapes for the popout state machine ahead of any
transition logic. Pure Python — does not import PySide6, mpv, httpx,
subprocess, or any module that does. The Phase B test suite (commit 3)
will exercise this purity by importing it directly without standing
up a QApplication; the test suite is the forcing function that keeps
the file pure as transitions land in commits 4-11.

Module structure follows docs/POPOUT_ARCHITECTURE.md exactly.

States (6, target ≤10):
  AwaitingContent — popout exists, no current media (initial OR mid-nav)
  DisplayingImage — static image or GIF on screen
  LoadingVideo — set_media called for video, awaiting first frame
  PlayingVideo — video active (paused or playing)
  SeekingVideo — user-initiated seek pending
  Closing — closeEvent received, terminal

Events (17, target ≤20):
  Open / ContentArrived / NavigateRequested
  VideoStarted / VideoEofReached / VideoSizeKnown
  SeekRequested / SeekCompleted
  MuteToggleRequested / VolumeSet / LoopModeSet / TogglePlayRequested
  FullscreenToggled
  WindowMoved / WindowResized / HyprlandDriftDetected
  CloseRequested

Effects (14, target ≤15):
  LoadImage / LoadVideo / StopMedia
  ApplyMute / ApplyVolume / ApplyLoopMode
  SeekVideoTo / TogglePlay
  FitWindowToContent / EnterFullscreen / ExitFullscreen
  EmitNavigate / EmitPlayNextRequested / EmitClosed

Frozen dataclasses for events and effects, Enum for State / MediaKind /
LoopMode. Dispatch uses Python 3.10+ structural pattern matching to
route by event type.

StateMachine fields cover the full inventory:
  - Lifecycle: state, is_first_content_load
  - Persistent (orthogonal): fullscreen, mute, volume, loop_mode
  - Geometry: viewport, pre_fullscreen_viewport, last_dispatched_rect
  - Seek: seek_target_ms
  - Content snapshot: current_path, current_info, current_kind,
    current_width, current_height
  - Open-event payload: saved_geo, saved_fullscreen, monitor
  - Nav: grid_cols

Read-path query implemented even at the skeleton stage:
  compute_slider_display_ms(mpv_pos_ms) returns seek_target_ms while
  in SeekingVideo, mpv_pos_ms otherwise. This is the structural
  replacement for the 500ms _seek_pending_until timestamp window —
  no timestamp, just the SeekingVideo state.

Every per-event handler is a stub that returns []. Real transitions
land in commits 4-11 (priority order: PlayingVideo + LoadingVideo +
EOF race fix → Navigating + AwaitingContent + double-load fix →
SeekingVideo + slider pin → Fullscreen + F11 → viewport + drift →
mute/volume/loop persistence → DisplayingImage + Closing → illegal
transition handler).

Closing is treated as terminal at the dispatch entry — once we're
there, every event returns [] regardless of type. Same property the
current closeEvent has implicitly.

Verification:
- Phase A test suite (16 tests) still passes
- state.py imports cleanly with PySide6/mpv/httpx blocked at the
  meta_path level (purity gate)
- StateMachine() constructs with all fields initialized to sensible
  defaults
- Stub dispatch returns [] for every event type
- 6 states / 17 events / 14 effects all under budget (≤10/≤20/≤15)

Test cases for state machine tests (Prompt 3 commit 3):
- Construct StateMachine, assert initial state == AwaitingContent
- Assert is_first_content_load is True at construction
- Assert all stub dispatches return []
- Assert compute_slider_display_ms returns mpv_pos when not seeking
2026-04-08 19:22:06 -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
pax
18a86358e2 Move Viewport + _DRIFT_TOLERANCE from preview.py to popout/viewport.py (no behavior change)
Step 2 of the gui/app.py + gui/preview.py structural refactor. Pure
move: the popout viewport NamedTuple and the drift-tolerance constant
are now in their own module under popout/. preview.py grows another
re-export shim line so FullscreenPreview's method bodies (which
reference Viewport and _DRIFT_TOLERANCE by bare name) keep working
unchanged. Shim removed in commit 14. See docs/REFACTOR_PLAN.md.
2026-04-08 13:49:47 -05:00