• v0.2.3 23828e7d0c

    v0.2.3 Stable

    pax released this 2026-04-09 05:15:16 +00:00 | 292 commits to main since this release

    A refactor + cleanup release. The two largest source files (gui/app.py 3608 lines + gui/preview.py 2273 lines) are gone, replaced by a module-per-concern layout. The popout viewer's internal state was rebuilt as an explicit state machine with the historical race bugs locked out structurally instead of by suppression windows. The slider drag-back race that no one had named is finally fixed. A handful of latent bugs got caught and resolved on the way through.

    Changes since v0.2.2

    Structural refactor: gui/app.py + gui/preview.py split

    The two largest source files were doing too much. gui/app.py was 3608 lines mixing async dispatch, signal wiring, tab switching, popout coordination, splitter persistence, context menus, bulk actions, batch download, fullscreen, privacy, and a dozen other concerns. gui/preview.py was 2273 lines holding the embedded preview, the popout, the image viewer, the video player, an OpenGL surface, and a click-to-seek slider. Both files had reached the point where almost every commit cited "the staging surface doesn't split cleanly" as the reason for bundling unrelated fixes.

    This release pays that cost down with a structural carve into 12 module-per-concern files plus 2 oversize-by-design god-class files. 14 commits, every commit byte-identical except for relative-import depth corrections, app runnable at every commit boundary.

    • gui/app.py (3608 lines) gone. Carved into:
      • app_runtime.py: run(), _apply_windows_dark_mode(), _load_user_qss() (@palette preprocessor), _BASE_POPOUT_OVERLAY_QSS. The QApplication setup, custom QSS load, icon resolution, BooruApp instantiation, and exec loop.
      • main_window.py: BooruApp(QMainWindow), ~3200 lines. The class is one indivisible unit because every method shares instance attributes with every other method. Splitting it across files would have required either inheritance, composition, or method-as-attribute injection, and none of those were worth introducing for a refactor that was supposed to be a pure structural move with no logic changes.
      • info_panel.py: InfoPanel(QWidget) toggleable info panel.
      • log_handler.py: LogHandler(logging.Handler, QObject) Qt-aware logger adapter.
      • async_signals.py: AsyncSignals(QObject) signal hub for async worker results.
      • search_state.py: SearchState dataclass.
    • gui/preview.py (2273 lines) gone. Carved into:
      • preview_pane.py: ImagePreview(QWidget) embedded preview pane.
      • popout/window.py: FullscreenPreview(QMainWindow) popout. Initially a single 1136-line file; further carved by the popout state machine refactor below.
      • media/constants.py: VIDEO_EXTENSIONS, _is_video().
      • media/image_viewer.py: ImageViewer(QWidget) zoom/pan image viewer.
      • media/mpv_gl.py: _MpvGLWidget + _MpvOpenGLSurface.
      • media/video_player.py: VideoPlayer(QWidget) + _ClickSeekSlider.
      • popout/viewport.py: Viewport(NamedTuple) + _DRIFT_TOLERANCE.
    • Re-export shim pattern. Each move added a from .new_location import MovedClass # re-export for refactor compat line at the bottom of the old file so existing imports kept resolving the same class object during the migration. The final cleanup commit updated the importer call sites to canonical paths and deleted the now-empty app.py and preview.py.

    Bug fixes surfaced by the refactor

    The refactor's "manually verify after every commit" rule exposed 10 latent bugs that had been lurking in the original god-files. Every one of these is a preexisting issue, not something the refactor caused.

    • Browse multi-select reshape. Split library and bookmark actions into four distinct entries (Save All / Unsave All / Bookmark All / Remove All Bookmarks), each shown only when the selection actually contains posts the action would affect. The original combined action did both library and bookmark operations under a misleading bookmark-only label, with no way to bulk-unsave without also stripping bookmarks. The reshape resolves the actual need.
    • Infinite scroll page_size clamp. One-character fix at _on_reached_bottom's search_append.emit call site (collected becomes collected[:limit]) to mirror the non-infinite path's slice in _do_search. The backfill loop's >= break condition allowed the last full batch to push collected past the configured page size.
    • Batch download: incremental saved-dot updates and browse-tab-only gating. Two-part fix. (1) Stash the chosen destination, light saved-dots incrementally as each file lands when the destination is inside saved_dir(). (2) Disable the Batch Download menu and Ctrl+D shortcut on the Bookmarks and Library tabs, where it didn't make sense.
    • F11 round-trip preserves zoom and position. Two preservation bugs. (1) ImageViewer.resizeEvent no longer clobbers the user's explicit zoom and pan on F11 enter/exit; it uses event.oldSize() to detect whether the user was at fit-to-view at the previous size and only re-fits in that case. (2) The popout's F11 enter writes the current Hyprland window state directly into its viewport tracking so F11 exit lands at the actual pre-fullscreen position regardless of how the user got there (drag, drag+nav, drag+F11). The previous drift detection only fired during a fit and missed the "drag then F11 with no nav between" sequence.
    • Remove O keybind for Open in Default App. Five-line block deleted from the main keypress handler. Right-click menu actions stay; only the keyboard shortcut is gone.
    • Privacy screen resumes video on un-hide. _toggle_privacy now calls resume() on the active video player on the privacy-off branch, mirroring the existing pause() calls on the privacy-on branch. The popout's privacy overlay also moved from "hide the popout window" to "raise an in-place black overlay over the popout's central widget" because Wayland's hide → show round-trip drops window position when the compositor unmaps and remaps; an in-place overlay sidesteps the issue.
    • VideoPlayer mute state preservation. When the popout opens, the embedded preview's mute state was synced into the popout's VideoPlayer before the popout's mpv instance was created (mpv is wired lazily on first set_media). The sync silently disappeared because the is_muted setter only forwarded to mpv if mpv existed. Now there's a _pending_mute field that the setter writes to unconditionally; _ensure_mpv replays it into the freshly-created mpv. Same pattern as the existing volume-from-slider replay.
    • Search count + end-of-results instrumentation. _do_search and _on_reached_bottom now log per-filter drop counts (bl_tags, bl_posts, dedup), api_returned, kept, and the at_end decision at DEBUG level. Distinguishes "API ran out of posts" from "client-side filters trimmed the page" for the next reproduction. This is instrumentation, not a fix; the underlying intermittent end-of-results bug is still under investigation.

    Popout state machine refactor

    In the past two weeks, five popout race fixes had landed (baa910a, 5a44593, 7d19555, fda3b10, 31d02d3), each correct in isolation but fitting the same pattern: a perf round shifted timing, a latent race surfaced, a defensive layer was added. The pattern was emergent from the popout's signal-and-callback architecture, not from any one specific bug. Every defensive layer added a timestamp-based suppression window that the next race fix would have to navigate around.

    This release rebuilds the popout's internal state as an explicit state machine. The 1136-line FullscreenPreview god-class became a thin Qt adapter on top of a pure-Python state machine, with the historical race fixes enforced structurally instead of by suppression windows. 16 commits.

    The state machine has 6 states (AwaitingContent, DisplayingImage, LoadingVideo, PlayingVideo, SeekingVideo, Closing), 17 events, and 14 effects. The pure-Python core lives in popout/state.py and popout/effects.py and imports nothing from PySide6, mpv, or httpx. The Qt-side adapter in popout/window.py translates Qt events into state machine events and applies the returned effects to widgets; it never makes decisions about what to do.

    The race fixes that were timestamp windows in the previous code are now structural transitions:

    • EOF race. VideoEofReached is only legal in PlayingVideo. In every other state (most importantly LoadingVideo, where the stale-eof race lived), the event is dropped at the dispatch boundary without changing state or emitting effects. Replaces the 250ms _eof_ignore_until timestamp window that the previous code used to suppress stale eof events from a previous video's stop.
    • Double-load race. NavigateRequested from a media-bearing state transitions to AwaitingContent once. A second NavigateRequested while still in AwaitingContent re-emits the navigate signal but does not re-stop or re-load. The state machine never produces two LoadVideo / LoadImage effects for the same navigation cycle, regardless of how many NavigateRequested events the eventFilter dispatches.
    • Persistent viewport. The viewport (center + long_side) is a state machine field, only mutated by user-action events (WindowMoved, WindowResized, or HyprlandDriftDetected). Never overwritten by reading the previous fit's output. Replaces the per-nav drift accumulation that the previous "recompute viewport from current state" shortcut produced.
    • F11 round-trip. Entering fullscreen snapshots the current viewport into a separate pre_fullscreen_viewport field. Exiting restores from the snapshot. The pre-fullscreen viewport is the captured value at the moment of entering, regardless of how the user got there.
    • Seek slider pin. SeekingVideo state holds the user's click target. The slider rendering reads from the state machine: while in SeekingVideo, the displayed value is the click target; otherwise it's mpv's actual time_pos. SeekCompleted (from mpv's playback-restart event) transitions back to PlayingVideo. No timestamp window.
    • Pending mute. The mute / volume / loop_mode values are state machine fields. MuteToggleRequested flips the field regardless of which state the machine is in. The PlayingVideo entry handler emits [ApplyMute, ApplyVolume, ApplyLoopMode] so the persistent values land in the freshly-loaded video on every load cycle.

    The Qt adapter's interface to main_window.py was also cleaned up. Previously main_window.py reached into _fullscreen_window._video.X, _fullscreen_window._stack.currentIndex(), _fullscreen_window._bookmark_btn.setVisible(...), and similar private-attribute access at ~25 sites. Those are gone. Nine new public methods on FullscreenPreview replace them: 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. Existing methods (set_media, update_state, set_post_tags, privacy_hide, privacy_show) are preserved unchanged.

    A new debug environment variable BOORU_VIEWER_STRICT_STATE=1 raises an InvalidTransition exception on illegal (state, event) pairs in the state machine. Default release mode drops + logs at debug.

    Slider drag-back race fixed

    The slider's _seek method used mpv.seek(pos / 1000.0, 'absolute') (keyframe-only seek). On videos with sparse keyframes (typical 1-5s GOP), mpv lands on the nearest keyframe at-or-before the click position, which is up to 5 seconds behind where the user actually clicked. The 500ms pin window from the earlier fix sweep papered over this for half a second, but afterwards the slider visibly dragged back to mpv's keyframe-rounded position and crawled forward.

    • 'absolute' → 'absolute+exact' in VideoPlayer._seek. Aligns the slider with seek_to_ms and _seek_relative, which were already using exact seek. mpv decodes from the previous keyframe forward to the EXACT target position before reporting it via time_pos. Costs 30-100ms more per seek but lands at the exact click position. No more drag-back. Affects both the embedded preview and the popout because they share the VideoPlayer class.
    • Legacy 500ms pin window removed. Now redundant after the exact-seek fix. The supporting fields (_seek_target_ms, _seek_pending_until, _seek_pin_window_secs) are gone, _seek is one line, _poll's slider write is unconditional after the isSliderDown() check.

    Grid layout fix

    The grid was collapsing by a column when switching to a post in some scenarios. Two compounding issues.

    • The flow layout's wrap loop was vulnerable to per-cell width drift. Walked each thumb summing widget.width() + THUMB_SPACING and wrapped on x + item_w > self.width(). If THUMB_SIZE was changed at runtime via Settings, existing thumbs kept their old setFixedSize value while new ones from infinite-scroll backfill got the new value. Mixed widths break a width-summing wrap loop.
    • The columns property had an off-by-one at column boundaries because it omitted the leading margin from w // (THUMB_SIZE + THUMB_SPACING). A row that fits N thumbs needs THUMB_SPACING + N * step pixels, not N * step. The visible symptom was that keyboard Up/Down navigation step was off-by-one in the boundary range.
    • Fix. The flow layout now computes column count once via (width - THUMB_SPACING) // step and positions thumbs by (col, row) index, with no per-widget widget.width() reads. The columns property uses the EXACT same formula so keyboard nav matches the visual layout at every window width. Affects all three tabs (Browse / Bookmarks / Library) since they all use the same ThumbnailGrid.

    Other fixes

    These two landed right after v0.2.2 was tagged but before the structural refactor started.

    • Popout video load performance. mpv URL streaming for uncached videos via a new video_stream signal that hands the remote URL to mpv directly instead of waiting for the cache download to finish. mpv fast-load options vd_lavc_fast and vd_lavc_skiploopfilter=nonkey. GL pre-warm at popout open via a showEvent calling ensure_gl_init so the first video click doesn't pay for context creation. Identical-rect skip in _fit_to_content so back-to-back same-aspect navigation doesn't redundantly dispatch hyprctl. Plus three race-defense layers: pause-on-activate at the top of _on_post_activated, the 250ms stale-eof suppression window in VideoPlayer that the state machine refactor later subsumed, and removed redundant _update_fullscreen calls from _navigate_fullscreen and _on_video_end_next that were re-loading the previous post's path with a stale value.
    • Double-activation race fix in _navigate_preview. Removed a redundant _on_post_activated call from all five view types (browse, bookmarks normal, bookmarks wrap-edge, library normal, library wrap-edge). _select(idx) already chains through post_selected which already calls _on_post_activated, so calling it explicitly again was a duplicate that fired the activation handler twice per keyboard nav.
    Downloads