-
v0.3.4 Stable
released this
2026-09-30 19:17:19 -05:00 | 1 commits to main since this releasev0.3.4
Fixed
- Under Qt styles that draw a scroll area's frame around its contents only, the results grid's scroll bar sat outside the grid's border. Fusion and Qt's Windows style frame the scroll bar too, so it only showed with some third party styles. The base stylesheet now sets
scrollview-frame-around-contents: 0on scroll areas, so the scroll bar stays inside the frame under every style. Behavior change: scroll bar inside the browse area
Downloads
- Under Qt styles that draw a scroll area's frame around its contents only, the results grid's scroll bar sat outside the grid's border. Fusion and Qt's Windows style frame the scroll bar too, so it only showed with some third party styles. The base stylesheet now sets
-
v0.3.3 Stable
released this
2026-09-30 18:47:17 -05:00 | 3 commits to main since this releasev0.3.3
Changed
-
The preview and popout toolbar buttons are SVG icons instead of text glyphs (☆ ↓ ⊘ ⊗ ⧉). A glyph is drawn by whatever font the Qt style supplies, so its size changed with the theme: under AeroThemePlasma the blacklist glyphs came out large and the save arrow tiny. The icons keep one shape and size under every style and take their colour from the theme. Save is now a floppy disk, filled when the post is saved (like the filled star when bookmarked), and "blacklist this post" is an eye struck through. Behavior change: new toolbar icons
-
The top bar is one row. Browse, Bookmarks and Library were a second row of full width buttons; they are now equal width tabs at the left of the search row. Rating, media type and minimum score moved from three unlabelled controls (two of which both read "All") into a labelled Filters popup, and the Filters button shows how many are active. Behavior change: filters are one click deeper
-
Saving a search moved from a Save button beside Search, easy to take for a second submit button, into the dropdown at the right of the search field, where saved searches already live; it is greyed out while the field is empty. The dropdown arrow is an SVG instead of a text glyph
-
The rest of the text glyph icons are SVG too, so every icon keeps its size under any theme: the bookmarked star on grid thumbnails, the tick for selected sites in the Multi dropdown, the loop once button in the video controls, the grips in Edit Layout mode, the +/- Folder buttons in Bookmarks and the Settings steppers
-
Windows installer bundles the libmpv build of 2026-09-30 (3186d369f9), refreshed from 2026-09-22
Removed
- The Page box in the top bar. It was only ever reset to 1 and never read, so typing a page number there did nothing; paging is the Prev and Next bar under the results
Downloads
-
-
v0.3.2 Stable
released this
2026-09-23 00:07:05 -05:00 | 9 commits to main since this releasev0.3.2
Added
- Settings > General > "Use system widget style (restart required)", Linux only, off by default. With no custom.qss the app forced Fusion so the system-Qt (AUR) and bundled-Qt (pip) builds looked the same, which also discarded the desktop's widget style (Breeze, Oxygen); the palette already came through.
QT_STYLE_OVERRIDEand-stylenow skip the forced Fusion too, since an explicitsetStyle()outranked both. A custom.qss still forces Fusion (#2). Behavior change: opt-in; Fusion stays the default - The results, preview and info panels can be rearranged by drag and drop: any panel can be its own column in any order or be stacked above or below another (discussion #1, e.g. info | preview | results). View > Edit Layout (Ctrl+E) shows a grip on every panel. Panels never leave the main window, so it behaves the same on Wayland, X11 and Windows. Sizes are saved by panel name under new keys; the old splitter keys are read once to seed them and never written, so an older build still restores its own layout. Behavior change: the "Preview on left" setting is gone; its value seeds the first layout, so nobody's arrangement changes on upgrade
Changed
- Windows installer bundles the libmpv build of 2026-09-22 (c646756799), refreshed from 2026-08-28
Downloads
- Settings > General > "Use system widget style (restart required)", Linux only, off by default. With no custom.qss the app forced Fusion so the system-Qt (AUR) and bundled-Qt (pip) builds looked the same, which also discarded the desktop's widget style (Breeze, Oxygen); the palette already came through.
-
v0.3.1 Stable
released this
2026-08-28 23:10:24 -05:00 | 16 commits to main since this releasev0.3.1
Fixed
- Clicking a slow-loading post (typically a large video) and then another post could end with the first post taking over the preview once its download finished, because
image_donewas applied regardless of which post was selected. Each activation now issues a load ticket; a download that lands after a newer click is dropped (its progress bar too) instead of replacing the preview. The download still completes into the cache, so returning to that post is instant - With a custom.qss loaded, the preview and popout toolbar glyphs (☆ ↓ ⊘ ⊗ ⧉) rendered clipped, most visibly the bookmark star losing its points, and the four buttons came out at different widths. The buttons are fixed at 24x24 and every bundled theme gives
QPushButton6px of horizontal padding plus a 1px border, leaving a 10px content box; the popout toolbar additionally styles them 15px bold under an ID selector. The buttons now carry aniconBtnproperty and the base stylesheet pins their padding, geometry, and font size with attribute selectors that outrank the theme rules, in both the preview pane and the popout - On Windows and other floating window managers the main window crept upward and grew by one title-bar height on every launch until the title bar was off the top of the screen and the window could no longer be dragged. The non-Hyprland path saved
frameGeometry()(frame included) and restored it withsetGeometry()(client area only), so each round trip shifted the client rect to where the frame used to be. Save and restore now use the same client rect, a saved rect that is off-screen or larger than the display is discarded, and with nothing valid saved the window opens centered at up to 80% of the screen. Behavior change: first launch and recovered installs start centered and modest instead of edge-to-edge
Downloads
- Clicking a slow-loading post (typically a large video) and then another post could end with the first post taking over the preview once its download finished, because
-
v0.3.0 Stable
released this
2026-08-23 22:09:47 -05:00 | 24 commits to main since this releasev0.3.0
Fixed
- Every in-code Hyprland dispatch was a no-op on Hyprland 0.56+, which replaced the string dispatch API with a Lua one — the popout's aspect lock, resize, move, and un-float-on-reopen all silently stopped working, indistinguishable from running with both opt-out env vars permanently set.
hyprctlexits 0 on the resulting Lua parse error, so nothing could detect it by return code. The app now probes the compositor once with a read-onlyhyprctl evaland emits whichever dialect it speaks; pre-0.56 builds keep the legacy strings. Behavior change: popout geometry and aspect lock work again on current Hyprland - The saved-locally dot was cross-site wrong:
library_metawas keyed onpost_idalone, so a post saved from one booru showed as saved on any other booru with the same post id. The library is now keyed on(site_id, post_id), and auser_version2 migration recovers each existing row's site offline from itsfile_urlhost. Behavior change: the saved-dot is per site - Saving a post whose id matched one already in the library from a different booru was treated as a re-save: the file was never written and the UI reported success. The save flow's identity check now compares
(site_id, post_id), so the existing collision resolver writes12345_1.jpginstead of dropping the image. Behavior change: same-id posts from different boorus both save
Added
%site%filename template token, alongside%artist%,%character%,%copyright%,%general%,%meta%,%species%- The preview info panel shows which site a post came from
- Right-click menu on info-panel tags: Search this tag, Add this tag
to search, Blacklist this tag - Multi-site search: a Multi tickbox turns the site selector into a
checkable list; results from the ticked sites interleave round-robin,
the info panel names each post's source site, and a failing site is
dropped for that page and named in the status bar instead of sinking
the whole search.
Downloads
- Every in-code Hyprland dispatch was a no-op on Hyprland 0.56+, which replaced the string dispatch API with a Lua one — the popout's aspect lock, resize, move, and un-float-on-reopen all silently stopped working, indistinguishable from running with both opt-out env vars permanently set.
-
v0.2.9 Stable
released this
2026-08-19 23:03:04 -05:00 | 51 commits to main since this releasev0.2.9
Fixed
- Sites whose
__batch_api_probe__sentinel was poisoned tofalseby a pre-0.2.8 transient network error now recover automatically. v0.2.8 stopped new poisoning but left existing rows stuck —CategoryFetcher._do_ensuresends aFalsestraight to the HTML scrape and never re-probes, so Settings → Clear Tag Cache was a required manual step on upgrade that users had no way to know about. A one-shot migration (PRAGMA user_version0 → 1) drops only thefalsesentinels; confirmed-workingtruerows are kept. Gated rather than unconditional because a bareDELETEwould make a genuinely broken tag API re-probe on every launch
Downloads
- Sites whose
-
v0.2.8 Stable
released this
2026-08-19 22:26:14 -05:00 | 55 commits to main since this releasev0.2.8
Added
- Settings → Cache: Clear Tag Cache button — wipes the per-site
tag_typesrows (including the__batch_api_probe__sentinel) so Gelbooru/Moebooru backends re-probe and re-populate tag categories from scratch. Useful when a stale cache from an earlier build leaves some category types mis-labelled or missing
Changed
- Windows installer now ships
licenses\LGPL-2.1.txtandlicenses\THIRD-PARTY-NOTICES.txtalongside the app, naming the exact libmpv build, its source and checksum, and how to swap the DLL — the attribution and relink terms LGPL section 6 asks for when redistributing the library - Windows installer now bundles the LGPL build of libmpv instead of the GPL build. booru-viewer decodes video and never encodes it, so the GPL-only x264/x265 encoders were unused weight in an MIT-licensed app; H.264/HEVC/AV1/VP9 decoding is unaffected
- Thumbnail drag-start threshold raised from 10px to 30px to match the rubber band's gate — small mouse wobbles on a thumb no longer trigger a file drag
- Settings → Cache layout: Clear Tag Cache moved into row 1 alongside Clear Thumbnails and Clear Image Cache as a 3-wide non-destructive row; destructive Clear Everything + Evict stay in row 2
Fixed
- rule34.xxx misdetected as
danbooru, then failing Test Connection withHTTP 403 Forbidden. rule34 now serves a WAF HTML block page (403) at/posts.json, anddetect_site_typeread any 401/403 there as "Danbooru behind auth". The Danbooru and Moebooru probes now only treat 401/403 as conclusive when the body is actually JSON, so WAF-guarded Gelbooru-family hosts fall through to the Gelbooru probe. Behavior change: rule34.xxx detects asgelbooru - Gelbooru-shape sites answering HTTP 200 with a bare JSON string were reported as a search with zero results. rule34 now requires
api_key+user_idand replies"Missing authentication..."at 200, whichraise_for_status()cannot see;GelbooruClientnow raisesBooruAuthErrorinstead, and the Gelbooru probe treats that shape as a positive match. Behavior change: a missing or rejected API key surfaces as an error rather than an empty grid - Grid blanked out after splitter drag or tile/float toggle until the next scroll —
ThumbnailGrid.resizeEventnow re-runs_recycle_offscreenagainst the new geometry so thumbs whose pixmap was evicted by a column-count shift get refreshed into view. Behavior change: no more blank grid after resize - Status bar overwrote the per-post info set by
_on_post_selectedwith"N results — Loaded"the moment the image finished downloading, hiding tag counts / post ID until the user re-clicked;on_image_donenow preserves the incominginfostring category_fetcher._do_ensureno longer permanently flips_batch_api_worksto False when a transient network error drops a tag-API request mid-call; the unprobed path now routes through_probe_batch_api, which distinguishes clean 200-with-zero-matches (structurally broken, flip) from timeout/HTTP-error (transient, retry next call)- Bookmark→library save and bookmark Save As now plumb the active site's
CategoryFetcherthrough to the filename template, so%artist%/%character%tokens render correctly instead of silently dropping out when saving a post that wasn't previewed first - Info panel no longer silently drops tags that failed to land in a cached category — any tag from
post.tag_listnot rendered under a known category section now appears in an "Other" bucket, so partial cache coverage can't make individual tags invisible BooruClient._requestretries now coverhttpx.RemoteProtocolErrorandhttpx.ReadErrorin addition to the existing timeout/connect/network set — an overloaded booru that drops the TCP connection mid-response no longer fails the whole search on the first try- VRAM retained when no video is playing —
stop()now frees the GL render context (textures + FBOs) instead of just dropping the hwdec surface pool. Context is recreated lazily on nextplay_file()viaensure_gl_init()(~5ms, invisible behind network fetch)
Refactored
category_fetcherbatch tag-API params are now built by a shared_build_tag_api_paramshelper instead of duplicated acrossfetch_via_tag_apiand_probe_batch_apidetect.detect_site_type— removed the leftoverif True:indent marker; no behavior changecore.http.make_client— single constructor for the threehttpx.AsyncClientinstances (cache download pool, API pool, detect probe). Each call site still keeps its own singleton and connection pool; only the construction is shared- Silent
except: passsites inpopout/window,video_player, andwindow_statenow carry one-line comments naming the absorbed failure and the graceful fallback (or were downgraded tolog.debug(..., exc_info=True)). No behavior change - Popout docstrings purged of in-flight-refactor commit markers (
skeleton,14a,14b,future commit) that referred to now-landed state-machine extraction; load-bearing commit 14b reference kept in_dispatch_and_applyas it still protects against reintroducing the bug core/cache.pytempfile cleanup:BaseExceptioncatch now documents why it's intentionally broader thanExceptionapi/e621andapi/moebooruJSON parse guards narrowed from bareexcepttoValueErrorgui/media/video_player.py—import timehoisted to module topgui/post_actions.is_in_library— deadtry/exceptstripped
Removed
- Unused
Favoritealias incore/db.py— callers migrated toBookmarkin 0.2.5, nothing referenced the fallback anymore
Downloads
- Settings → Cache: Clear Tag Cache button — wipes the per-site
-
v0.2.7 Stable
released this
2026-04-14 19:03:22 -05:00 | 90 commits to main since this releasev0.2.7
Fixed
- Popout always reopened as floating even when tiled at close — Hyprland tiled state is now persisted and restored via
settiledon reopen - Video stutter on network streams —
cache_pause_initialwas blocking first frame, reverted cache_pause changes and kept larger demuxer buffer - Rubber band selection state getting stuck across interrupted drags
- LIKE wildcards in
search_library_metanot being escaped - Copy File to Clipboard broken in preview pane and popout; added Copy Image URL action
- Thumbnail cleanup and Post ID sort broken for templated filenames in library
- Save/unsave bookmark UX — no flash on toggle, correct dot indicators
- Autocomplete broken for multi-tag queries
- Search not resetting to page 1 on new query
- Fade animation cleanup crashing
FlowLayout.clear - Privacy toggle not preserving video pause state
- Bookmarks grid not refreshing on unsave
_cached_pathnot set for streaming videos- Standard icon column showing in QMessageBox dialogs
- Popout aspect lock for bookmarks now reads actual image dimensions instead of guessing
- GPU resource leak on Mesa/Intel drivers —
mpv_render_context_freenow runs with the owning GL context current (NVIDIA tolerated the bug, other drivers did not) - Popout teardown
AttributeErrorwhencentralWidget()orQApplication.instance()returnedNoneduring init/shutdown race - Category fetcher rejects XML responses containing
<!DOCTYPEor<!ENTITYbefore parsing, blocking XXE and billion-laughs payloads from user-configured sites - VRAM not released on popout close —
video_playernow drops the hwdec surface pool on stop and popout runs explicit mpv cleanup before teardown - Popout open animation was being suppressed by the
no_animaspect-lock workaround — first fit after open now lets Hyprland'swindowsIn/popinplay; subsequent navigation fits still suppress anim to avoid resize flicker - Thumbnail grid blanking out after Hyprland tiled resize until a scroll/click — viewport is now force-updated at the end of
ThumbnailGrid.resizeEventso the Qt Wayland buffer stays in sync with the new geometry - Library video thumbnails captured from a black opening frame — mpv now seeks to 10% before the first frame decode so title cards, fade-ins, and codec warmup no longer produce a black thumbnail (delete
~/.cache/booru-viewer/thumbnails/library/to regenerate existing entries)
Changed
- Uncached videos now download via httpx in parallel with mpv streaming — file is cached immediately for copy/paste without waiting for playback to finish
- Library video thumbnails use mpv instead of ffmpeg — drops the ffmpeg dependency entirely
- Save/Unsave from Library mutually exclusive in context menus, preview pane, and popout
- S key guard consistent with B/F behavior
- Tag count limits removed from info panel
- Ctrl+S and Ctrl+D menu shortcuts removed (conflict-prone)
- Thumbnail fade-in shortened from 200ms to 80ms
- Default demuxer buffer reduced to 50MiB; streaming URLs still get 150MiB
- Minimum width set on thumbnail grid
- Popout overlay hover zone enlarged
- Settings dialog gets an Apply button; thumbnail size and flip layout apply live
- Tab selection preserved on view switch
- Scroll delta accumulated for volume control and zoom (smoother with hi-res scroll wheels)
- Force Fusion widget style when no
custom.qssis present - Dark Fusion palette applied as fallback when no system Qt theme file (
Trolltech.conf) is detected; KDE/GNOME users keep their own palette - Behavior change: popout re-fits window to current content's aspect and resets zoom when leaving a tiled layout to a different-aspect image or video; previously restored the old floating geometry with the wrong aspect lock
Performance
- Thumbnails re-decoded from disk on size change instead of holding full pixmaps in memory
- Off-screen thumbnail pixmaps recycled (decoded on demand from cached path)
- Lookup sets cached across infinite scroll appends; invalidated on bookmark/save
auto_evict_cachethrottled to once per 30s- Stale prefetch spirals cancelled on new click
- Single-pass directory walk in cache eviction functions
- GTK dialog platform detection cached instead of recreating Database per call
Removed
- Dead code:
core/images.py TODO.md- Unused imports across
main_window,grid,settings,dialogs,sites,search_controller,video_player,info_panel - Dead
midvariable ingrid.paintEvent, deadget_connection_logimport insettings._build_network_tab
Downloads
- Popout always reopened as floating even when tiled at close — Hyprland tiled state is now persisted and restored via
-
v0.2.6 Stable
released this
2026-04-11 16:45:40 -05:00 | 182 commits to main since this releasev0.2.6
Security: 2026-04-10 audit remediation
Closes 12 of the 16 findings from the read-only audit at
docs/SECURITY_AUDIT.md. Two High, four Medium, four Low, and two Informational findings fixed; the four skipped Informational items are documented at the bottom. Each fix is its own commit on thesecurity/audit-2026-04-10branch with anAudit-Ref:trailer.- #1 SSRF (High): every httpx client now installs an event hook that resolves the target host and rejects loopback, RFC1918, link-local (including the 169.254.169.254 cloud-metadata endpoint), CGNAT, unique-local v6, and multicast. Hook fires on every redirect hop, not just the initial request. Behavior change: user-configured boorus pointing at private/loopback addresses now fail with
blocked request target ...instead of being probed. Test Connection on a local booru will be rejected. - #2 mpv (High): the embedded mpv instance is constructed with
ytdl=no,load_scripts=no, anddemuxer_lavf_o=protocol_whitelist=file,http,https,tls,tcp, plusinput_conf=/dev/nullon POSIX. Closes the yt-dlp delegation surface (CVE-prone extractors invoked on attacker-supplied URLs) and theconcat:/subfile:local-file-read gadget via ffmpeg's lavf demuxer. Behavior change: anyfile_urlwhose host is only handled by yt-dlp (youtube.com, reddit.com, ...) no longer plays. Boorus do not legitimately serve such URLs, so in practice this only affects hostile responses. - #3 Credential logging (Medium):
login,api_key,user_id, andpassword_hashare now stripped from URLs and params before any logging path emits them. Single redaction helper incore/api/_safety.py, called from the booru-base request hook and from each per-clientlog.debugline. - #4 DB + data dir permissions (Medium): on POSIX,
~/.local/share/booru-viewer/is now0o700andbooru.db(plus the-wal/-shmsidecars) is0o600. Behavior change: existing installs are tightened on next launch. Windows is unchanged — NTFS ACLs handle this separately. - #5 Lock leak (Medium): the per-URL coalesce lock table is capped at 4096 entries with LRU eviction. Eviction skips currently-held locks so a coroutine mid-
async withcan't be ripped out from under itself. - #6 HTML injection (Medium):
post.sourceis escaped before insertion into the info-panel rich text. Non-http(s) sources (includingjavascript:anddata:) render as plain escaped text without an<a>tag, so they can't become click targets. - #7 Windows reserved names (Low):
render_filename_templatenow prefixes filenames whose stem matches a reserved Windows device name (CON,PRN,AUX,NUL,COM1-9,LPT1-9) with_, regardless of host platform. Cross-OS library copies stay safe. - #8 PIL bomb cap (Low):
Image.MAX_IMAGE_PIXELS=256Mmoved fromcore/cache.py(where it was a side-effect of import order) tocore/__init__.py, so anybooru_viewer.core.*import installs the cap first. - #9 Dependency bounds (Low): upper bounds added to runtime deps in
pyproject.toml(httpx<1.0,Pillow<12.0,PySide6<7.0,python-mpv<2.0). Lock-file generation deferred — seeTODO.md. - #10 Early content validation (Low):
_do_downloadnow accumulates the first 16 bytes of the response and validates magic bytes before committing to writing the rest. A hostile server omitting Content-Type previously could burn up toMAX_DOWNLOAD_BYTES(500MB) of bandwidth before the post-download check rejected. - #14 Category fetcher body cap (Informational): HTML body the regex walks over in
CategoryFetcher.fetch_postis truncated at 2MB. Defense in depth — the regex is linear-bounded but a multi-MB hostile body still pegs CPU. - #16 Logging hook gap (Informational): e621 and detect_site_type clients now install the
_log_requesthook so their requests appear in the connection log alongside the base client. Absorbed into the #1 wiring commits since both files were already being touched.
Skipped (Wontfix), with reason:
- #11 64-bit hash truncation: not exploitable in practice (audit's own words). Fix would change every cache path and require a migration.
- #12 Referer leak through CDN redirects: intentional — booru CDNs gate downloads on Referer matching. Documented; not fixed.
- #13 hyprctl batch joining: user is trusted in the threat model and Hyprland controls the field. Informational only.
- #15 dead code in
core/images.py: code quality, not security. Out of scope under the no-refactor constraint. Logged inTODO.md.
Downloads
- #1 SSRF (High): every httpx client now installs an event hook that resolves the target host and rejects loopback, RFC1918, link-local (including the 169.254.169.254 cloud-metadata endpoint), CGNAT, unique-local v6, and multicast. Hook fires on every redirect hop, not just the initial request. Behavior change: user-configured boorus pointing at private/loopback addresses now fail with
-
v0.2.5 Stable
released this
2026-04-10 21:25:40 -05:00 | 221 commits to main since this releasev0.2.5
Full UI overhaul (icon buttons, compact top bar, responsive video controls), popout resize-pivot anchor, layout flip, and the main_window.py controller decomposition.
Changes since v0.2.4
Refactor: main_window.py controller decomposition
main_window.pywent from a 3,318-line god-class to a 1,164-line coordinator plus 7 controller modules. Every other subsystem in the codebase had already been decomposed (popout state machine, library save, category fetcher) — BooruApp was the last monolith. 11 commits, pure refactor, no behavior change. Design doc atdocs/MAIN_WINDOW_REFACTOR.md.- New
gui/window_state.py(293 lines) — geometry persistence, Hyprland IPC, splitter savers. - New
gui/privacy.py(66 lines) — privacy overlay toggle + popout coordination. - New
gui/search_controller.py(572 lines) — search orchestration, infinite scroll, backfill, blacklist filtering, tag building, autocomplete, thumbnail fetching. - New
gui/media_controller.py(273 lines) — image/video loading, prefetch, download progress, video streaming fast-path, cache eviction. - New
gui/popout_controller.py(204 lines) — popout lifecycle (open/close), state sync, geometry persistence, navigation delegation. - New
gui/post_actions.py(561 lines) — bookmarks, save/library, batch download, unsave, bulk ops, blacklist actions from popout. - New
gui/context_menus.py(246 lines) — single-post and multi-select context menu building + dispatch. - Controller-pattern: each takes
app: BooruAppvia constructor, accesses app internals as trusted collaborator viaself._app. No mixins, no ABC, no dependency injection — just plain classes with one reference each.TYPE_CHECKINGimport forBooruAppavoids circular imports at runtime. - Cleaned up 14 dead imports from
main_window.py. - The
_fullscreen_windowreference (52 sites across the codebase) was fully consolidated intoPopoutController.window. No file outsidepopout_controller.pytouches_fullscreen_windowdirectly anymore.
New: Phase 2 test suite (64 tests for extracted pure functions)
Each controller extraction also pulled decision-making code out into standalone module-level functions that take plain data in and return plain data out. Controllers call those functions; tests import them directly. Same structural forcing function as the popout state machine tests — the test files fail to collect if anyone adds a Qt import to a tested module.
tests/gui/test_search_controller.py(24 tests):build_search_tagsrating/score/media filter mapping per API type,filter_postsblacklist/dedup/seen-ids interaction,should_backfilltermination conditions.tests/gui/test_window_state.py(16 tests):parse_geometry/format_geometryround-trip,parse_splitter_sizesvalidation edge cases,build_hyprctl_restore_cmdsfor every floating/tiled permutation including the no_anim priming path.tests/gui/test_media_controller.py(9 tests):compute_prefetch_orderfor Nearby (cardinals) and Aggressive (ring expansion) modes, including bounds, cap, and dedup invariants.tests/gui/test_post_actions.py(10 tests):is_batch_messageprogress-pattern detection,is_in_librarypath-containment check.tests/gui/test_popout_controller.py(3 tests):build_video_sync_dictshape.- Total suite: 186 tests (57 core + 65 popout state machine + 64 new controller pure functions), ~0.3s runtime, all import-pure.
- PySide6 imports in controller modules were made lazy (inside method bodies) so the Phase 2 tests can collect on CI, which only installs
httpx,Pillow, andpytest.
UI overhaul: icon buttons and responsive layout
Toolbar and video controls moved from fixed-width text buttons to 24x24 icon buttons. Preview toolbar uses Unicode symbols (☆/★ bookmark, ↓/✕ save, ⊘ blacklist tag, ⊗ blacklist post, ⧉ popout) — both the embedded preview and the popout toolbar share the same object names (
#_tb_bookmark,#_tb_save,#_tb_bl_tag,#_tb_bl_post,#_tb_popout) so one QSS rule styles both. Video controls (play/pause, mute, loop, autoplay) render via QPainter using the palette'sbuttonTextcolor so they match any theme automatically, with1×as bold text for the Once loop state.- Responsive video controls bar: hides volume slider below 320px, duration label below 240px, current time label below 200px. Play/pause/seek/mute/loop always visible.
- Compact top bar: combos use
AdjustToContents, 3px spacing, top/nav bars wrapped in#_top_bar/#_nav_barnamed containers for theme targeting. - Main window minimum size dropped from 900x600 to 740x400 — the hard floor was blocking Hyprland's keyboard resize mode on narrow floating windows.
- Preview pane minimum width dropped from 380 to 200.
- Info panel title + details use
QSizePolicy.Ignoredhorizontally so long source URLs wrap within the splitter instead of pushing it wider.
New: popout anchor setting (resize pivot)
Combo in Settings > General. Controls which point of the popout window stays fixed across navigations as the aspect ratio changes:
Center(default, pins window center), or one of the four corners (pins that corner, window grows/shrinks from the opposite corner). The user can still drag the window anywhere — the anchor only controls the resize direction, not the screen position. Works on all platforms; on Hyprland the hyprctl dispatch path is used, elsewhere Qt'ssetGeometryfallback handles the same math.Viewport.center_x/center_yrepurposed as anchor point coordinates — in center mode it's the window center, in corner modes it's the pinned corner. Newanchor_point()helper inviewport.pyextracts the right point from a window rect based on mode._compute_window_rectbranches on anchor: center mode keeps the existing symmetric math, corner modes derive position from the anchor point + the new size.- Hyprland monitor reserved-area handling: reads
reservedfromhyprctl monitors -jso window positioning respects Waybar's exclusive zone (Qt'sscreen.availableGeometry()doesn't see layer-shell reservations on Wayland).
New: layout flip setting
Checkbox in Settings > General (restart required). Swaps the main splitter — preview+info panel on the left, grid on the right. Useful for left-handed workflows or multi-monitor setups where you want the preview closer to your other reference windows.
New: thumbnail fade-in animation
Thumbnails animate from 0 to 1 opacity over 200ms (OutCubic easing) as they load. Uses a
QPropertyAnimationon athumbOpacityQt Property applied inpaintEvent. The animation is stored on the widget instance to prevent Python garbage collection before the Qt event loop runs it.New: B / F / S keyboard shortcuts
BorF— toggle bookmark on the selected post (works in main grid and popout).S— toggle save to library (Unfiled). If already saved, unsaves. Works in main grid and popout.- The popout gained a new
toggle_save_requestedsignal that routes to a sharedPostActionsController.toggle_save_from_previewso both paths use the same toggle logic.
UX: grid click behavior
- Clicking empty grid space (blue area around thumbnails, cell padding outside the pixmap, or the 2px gaps between cells) deselects everything. Cell padding clicks work via a direct parent-walk from
ThumbnailWidget.mousePressEventto the grid — Qt event propagation throughQScrollAreaswallows events too aggressively to rely on. - Rubber band drag selection now works from any empty space — not just the 2px gaps. 30px manhattan threshold gates activation so single clicks on padding just deselect without flashing a zero-size rubber band.
- Hover highlight only appears when the cursor is actually over the pixmap, not the cell padding. Uses the same
_hit_pixmaphit-test as clicks. Cursor swaps between pointing-hand (over pixmap) and arrow (over padding) viamouseMoveEventtracking. - Clicking an already-showing post no longer restarts the video (fixes the click-to-drag case where the drag-start click was restarting mpv).
- Escape clears the grid selection.
- Stuck forbidden cursor after cancelled drag-and-drop is reset on mouse release. Stuck hover states on Wayland fast-exits are force-cleared in
ThumbnailGrid.leaveEvent.
Themes
All 12 bundled QSS themes were trimmed and regenerated:
- Removed 12 dead selector groups that the app never instantiates:
QRadioButton,QToolButton,QToolBar,QDockWidget,QTreeView/QTreeWidget,QTableView/QTableWidget,QHeaderView,QDoubleSpinBox,QPlainTextEdit,QFrame. - Popout overlay buttons now use
font-size: 15px; font-weight: boldso the icon symbols read well against the translucent-black overlay. themes/README.mddocuments the new#_tb_*toolbar button object names and the popout overlay styling. Removed the old Nerd Font remapping note — QSS can't change button text, so that claim was incorrect.
Downloads
- New