-
v0.2.3 Stable
released this
2026-04-09 05:15:16 +00:00 | 292 commits to main since this releaseA refactor + cleanup release. The two largest source files (
gui/app.py3608 lines +gui/preview.py2273 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.pywas 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.pywas 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()(@palettepreprocessor),_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:SearchStatedataclass.
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 compatline 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-emptyapp.pyandpreview.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'ssearch_append.emitcall site (collectedbecomescollected[: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.resizeEventno longer clobbers the user's explicit zoom and pan on F11 enter/exit; it usesevent.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_privacynow callsresume()on the active video player on the privacy-off branch, mirroring the existingpause()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
VideoPlayerbefore the popout's mpv instance was created (mpv is wired lazily on firstset_media). The sync silently disappeared because theis_mutedsetter only forwarded to mpv if mpv existed. Now there's a_pending_mutefield that the setter writes to unconditionally;_ensure_mpvreplays it into the freshly-created mpv. Same pattern as the existing volume-from-slider replay. - Search count + end-of-results instrumentation.
_do_searchand_on_reached_bottomnow log per-filter drop counts (bl_tags,bl_posts,dedup),api_returned,kept, and theat_enddecision 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
FullscreenPreviewgod-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 inpopout/state.pyandpopout/effects.pyand imports nothing from PySide6, mpv, or httpx. The Qt-side adapter inpopout/window.pytranslates 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.
VideoEofReachedis only legal inPlayingVideo. In every other state (most importantlyLoadingVideo, 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_untiltimestamp window that the previous code used to suppress stale eof events from a previous video's stop. - Double-load race.
NavigateRequestedfrom a media-bearing state transitions toAwaitingContentonce. A secondNavigateRequestedwhile still inAwaitingContentre-emits the navigate signal but does not re-stop or re-load. The state machine never produces twoLoadVideo/LoadImageeffects for the same navigation cycle, regardless of how manyNavigateRequestedevents the eventFilter dispatches. - Persistent viewport. The viewport (center + long_side) is a state machine field, only mutated by user-action events (
WindowMoved,WindowResized, orHyprlandDriftDetected). 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_viewportfield. 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.
SeekingVideostate holds the user's click target. The slider rendering reads from the state machine: while inSeekingVideo, the displayed value is the click target; otherwise it's mpv's actualtime_pos.SeekCompleted(from mpv'splayback-restartevent) transitions back toPlayingVideo. No timestamp window. - Pending mute. The mute / volume / loop_mode values are state machine fields.
MuteToggleRequestedflips the field regardless of which state the machine is in. ThePlayingVideoentry 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.pywas also cleaned up. Previouslymain_window.pyreached 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 onFullscreenPreviewreplace 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=1raises anInvalidTransitionexception on illegal (state, event) pairs in the state machine. Default release mode drops + logs at debug.Slider drag-back race fixed
The slider's
_seekmethod usedmpv.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'inVideoPlayer._seek. Aligns the slider withseek_to_msand_seek_relative, which were already using exact seek. mpv decodes from the previous keyframe forward to the EXACT target position before reporting it viatime_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 theVideoPlayerclass.- 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,_seekis one line,_poll's slider write is unconditional after theisSliderDown()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_SPACINGand wrapped onx + item_w > self.width(). IfTHUMB_SIZEwas changed at runtime via Settings, existing thumbs kept their oldsetFixedSizevalue while new ones from infinite-scroll backfill got the new value. Mixed widths break a width-summing wrap loop. - The
columnsproperty had an off-by-one at column boundaries because it omitted the leading margin fromw // (THUMB_SIZE + THUMB_SPACING). A row that fits N thumbs needsTHUMB_SPACING + N * steppixels, notN * 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) // stepand positions thumbs by(col, row)index, with no per-widgetwidget.width()reads. Thecolumnsproperty 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 sameThumbnailGrid.
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_streamsignal that hands the remote URL to mpv directly instead of waiting for the cache download to finish. mpv fast-load optionsvd_lavc_fastandvd_lavc_skiploopfilter=nonkey. GL pre-warm at popout open via ashowEventcallingensure_gl_initso the first video click doesn't pay for context creation. Identical-rect skip in_fit_to_contentso 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_fullscreencalls from_navigate_fullscreenand_on_video_end_nextthat were re-loading the previous post's path with a stale value. - Double-activation race fix in
_navigate_preview. Removed a redundant_on_post_activatedcall from all five view types (browse, bookmarks normal, bookmarks wrap-edge, library normal, library wrap-edge)._select(idx)already chains throughpost_selectedwhich already calls_on_post_activated, so calling it explicitly again was a duplicate that fired the activation handler twice per keyboard nav.
Downloads