• v0.3.4 0fd30969a3

    v0.3.4 Stable

    pax released this 2026-09-30 19:17:19 -05:00 | 1 commits to main since this release

    v0.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: 0 on scroll areas, so the scroll bar stays inside the frame under every style. Behavior change: scroll bar inside the browse area
    Downloads
  • v0.3.3 eb75e0685a

    v0.3.3 Stable

    pax released this 2026-09-30 18:47:17 -05:00 | 3 commits to main since this release

    v0.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 8252514467

    v0.3.2 Stable

    pax released this 2026-09-23 00:07:05 -05:00 | 9 commits to main since this release

    v0.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_OVERRIDE and -style now skip the forced Fusion too, since an explicit setStyle() 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
  • v0.3.1 876c3c5662

    v0.3.1 Stable

    pax released this 2026-08-28 23:10:24 -05:00 | 16 commits to main since this release

    v0.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_done was 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 QPushButton 6px 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 an iconBtn property 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 with setGeometry() (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
  • v0.3.0 f082bf56d7

    v0.3.0 Stable

    pax released this 2026-08-23 22:09:47 -05:00 | 24 commits to main since this release

    v0.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. hyprctl exits 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-only hyprctl eval and 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_meta was keyed on post_id alone, 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 a user_version 2 migration recovers each existing row's site offline from its file_url host. 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 writes 12345_1.jpg instead 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
  • v0.2.9 d81fb7f3ec

    v0.2.9 Stable

    pax released this 2026-08-19 23:03:04 -05:00 | 51 commits to main since this release

    v0.2.9

    Fixed

    • Sites whose __batch_api_probe__ sentinel was poisoned to false by a pre-0.2.8 transient network error now recover automatically. v0.2.8 stopped new poisoning but left existing rows stuck — CategoryFetcher._do_ensure sends a False straight 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_version 0 → 1) drops only the false sentinels; confirmed-working true rows are kept. Gated rather than unconditional because a bare DELETE would make a genuinely broken tag API re-probe on every launch
    Downloads
  • v0.2.8 8eba84a63a

    v0.2.8 Stable

    pax released this 2026-08-19 22:26:14 -05:00 | 55 commits to main since this release

    v0.2.8

    Added

    • Settings → Cache: Clear Tag Cache button — wipes the per-site tag_types rows (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.txt and licenses\THIRD-PARTY-NOTICES.txt alongside 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 with HTTP 403 Forbidden. rule34 now serves a WAF HTML block page (403) at /posts.json, and detect_site_type read 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 as gelbooru
    • 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_id and replies "Missing authentication..." at 200, which raise_for_status() cannot see; GelbooruClient now raises BooruAuthError instead, 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.resizeEvent now re-runs _recycle_offscreen against 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_selected with "N results — Loaded" the moment the image finished downloading, hiding tag counts / post ID until the user re-clicked; on_image_done now preserves the incoming info string
    • category_fetcher._do_ensure no longer permanently flips _batch_api_works to 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 CategoryFetcher through 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_list not rendered under a known category section now appears in an "Other" bucket, so partial cache coverage can't make individual tags invisible
    • BooruClient._request retries now cover httpx.RemoteProtocolError and httpx.ReadError in 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 next play_file() via ensure_gl_init() (~5ms, invisible behind network fetch)

    Refactored

    • category_fetcher batch tag-API params are now built by a shared _build_tag_api_params helper instead of duplicated across fetch_via_tag_api and _probe_batch_api
    • detect.detect_site_type — removed the leftover if True: indent marker; no behavior change
    • core.http.make_client — single constructor for the three httpx.AsyncClient instances (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: pass sites in popout/window, video_player, and window_state now carry one-line comments naming the absorbed failure and the graceful fallback (or were downgraded to log.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_apply as it still protects against reintroducing the bug
    • core/cache.py tempfile cleanup: BaseException catch now documents why it's intentionally broader than Exception
    • api/e621 and api/moebooru JSON parse guards narrowed from bare except to ValueError
    • gui/media/video_player.py — import time hoisted to module top
    • gui/post_actions.is_in_library — dead try/except stripped

    Removed

    • Unused Favorite alias in core/db.py — callers migrated to Bookmark in 0.2.5, nothing referenced the fallback anymore
    Downloads
  • v0.2.7 289e4c2fdb

    v0.2.7 Stable

    pax released this 2026-04-14 19:03:22 -05:00 | 90 commits to main since this release

    v0.2.7

    Fixed

    • Popout always reopened as floating even when tiled at close — Hyprland tiled state is now persisted and restored via settiled on reopen
    • Video stutter on network streams — cache_pause_initial was 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_meta not 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_path not 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_free now runs with the owning GL context current (NVIDIA tolerated the bug, other drivers did not)
    • Popout teardown AttributeError when centralWidget() or QApplication.instance() returned None during init/shutdown race
    • Category fetcher rejects XML responses containing <!DOCTYPE or <!ENTITY before parsing, blocking XXE and billion-laughs payloads from user-configured sites
    • VRAM not released on popout close — video_player now drops the hwdec surface pool on stop and popout runs explicit mpv cleanup before teardown
    • Popout open animation was being suppressed by the no_anim aspect-lock workaround — first fit after open now lets Hyprland's windowsIn/popin play; 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.resizeEvent so 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.qss is 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_cache throttled 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 mid variable in grid.paintEvent, dead get_connection_log import in settings._build_network_tab
    Downloads
  • v0.2.6 af0d8facb8

    v0.2.6 Stable

    pax released this 2026-04-11 16:45:40 -05:00 | 182 commits to main since this release

    v0.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 the security/audit-2026-04-10 branch with an Audit-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, and demuxer_lavf_o=protocol_whitelist=file,http,https,tls,tcp, plus input_conf=/dev/null on POSIX. Closes the yt-dlp delegation surface (CVE-prone extractors invoked on attacker-supplied URLs) and the concat:/subfile: local-file-read gadget via ffmpeg's lavf demuxer. Behavior change: any file_url whose 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, and password_hash are now stripped from URLs and params before any logging path emits them. Single redaction helper in core/api/_safety.py, called from the booru-base request hook and from each per-client log.debug line.
    • #4 DB + data dir permissions (Medium): on POSIX, ~/.local/share/booru-viewer/ is now 0o700 and booru.db (plus the -wal/-shm sidecars) is 0o600. 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 with can't be ripped out from under itself.
    • #6 HTML injection (Medium): post.source is escaped before insertion into the info-panel rich text. Non-http(s) sources (including javascript: and data:) render as plain escaped text without an <a> tag, so they can't become click targets.
    • #7 Windows reserved names (Low): render_filename_template now 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=256M moved from core/cache.py (where it was a side-effect of import order) to core/__init__.py, so any booru_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 — see TODO.md.
    • #10 Early content validation (Low): _do_download now 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 to MAX_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_post is 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_request hook 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 in TODO.md.
    Downloads
  • v0.2.5 5bf85f223b

    v0.2.5 Stable

    pax released this 2026-04-10 21:25:40 -05:00 | 221 commits to main since this release

    v0.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.py went 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 at docs/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: BooruApp via constructor, accesses app internals as trusted collaborator via self._app. No mixins, no ABC, no dependency injection — just plain classes with one reference each. TYPE_CHECKING import for BooruApp avoids circular imports at runtime.
    • Cleaned up 14 dead imports from main_window.py.
    • The _fullscreen_window reference (52 sites across the codebase) was fully consolidated into PopoutController.window. No file outside popout_controller.py touches _fullscreen_window directly 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_tags rating/score/media filter mapping per API type, filter_posts blacklist/dedup/seen-ids interaction, should_backfill termination conditions.
    • tests/gui/test_window_state.py (16 tests): parse_geometry / format_geometry round-trip, parse_splitter_sizes validation edge cases, build_hyprctl_restore_cmds for every floating/tiled permutation including the no_anim priming path.
    • tests/gui/test_media_controller.py (9 tests): compute_prefetch_order for Nearby (cardinals) and Aggressive (ring expansion) modes, including bounds, cap, and dedup invariants.
    • tests/gui/test_post_actions.py (10 tests): is_batch_message progress-pattern detection, is_in_library path-containment check.
    • tests/gui/test_popout_controller.py (3 tests): build_video_sync_dict shape.
    • 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, and pytest.

    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's buttonText color so they match any theme automatically, with 1× 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_bar named 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.Ignored horizontally 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's setGeometry fallback handles the same math.

    • Viewport.center_x/center_y repurposed as anchor point coordinates — in center mode it's the window center, in corner modes it's the pinned corner. New anchor_point() helper in viewport.py extracts the right point from a window rect based on mode.
    • _compute_window_rect branches 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 reserved from hyprctl monitors -j so window positioning respects Waybar's exclusive zone (Qt's screen.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 QPropertyAnimation on a thumbOpacity Qt Property applied in paintEvent. 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

    • B or F — 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_requested signal that routes to a shared PostActionsController.toggle_save_from_preview so 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.mousePressEvent to the grid — Qt event propagation through QScrollArea swallows 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_pixmap hit-test as clicks. Cursor swaps between pointing-hand (over pixmap) and arrow (over padding) via mouseMoveEvent tracking.
    • 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: bold so the icon symbols read well against the translucent-black overlay.
    • themes/README.md documents 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