Compare commits

...

232 Commits
v0.2.4 ... main

Author SHA1 Message Date
pax
83a0637750 Update README.md 2026-04-21 12:49:56 -05:00
pax
04e85e000c docs(changelog): log changes since v0.2.7 2026-04-21 08:44:32 -05:00
pax
7a32dc931a fix(media): show per-post info in status after load
on_image_done overwrote the info set by _on_post_selected with "N results — Loaded", hiding it until a re-click.
2026-04-20 23:37:23 -05:00
pax
e0146a4681 fix(grid): refresh pixmaps on resize to stop black-out
Column shifts evict pixmaps via _recycle_offscreen, which only ran on scroll until now.

behavior change: no blank grid after splitter/tile resize.
2026-04-20 10:59:43 -05:00
pax
1941cb35e8 post_actions: drop dead try/except in is_in_library 2026-04-17 20:15:52 -05:00
pax
c16c3a794a video_player: hoist time import to module top 2026-04-17 20:15:50 -05:00
pax
21ac77ab7b api/moebooru: narrow JSON parse except to ValueError 2026-04-17 20:15:48 -05:00
pax
cd688be893 api/e621: narrow JSON parse except to ValueError 2026-04-17 20:15:45 -05:00
pax
7c4215c5d7 cache: document BaseException intent in tempfile cleanup 2026-04-17 20:15:43 -05:00
pax
eab805e705 video_player: free GL render context on stop to release idle VRAM
behavior change: stop() now calls _gl_widget.release_render_context()
after dropping hwdec, which frees the MpvRenderContext's internal
textures and FBOs. Previously the render context stayed alive for the
widget lifetime — its GPU allocations accumulated across video-to-image
switches in the stacked widget even though no video was playing.

The context is recreated lazily on the next play_file() via the
existing ensure_gl_init() path (~5ms, invisible behind network fetch).
After release, paintGL is a no-op (_ctx is None guard) and mpv won't
fire frame-ready callbacks, so the hidden QOpenGLWidget is inert.

cleanup() now delegates to release_render_context() + terminate()
instead of duplicating the ctx.free() logic.
2026-04-15 22:21:32 -05:00
pax
db4348c077 settings: pair Clear Tag Cache with the other non-destructive clears
Was dangling alone in row3 left-aligned under two 2-button rows,
which looked wrong. Moves it into row1 alongside Clear Thumbnails
and Clear Image Cache as a 3-wide non-destructive row; destructive
Clear Everything + Evict stay in row2. Label shortened to 'Clear
Tag Cache' to fit the 3-column width.
2026-04-15 17:55:31 -05:00
pax
deec81fc12 db: remove unused Favorite alias
Zero callers in source (rg 'Favorite\b' returns only this line).
The rename from favorite -> bookmark landed; the alias existed as
a fall-back while callers migrated, and nothing still needs it.
2026-04-15 17:50:14 -05:00
pax
585979a0d1 window_state: annotate silent excepts
Both hyprctl-path guards in window_state (hyprctl_main_window()
JSON parse, save_main_window_state() full flow) now explain why
the failure is absorbed instead of raised. No behavior change.
2026-04-15 17:49:54 -05:00
pax
b63341fec1 video_player: annotate silent excepts
Four mpv-state transition guards (letterbox color apply, hwdec
re-arm on play_file, hwdec drop on stop, replay-on-end seek) each
gained a one-line comment naming the absorbed failure and the
graceful fallback. No behavior change.
2026-04-15 17:49:28 -05:00
pax
873dcd8998 popout/window: annotate silent excepts
Four silent except-pass sites now either explain the absorbed
failure (mpv mid-transition, close-path cleanup, post-shutdown
video_params access) or downgrade to log.debug with exc_info so
the next debugger has breadcrumbs.

No behavior change.
2026-04-15 17:48:44 -05:00
pax
cec93545ad popout: drop in-flight-refactor language from docstrings
During the state machine extraction every comment that referenced
a specific commit in the plan (skeleton / 14a / 14b / 'future
commit') was useful — it told you which commit a line appeared
in and what was about to change. Once the refactor landed those
notes became noise: they describe history nobody needs while
reading the current code.

Rewrites keep the rationale (no-op handlers still explain WHY
they're no-ops, Loop=Next / video auto-fit still have their
explanations) and preserves the load-bearing commit 14b reference
in _dispatch_and_apply's docstring — that one actually does
protect future-you from reintroducing the bug-by-typo pattern.
2026-04-15 17:47:36 -05:00
pax
9ec034f7ef api/base: retry RemoteProtocolError and ReadError
Both surface when an overloaded booru drops the TCP connection
after sending headers but before the body completes. The existing
retry tuple (TimeoutException, ConnectError, NetworkError) missed
these even though they're the same shape of transient server-side
failure.

Keeps the existing single-retry-at-1s cadence; no retry-count
bump in this pass.
2026-04-15 17:44:15 -05:00
pax
ab44735f28 http: consolidate httpx.AsyncClient construction into make_client
Three call sites built near-identical httpx.AsyncClient instances:
the cache download pool, BooruClient's shared API pool, and
detect_site_type's reach into that same pool. They differed only
in timeout (60s vs 20s), Accept header (cache pool only), and
which extra request hooks to attach.

core/http.py:make_client is the single constructor now. Each call
site still keeps its own singleton + lock (separate connection
pools for large transfers vs short JSON), so this is a constructor
consolidation, not a pool consolidation.

No behavior change. Drops now-unused USER_AGENT imports from
cache.py and base.py; make_client pulls it from core.config.
2026-04-15 17:43:49 -05:00
pax
90b27fe36a info_panel: render uncategorized tags under Other bucket
behavior change: tags that weren't in any section of
post.tag_categories (partial batch-API response, HTML scrape
returned empty, stale cache) used to silently disappear from the
info panel — the categorized loop only iterated categories, so
any tag without a cached label just didn't render.

Now after the known category sections, any remaining tags from
post.tag_list are collected into an 'Other:' section with a
neutral header. The tag is visible and clickable even when its
type code never made it into the cache.

Reported against Gelbooru posts with long character tag names
where the batch tag API was returning partial results and the
missing tags were just gone from the UI.
2026-04-15 17:42:38 -05:00
pax
730b2a7b7e settings: add Clear Tag Category Cache button
behavior change: Settings > Cache now has a 'Clear Tag Category
Cache' action that wipes the per-site tag_types table via the
existing db.clear_tag_cache() hook. This also drops the
__batch_api_probe__ sentinel so Gelbooru/Moebooru sites re-probe
the batch tag API on next use and repopulate the cache from a
fresh response.

Use case: category types like Character/Copyright/Meta appear
missing when the local tag cache was populated by an older build
that didn't map all of Gelbooru's type codes. Clearing lets the
current _GELBOORU_TYPE_MAP re-label tags cleanly instead of
inheriting whatever the old rows said.
2026-04-15 17:39:57 -05:00
pax
0f26475f52 detect: remove leftover if-True indent marker
Dead syntax left over from a prior refactor. No behavior change.
2026-04-15 17:34:27 -05:00
pax
cf8bc0ad89 library_save: require category_fetcher to prevent silent category drop
behavior change: save_post_file's category_fetcher argument is now
keyword-only with no default, so every call site has to pass something
explicit (fetcher instance or None). Previously the =None default let
bookmark→library save and bookmark Save As slip through without a
fetcher at all, silently rendering %artist%/%character% tokens as
empty strings and producing filenames like '_12345.jpg' instead of
'greatartist_12345.jpg'.

BookmarksView now takes a category_fetcher_factory callable in its
constructor (wired to BooruApp._get_category_fetcher), called at save
time so it picks up the fetcher for whatever site is currently active.

tests/core/test_library_save.py pins the signature shape and the
three relevant paths: fetcher populates empty categories, None
accepted when categories are pre-populated (Danbooru/e621 inline),
fetcher skipped when template has no category tokens.
2026-04-15 17:32:25 -05:00
pax
bbf0d3107b category_fetcher: stop flipping _batch_api_works=False on transient errors in single-post path
behavior change: a single mid-call network drop could previously
poison _batch_api_works=False for the whole site, forcing every
future ensure_categories onto the slower HTML scrape path. _do_ensure
now routes the unprobed case through _probe_batch_api, which only
flips the flag on a clean HTTP 200 with zero matching names; timeout
and non-200 responses leave the flag None so the next call retries
the probe.

The bug surfaced because fetch_via_tag_api swallows per-chunk
failures with 'except Exception: continue', so the previous code
path couldn't distinguish 'API returned zero matches' from 'the
network dropped halfway through.' _probe_batch_api already made
that distinction for prefetch_batch; _do_ensure now reuses it.

Tests in tests/core/api/test_category_fetcher.py pin the three
routes (transient raise, clean-200-zero-matches, non-200).
2026-04-15 17:29:01 -05:00
pax
ec9e44efbe category_fetcher: extract shared tag-API params builder
Both fetch_via_tag_api and _probe_batch_api built the same params
dict (with identical lstrip/startswith credential quirks) inline.
Pulled into _build_tag_api_params so future credential-format tweaks
have one site, not two.
2026-04-15 17:27:10 -05:00
pax
24f398795b changelog: drag-start threshold bump 2026-04-14 23:27:32 -05:00
pax
3b3de35689 grid: raise drag-start threshold to 30px to match rubber band
Thumbnail file drag kicked off after only 10px of movement, which made
it too easy to start a drag when the user meant to rubber-band select
or just click-and-micro-wobble. Bumped to 30px so the gate matches the
rubber band's own threshold in `_maybe_start_rb`.

behavior change: tiny mouse movement on a thumbnail no longer starts a
file drag; you now need to drag ~30px before the OS drag kicks in.
2026-04-14 23:25:56 -05:00
pax
21bb3aa979 CHANGELOG: add [Unreleased] section for changes since v0.2.7 2026-04-14 19:05:23 -05:00
pax
289e4c2fdb release: v0.2.7 2026-04-14 19:03:22 -05:00
pax
3c2aa5820d popout: remember tiled state across open/close
Popout was always reopening as floating even when it had been tiled at
close. closeEvent already persisted geometry + fullscreen, but nothing
captured the Hyprland floating/tiled bit, so the windowrule's
`float = yes` rule always won on reopen.

Now closeEvent records `_saved_tiled` from hyprctl, popout_controller
persists it as `slideshow_tiled`, and FullscreenPreview's restore path
calls the new `hyprland.settiled` helper shortly after show() to push
the window back into the layout. Saved geometry is ignored for tiled
reopens since the tile extent is the layout's concern.

behavior change: popout reopens tiled if it was tiled at close.
2026-04-14 19:01:34 -05:00
pax
a2609199bd changelog: tiled grid repaint + video thumb 10% seek 2026-04-14 15:58:36 -05:00
pax
c3efcf9f89 library: seek to 10% before capturing video thumbnail
Videos that open on a black frame (fade-in, title card, codec warmup)
produced black library thumbnails. mpv now starts at 10% with hr_seek
so the first decoded frame is past the opening. mpv clamps `start`
to valid range so very short clips still land on a real frame.
2026-04-14 15:58:33 -05:00
pax
22f09c3cdb grid: force viewport repaint on resize to fix tiled blank-out
Qt Wayland buffer goes stale after compositor-driven resize events
(Hyprland tiled geometry change). FlowLayout reflowed thumbs but the
viewport skipped paint until a scroll or click invalidated it, leaving
the grid blank. ThumbnailGrid.resizeEvent now calls viewport().update()
after reflowing so the buffer stays in sync.
2026-04-14 15:58:28 -05:00
pax
70a7903f85 changelog: VRAM fixes and popout open animation 2026-04-13 21:49:38 -05:00
pax
e004add28f popout: let open animation play on first fit
resize() and resize_and_move() gain an animate flag — when True, skip
the no_anim setprop so Hyprland's windowsIn/popin animation plays
through. Popout passes animate=_first_fit_pending so the first fit
after open animates; subsequent navigation fits still suppress anim
to avoid resize flicker.

behavior change: popout now animates in on open instead of snapping.
2026-04-13 21:49:35 -05:00
pax
9713794633 popout: explicit mpv cleanup on close to free VRAM
FullscreenPreview has no WA_DeleteOnClose and Qt's C++ dtor does
not reliably call Python-side destroy() overrides once
popout_controller drops its reference, so the popout's separate
mpv instance + NVDEC surface pool leaked until the next full
Python GC cycle. closeEvent now calls _gl_widget.cleanup()
explicitly after the state machine's CloseRequested dispatch.

behavior change from v0.2.6: popout open/close cycles no longer
stair-step VRAM upward; the popout's mpv is torn down immediately
on close instead of waiting on GC.
2026-04-13 21:04:59 -05:00
pax
860c8dcd50 video_player: drop hwdec surface pool on stop
On NVIDIA the NVDEC surface pool is the bulk of mpv's idle VRAM
footprint, and keep_open=yes plus the live GL render context pin
it for the widget lifetime. stop() now sets hwdec='no' to release
the pool while idle; play_file() re-arms hwdec='auto' before the
next loadfile so GPU decode is restored on playback.

behavior change from v0.2.6: video VRAM now releases when switching
from a video post to an image post in the same preview pane, instead
of staying pinned for the widget lifetime.
2026-04-13 21:04:54 -05:00
pax
0d75b8a3c8 changelog: 9 commits since last Unreleased update
Fixed: GL context leak on Mesa/Intel, popout teardown None guards,
category_fetcher XXE/billion-laughs rejection.
Changed: dark Fusion palette fallback, popout aspect refit on untile
(behavior change).
Removed: rolled in latest dead-var and unused-import cleanups.
2026-04-13 19:02:40 -05:00
pax
94a64dcd25 mpv_gl: make GL current before freeing mpv render context
Drivers that enforce per-context GPU resource ownership (Mesa, Intel)
leak textures and FBOs when mpv_render_context_free runs without the
owning GL context current. NVIDIA tolerates this but others do not.
2026-04-13 18:40:23 -05:00
pax
3d26e40e0f popout: guard against None centralWidget and QApplication during teardown
resizeEvent, installEventFilter, and removeEventFilter all
dereference return values that can be None during init/shutdown,
causing AttributeError crashes on edge-case lifecycle timing.
2026-04-13 18:35:22 -05:00
pax
2cdab574ca popout: refit window with correct aspect when leaving tiled layout
behavior change: navigating to a different-aspect image/video while
tiled then un-tiling now resizes the floating window to the current
content's aspect and resets the image viewer zoom. Previously the
window restored to the old floating geometry with the wrong aspect
locked.

Stash content dims on the tiled early-return in _fit_to_content, then
detect the tiled-to-floating transition via a debounced resizeEvent
check that re-runs the fit.
2026-04-12 22:18:21 -05:00
pax
57108cd0b5 info_panel: remove unnecessary f-prefix on plain string 2026-04-12 14:55:35 -05:00
pax
667ee87641 settings: remove dead get_connection_log import in _build_network_tab 2026-04-12 14:55:35 -05:00
pax
2e436af4e8 video_player: remove unused QBrush and QApplication imports 2026-04-12 14:55:34 -05:00
pax
a7586a9e43 grid: remove dead mid variable from paintEvent 2026-04-12 14:55:33 -05:00
pax
ad6f876f40 category_fetcher: reject XML responses with DOCTYPE/ENTITY declarations
User-configurable sites could send XXE or billion-laughs payloads
via tag category API responses. Reject any XML body containing
<!DOCTYPE or <!ENTITY before passing to ET.fromstring.
2026-04-12 14:55:30 -05:00
pax
56c5eac870 app_runtime: dark Fusion fallback when no system theme is detected
Systems without Trolltech.conf (bare Arch, fresh installs without a
DE) were landing on Qt's default light palette. Apply a neutral dark
Fusion palette when no system theme file exists and the palette is
still light. KDE/GNOME users keep their own palette untouched.
2026-04-12 14:43:59 -05:00
pax
11cc26479b changelog: parallel video caching, mpv library thumbnails 2026-04-12 14:31:33 -05:00
pax
14c81484c9 replace ffmpeg with mpv for library video thumbnails
behavior change: video thumbnails in the Library tab are now generated
by a headless mpv instance (vo=null, pause=True, screenshot-to-file)
instead of shelling out to ffmpeg. Resized to LIBRARY_THUMB_SIZE with
PIL. Falls back to the same placeholder on failure. ffmpeg removed
from README install commands — no longer a dependency.
2026-04-12 14:31:30 -05:00
pax
0d72b0ec8a replace stream-record with parallel httpx download for uncached videos
behavior change: clicking an uncached video now starts a full httpx
download in the background alongside mpv streaming. The cached file
is available for copy/paste as soon as the download completes, without
waiting for playback to finish. stream-record machinery removed from
video_player.py (~60 lines); on_image_done detects the streaming case
and updates path references without restarting playback.
2026-04-12 14:31:23 -05:00
pax
445d3c7a0f CHANGELOG: add [Unreleased] section for changes since v0.2.6 2026-04-12 07:23:46 -05:00
pax
0583f962d1 main_window: set minimum width on thumbnail grid
Prevents the splitter from collapsing the grid to zero width.
The minimum is one column of thumbnails (THUMB_SIZE + margins).
2026-04-12 01:15:31 -05:00
pax
3868858811 media_controller: set _cached_path for streaming videos
Streaming videos skip on_image_done (the _load coroutine returns
early), so the thumbnail's _cached_path was never set. Drag-to-copy
failed until the user navigated away and back (which went through
the cached path and hit on_image_done).

Now on_video_stream sets _cached_path to the expected cache location
immediately. Once the stream-record promotes the .part file on EOF,
drag-to-copy works without needing to change posts first.
2026-04-12 01:10:53 -05:00
pax
7ef517235f revert audio normalization feature
Neither loudnorm (EBU R128) nor dynaudnorm work well for this
use case — both are designed for continuous playback, not rapidly
switching between random short clips with wildly different levels.
2026-04-11 23:30:09 -05:00
pax
2824840b07 post_actions: refresh bookmarks grid on unsave
unsave_from_preview only refreshed the library grid when on the
library tab. Now also refreshes the bookmarks grid when on the
bookmarks tab so the saved dot clears immediately.
2026-04-11 23:24:35 -05:00
pax
61403c8acc main_window: wire loudnorm setting to video players
Read the setting at startup and apply it to the embedded preview's
video player. On settings change, toggle af=loudnorm live on all
active mpv instances (embedded + popout).

Also adds _get_all_video_players() helper for iterating both.
2026-04-11 23:22:33 -05:00
pax
2e9b99e4b8 video_player: apply loudnorm audio filter on mpv init
Reads the _loudnorm flag (set by main_window from the DB setting)
and applies af=loudnorm when mpv is first initialized.
2026-04-11 23:22:30 -05:00
pax
73206994ec settings: add audio normalization checkbox 2026-04-11 23:22:28 -05:00
pax
738e1329b8 main_window: read image dimensions for bookmark popout aspect lock
Bookmark Post objects have no width/height (the DB doesn't store
them). When opening a bookmark in the popout, read the actual
dimensions from the cached file via QImageReader so the popout
can set keep_aspect_ratio correctly. Previously images from the
bookmarks tab always got 0x0, skipping the aspect lock.
2026-04-11 23:18:06 -05:00
pax
a3cb563ae0 grid: shorten thumbnail fade-in from 200ms to 80ms 2026-04-11 23:18:06 -05:00
pax
60cf4e0beb grid: fix fade animation cleanup crashing FlowLayout.clear
The previous deleteLater on the QPropertyAnimation left a dangling
self._fade_anim reference to a dead C++ object. When the next
search called FlowLayout.clear(), calling .stop() on the dead
animation threw RuntimeError and aborted widget cleanup, leaving
stale thumbnails in the grid.

Now the finished callback nulls self._fade_anim before scheduling
deletion, so clear() never touches a dead object.
2026-04-11 23:10:54 -05:00
pax
692a0c1569 grid: clean up QPropertyAnimation after fade completes
Connect finished signal to deleteLater so the animation object
is freed instead of being retained on the widget indefinitely.
2026-04-11 23:01:45 -05:00
pax
b964a77688 cache: single-pass directory walk in eviction functions
evict_oldest and evict_oldest_thumbnails now collect paths, stats,
and sizes in one iterdir() pass instead of separate passes for
sorting, sizing, and deleting. evict_oldest also accepts a
current_bytes arg to skip a redundant cache_size_bytes() call.
2026-04-11 23:01:44 -05:00
pax
10f1b3fd10 test_mpv_options: update demuxer_max_bytes assertion to 50MiB 2026-04-11 23:01:41 -05:00
pax
5564f4cf0a video_player: pass 150MiB demuxer cap for streaming URLs
Per-file override so network video buffering stays at the
previous level despite the lower default in _mpv_options.
2026-04-11 23:01:40 -05:00
pax
b055cdd1a2 _mpv_options: reduce default demuxer buffer from 150MiB to 50MiB
150MiB is excessive for local cached file playback. Network
streaming URLs get the 150MiB cap via a per-file override in
play_file() so the fast-path buffering is unaffected.

behavior change: mpv allocates less demuxer memory for local files.
2026-04-11 23:01:38 -05:00
pax
45b87adb33 media_controller: cancel stale prefetch spirals on new click
Each prefetch_adjacent() call now bumps a generation counter.
Running spirals check the counter at each iteration and exit
when superseded. Previously, rapid clicks between posts stacked
up concurrent download loops that never cancelled, accumulating
HTTP connections and response buffers.

Also incrementally updates the search controller's cached-names
set when a download completes, avoiding a full directory rescan.

behavior change: only the most recent click's prefetch spiral
runs; older ones exit at their next iteration.
2026-04-11 23:01:35 -05:00
pax
c11cca1134 settings: remove stale restart-required label from flip layout
The setting now applies live — the "(restart required)" label was
left over from before the live-apply change.
2026-04-11 22:54:04 -05:00
pax
fa8c5b84cf media_controller: throttle auto_evict_cache to once per 30s
cache_size_bytes() does a full stat() of every file in the cache
directory. It was called on every image load and every infinite
scroll drain. Now skipped if less than 30 seconds since the last
check.

Also replace QPixmap with QImageReader in image_dimensions() to
read width/height from the file header without decoding the full
image into memory.

behavior change: cache eviction checks run at most once per 30s
instead of on every image load. Library image dimensions are read
via QImageReader (header-only) instead of QPixmap (full decode).
2026-04-11 22:49:00 -05:00
pax
c3258c1d53 post_actions: invalidate search lookup caches on bookmark/save 2026-04-11 22:48:55 -05:00
pax
3a95b6817d search_controller: cache lookup sets across infinite scroll appends
Build the cache-dir listing, bookmark ID set, and saved-post ID
set once in on_search_done and reuse in _drain_append_queue.
Previously these were rebuilt from scratch on every infinite
scroll append — a full directory listing and two DB queries per
page load.

Caches are invalidated on new search, site change, and
bookmark/save operations via invalidate_lookup_caches().
2026-04-11 22:48:54 -05:00
pax
b00f3ff95c grid: recycle decoded pixmaps for off-screen thumbnails
Release _pixmap for ThumbnailWidgets outside the visible viewport
plus a 5-row buffer zone. Re-decode from the on-disk thumbnail
cache (_source_path) when they scroll back into view. Caps decoded
thumbnail memory to the visible area instead of growing unboundedly
during infinite scroll.

behavior change: off-screen thumbnails release their decoded
pixmaps and re-decode on scroll-back. No visual difference —
the buffer zone prevents flicker.
2026-04-11 22:48:49 -05:00
pax
172fae9583 main_window: re-decode thumbnails from disk on size change
The settings thumbnail-resize path now loads from _source_path
instead of scaling from a held _source_pixmap (which no longer
exists after the grid.py change).
2026-04-11 22:40:56 -05:00
pax
12ec94b4b1 library: pass thumbnail path to set_pixmap 2026-04-11 22:40:55 -05:00
pax
f83435904a bookmarks: pass thumbnail path to set_pixmap 2026-04-11 22:40:54 -05:00
pax
a73c2d6b02 search_controller: pass thumbnail path to set_pixmap 2026-04-11 22:40:53 -05:00
pax
738ece9cd5 grid: replace _source_pixmap with _source_path
Store the on-disk thumbnail path instead of a second decoded QPixmap
per ThumbnailWidget. Saves ~90 KB per widget in decoded pixel memory.
The source pixmap was only needed for the settings thumbnail-resize
path, which now re-decodes from disk (rare operation).

behavior change: thumbnail resize in settings re-reads from disk
instead of scaling from a held pixmap. No visual difference.
2026-04-11 22:40:49 -05:00
pax
3d288a909f search_controller: reset page to 1 on new search
on_search previously read the page spin value, so a stale page
number from a previous search carried over. Now resets the spin
to 1 on every new search.

behavior change: new searches always start from page 1.
2026-04-11 22:30:23 -05:00
pax
a8dfff90c5 search: fix autocomplete for multi-tag queries
QCompleter previously replaced the entire search bar text when
accepting a suggestion, wiping all previous tags. Added _TagCompleter
subclass that overrides splitPath (match against last tag only) and
pathFromIndex (prepend existing tags). Accepting a suggestion now
replaces only the last tag.

Space clears the suggestion popup so stale completions from the
previous tag don't linger when starting a new tag.

behavior change: autocomplete preserves existing tags in multi-tag
search; suggestions reset on space.
2026-04-11 22:30:21 -05:00
pax
14033b57b5 main_window: live-apply thumbnail size and flip layout
Thumbnail size change now resizes all existing thumbnails from their
source pixmap and reflows all three grids immediately. No restart
needed.

Flip layout change now swaps the splitter widget order live.

behavior change: thumbnail size and preview-on-left settings apply
instantly via Apply/Save instead of requiring a restart.
2026-04-11 22:26:31 -05:00
pax
9592830e67 grid: store source pixmap for lossless re-scaling
set_pixmap now keeps the original pixmap alongside the scaled display
copy. Used by live thumbnail resize in settings — re-scales from the
source instead of the already-scaled pixmap, preventing quality
degradation when changing sizes up and down.
2026-04-11 22:26:28 -05:00
pax
d895c28608 settings: add Apply button
Extracted save logic into _apply() method. Apply writes settings
and emits settings_changed without closing the dialog. Save calls
Apply then closes. Lets users preview setting changes before
committing.

behavior change: settings dialog now has Apply | Save | Cancel.
2026-04-11 22:23:46 -05:00
pax
53a8622020 main_window: preserve tab selection on switch
Tab switch previously cleared all grid selections and nulled
_current_post, losing the user's place and leaving toolbar actions
dead. Now only clears the other tabs' selections — the target tab
keeps its selection so switching back and forth preserves state.

behavior change: switching tabs no longer clears the current tab's
grid selection or preview post.
2026-04-11 22:20:46 -05:00
pax
88f6d769c8 settings: reset dialog platform cache on save
Calls reset_gtk_cache() after writing file_dialog_platform so the
next dialog open picks up the new value without restarting.
2026-04-11 22:19:38 -05:00
pax
5812f54877 dialogs: cache _use_gtk result instead of creating Database per call
_use_gtk() created a fresh Database instance on every file dialog
open just to read one setting. Now caches the result at module level
after first check. reset_gtk_cache() clears it when the setting
changes.
2026-04-11 22:19:36 -05:00
pax
0a046bf936 main_window: remove Ctrl+S and Ctrl+D menu shortcuts
Ctrl+S (Manage Sites) and Ctrl+D (Batch Download) violate platform
conventions where these keys mean Save and Bookmark respectively.
Menu items remain accessible via File menu.

behavior change: Ctrl+S and Ctrl+D no longer trigger actions.
2026-04-11 22:18:34 -05:00
pax
0c0dd55907 popout: increase overlay hover zone
Fixed 40px hover zone was too small on high-DPI monitors. Now scales
to ~10% of window height with a 60px floor.
2026-04-11 22:17:32 -05:00
pax
710839387a info_panel: remove tag count limits
Categorized tags were capped at 50 per category and flat tags at
100. Tags area is already inside a QScrollArea so there's no layout
reason for the limit. All tags now render.

behavior change: posts with 50+ tags per category now show all of
them instead of silently truncating.
2026-04-11 22:16:00 -05:00
pax
d355f24394 main_window: make S key guard consistent with B/F
S key (toggle save) previously checked _preview._current_post which
could be stale after tab switches or right-clicks. Now uses the same
guard as B/F: requires posts loaded and a valid grid selection index.
2026-04-11 22:15:07 -05:00
pax
f687141f80 privacy: preserve video pause state across privacy toggle
Previously privacy dismiss unconditionally resumed the embedded
preview video, overriding a manual pause. Now captures whether
the video was playing before privacy activated and only resumes
if it was.

behavior change: manually paused videos stay paused after
privacy screen dismiss.
2026-04-11 22:14:15 -05:00
pax
d64b1d6465 popout: make Save/Unsave from Library mutually exclusive
Context menu now shows either Save to Library or Unsave from Library
based on saved state, never both.

behavior change: popout context menu shows either Save or Unsave.
2026-04-11 22:13:23 -05:00
pax
558c19bdb5 preview_pane: make Save/Unsave from Library mutually exclusive
Context menu now shows either Save to Library or Unsave from Library
based on saved state, never both.

behavior change: preview context menu shows either Save or Unsave.
2026-04-11 22:13:23 -05:00
pax
4bcff35708 context_menus: make Save/Unsave from Library mutually exclusive
Previously both Save to Library submenu and Unsave from Library
showed simultaneously for saved posts. Now only the relevant action
appears based on whether the post is already in the library.

Also removed stale _current_post override on unsave — get_preview_post
already resolves the right-clicked post via grid selection index.

behavior change: browse grid context menu shows either Save or
Unsave, never both.
2026-04-11 22:13:21 -05:00
pax
79419794f6 bookmarks: fix save/unsave UX — no flash, correct dot indicators
Save to Library and Unsave from Library are now mutually exclusive
in both single and multi-select context menus (previously both
showed simultaneously).

Replaced full grid refresh() after save/unsave with targeted dot
updates — save_done signal fires per-post after async save completes
and lights the saved dot on just that thumbnail. Unsave clears the
dot inline. Eliminates the visible flash from grid rebuild.

behavior change: context menus show either Save or Unsave, never
both. Saved dots appear without grid flash.
2026-04-11 22:13:06 -05:00
pax
5e8035cb1d library: fix Post ID sort for templated filenames
Post ID sort used filepath.stem which sorted templated filenames
like artist_12345.jpg alphabetically instead of by post ID. Now
resolves post_id via library_meta DB lookup, falls back to digit-stem
for legacy files, unknowns sort to the end.
2026-04-11 21:59:20 -05:00
pax
52b76dfc83 library: fix thumbnail cleanup for templated filenames
Single-delete and multi-delete used filepath.stem for the thumbnail
path, but library thumbnails are keyed by post_id. Templated filenames
like artist_12345.jpg would look for thumbnails/library/artist_12345.jpg
instead of thumbnails/library/12345.jpg, leaving orphan thumbnails.

Now uses the resolved post_id when available, falls back to stem for
legacy digit-stem files.
2026-04-11 21:57:18 -05:00
pax
c210c4b44a popout: fix Copy File to Clipboard, add Copy Image URL
Fixed self._state → self._state_machine (latent AttributeError when
copying video to clipboard from popout context menu).

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

behavior change: clipboard copy now includes file URL; new context
menu entry for URL copy; video copy no longer crashes.
2026-04-11 21:55:07 -05:00
pax
fd21f735fb preview_pane: fix Copy File to Clipboard, add Copy Image URL
Copy File to Clipboard now sets QMimeData with both the file URL
and image data, matching main_window's Ctrl+C behavior. Previously
it only called setPixmap which didn't work in file managers.

Added Copy Image URL context menu entry that copies the booru CDN
URL as text.

behavior change: clipboard copy now includes file URL for paste
into file managers; new context menu entry for URL copy.
2026-04-11 21:55:04 -05:00
pax
e9d1ca7b3a image_viewer: accumulate scroll delta for zoom
Same hi-res scroll fix — accumulate angleDelta to ±120 boundaries
before applying a zoom step. Uses 1.15^steps so multi-step scrolls
on standard mice still feel the same.

behavior change
2026-04-11 20:06:31 -05:00
pax
21f2fa1513 popout: accumulate scroll delta for volume control
Same hi-res scroll fix as preview_pane — accumulate angleDelta to
±120 boundaries before triggering a volume step.

behavior change
2026-04-11 20:06:26 -05:00
pax
ebaacb8a25 preview_pane: accumulate scroll delta for volume control
Hi-res scroll mice (e.g. G502) send many small angleDelta events
per physical notch instead of one ±120. Without accumulation, each
micro-event triggered a ±5 volume jump, making volume unusable on
hi-res hardware. Now accumulates to ±120 boundaries before firing.

behavior change
2026-04-11 20:06:22 -05:00
pax
553734fe79 test_mpv_options: update demuxer_max_bytes assertion (50→150MiB) 2026-04-11 20:01:29 -05:00
pax
c1af3f2e02 mpv: revert cache_pause changes, keep larger demuxer buffer
The cache_pause=yes change (ac3939e) broke first-click popout
playback — mpv paused indefinitely waiting for cache fill on
uncached videos. Reverted to cache_pause=no.

Kept the demuxer_max_bytes bump (50→150MiB) which reduces stutter
on network streams by giving mpv more buffer headroom without
changing the pause/play behavior.

behavior change
2026-04-11 20:00:27 -05:00
pax
7046f9b94e mpv: drop cache_pause_initial (blocks first frame)
cache_pause_initial=yes made mpv wait for a full buffer before
showing the first frame on uncached videos, which looked like the
popout was broken on first click. Removing it restores immediate
playback start — cache_pause=yes still handles mid-playback
underruns.

behavior change
2026-04-11 19:53:20 -05:00
pax
ac3939ef61 mpv: fix video stutter on network streams
cache_pause=no caused frame-wait-frame-wait on uncached videos
because mpv kept playing through buffer underruns instead of
pausing to refill. Flip to cache_pause=yes with a 2s resume
threshold so playback is smooth after the initial buffer fill.

Also: bump demuxer buffers (50→150MiB forward, add 75MiB back for
backward seek without refetch), increase stream_buffer_size from
default 128KiB to 4MiB to reduce syscall overhead, extend network
timeout (10→30s) for slow CDNs, and set a browser-like user agent
to avoid 403s from boorus that block mpv's default UA.

behavior change
2026-04-11 19:51:56 -05:00
pax
e939085ac9 main_window: restore Path import (used at line 69)
Erroneously removed in a51c9a1 — Path is used in __init__ for
set_library_dir(Path(lib_dir)). The dead-code scan missed it.
2026-04-11 19:30:19 -05:00
pax
b28cc0d104 db: escape LIKE wildcards in search_library_meta
Same fix as audit #5 applied to get_bookmarks (lines 490-499) but
missed here. Without ESCAPE, searching 'cat_ear' also matches
'catxear' because _ is a SQL LIKE wildcard that matches any single
character.
2026-04-11 19:28:59 -05:00
pax
37f89c0bf8 search_controller: remove unused saved_dir import 2026-04-11 19:28:44 -05:00
pax
925e8c1001 sites: remove unused parse_qs import 2026-04-11 19:28:44 -05:00
pax
a760b39c07 dialogs: remove unused sys and Path imports 2026-04-11 19:28:44 -05:00
pax
77e49268ae settings: remove unused QProgressBar import 2026-04-11 19:28:44 -05:00
pax
e262a2d3bb grid: remove unused imports, stop animation before widget deletion
Unused: Path, Post, QPainterPath, QMenu, QApplication.

FlowLayout.clear() now stops any in-flight fade animation before
calling deleteLater() on thumbnails. Without this, a mid-flight
QPropertyAnimation can fire property updates on a widget that's
queued for deletion.
2026-04-11 19:28:13 -05:00
pax
a51c9a1fda main_window: remove unused imports (os, sys, Path, field, is_cached) 2026-04-11 19:27:44 -05:00
pax
7249d57852 fix rubber band state getting stuck across interrupted drags
Two fixes:

1. Stale state cleanup. If a rubber band drag is interrupted without a
   matching release event (Wayland focus steal, drag outside window,
   tab switch, alt-tab), _rb_origin and the rubber band widget stay
   stuck. The next click then reuses the stale origin and rubber band
   stops working until the app is restarted. New _clear_stale_rubber_band
   helper is called at the top of every mouse press entry point
   (Grid.mousePressEvent, on_padding_click, ThumbnailWidget pixmap
   press) so the next interaction starts from a clean slate.

2. Scroll offset sign error in _rb_drag. The intersection test
   translated thumb geometry by +vp_offset, but thumb.geometry() is in
   widget coords and rb_rect is in viewport coords — the translation
   needs to convert between them. Switched to translating rb_rect into
   widget coords (rb_widget = rb_rect.translated(vp_offset)) before the
   intersection test, which is the mathematically correct direction.
   Rubber band selection now tracks the visible band when scrolled.

behavior change: rubber band stays responsive after interrupted drags
2026-04-11 18:04:55 -05:00
pax
e31ca07973 hide standard icon column from QMessageBox dialogs
Targets the internal qt_msgboxex_icon_label by objectName via the
base stylesheet, so confirm/warn/info dialogs across all 36+ call
sites render text-only without per-call setIcon plumbing.

behavior change
2026-04-11 17:35:54 -05:00
pax
58cbeec2e4 remove TODO.md
Both follow-ups (lock file, dead code in core/images.py) are
resolved or explicitly out of scope. The lock file item was
declined as not worth the dev tooling overhead; the dead code
was just removed in 2186f50.
2026-04-11 17:29:13 -05:00
pax
2186f50065 remove dead code: core/images.py
make_thumbnail and image_dimensions were both unreferenced. The
library's actual thumbnailing happens inline in gui/library.py
(PIL for stills, ffmpeg subprocess for videos), and the live
image_dimensions used by main_window.py is the static method on
gui/media_controller.py — not the standalone function this file
exposed. Audit finding #15 follow-up.
2026-04-11 17:29:04 -05:00
pax
07665942db core/__init__.py: drop stale core.images reference from docstring
The audit #8 explanation no longer needs to name core.images as the
example case — the invariant holds for any submodule, and core.images
is about to be removed entirely as dead code.
2026-04-11 17:28:57 -05:00
pax
1864cfb088 test_pil_safety: target core.config instead of core.images
The 'audit #8 invariant' the test was anchored on (core.images
imported without core.cache first) is about to become moot when
images.py is removed in a follow-up commit. Swap to core.config
to keep the same coverage shape: any non-cache submodule import
must still trigger __init__.py and install the PIL cap.
2026-04-11 17:28:47 -05:00
pax
a849b8f900 force Fusion widgets when no custom.qss
Distro pyside6 builds linked against system Qt pick up the system
platform theme plugin (Breeze on KDE, Adwaita-ish on GNOME, etc.),
which gave AUR users a different widget style than the source-from-pip
build that uses bundled Qt. Force Fusion in the no-custom.qss path so
both routes render identically.

The inherited palette is intentionally untouched: KDE writes
~/.config/Trolltech.conf which every Qt app reads, so KDE users
still get their color scheme — just under Fusion widgets instead
of Breeze.
2026-04-11 17:23:05 -05:00
pax
af0d8facb8 bump version to 0.2.6 2026-04-11 16:43:57 -05:00
pax
1531db27b7 update changelog to v0.2.6 2026-04-11 16:41:37 -05:00
pax
278d4a291d ci: convert test_safety async tests off pytest-asyncio
The two validate_public_request hook tests used @pytest.mark.asyncio
which requires pytest-asyncio at collection time. CI only installs
httpx + Pillow + pytest, so the marker decoded as PytestUnknownMark
and the test bodies failed with "async def functions are not
natively supported."

Switches both to plain sync tests that drive the coroutine via
asyncio.run(), matching the pattern already used in test_cache.py
for the same reason.

Audit-Ref: SECURITY_AUDIT.md finding #1 (test infrastructure)
2026-04-11 16:38:36 -05:00
pax
5858c274c8 security: fix #2 — set lavf options on _MpvGLWidget after construction
Calls lavf_options() post mpv.MPV() init and writes each entry into
the demuxer-lavf-o property. This is the consumer side of the split
helpers introduced in the previous commit. Verified end-to-end by
launching the GUI: mpv constructs cleanly and m['demuxer-lavf-o']
reads back as {'protocol_whitelist': 'file,http,https,tls,tcp'}.

Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
2026-04-11 16:34:57 -05:00
pax
4db7943ac7 security: fix #2 — apply lavf protocol whitelist via property API
The previous attempt set ``demuxer_lavf_o`` as an init kwarg with a
comma-laden ``protocol_whitelist=file,http,https,tls,tcp`` value.
mpv rejected it with -7 OPT_FORMAT because python-mpv's init path
goes through ``mpv_set_option_string``, which routes through mpv's
keyvalue list parser — that parser splits on ``,`` to find entries,
shredding the protocol list into orphan tokens. Backslash-escaping
``\,`` did not unescape on this code path either.

Splits the option set into two helpers:

- ``build_mpv_kwargs`` — init kwargs only (ytdl=no, load_scripts=no,
  POSIX input_conf null, all the existing playback/audio/network
  tuning). The lavf option is intentionally absent.
- ``lavf_options`` — a dict applied post-construction via the
  python-mpv property API, which uses the node API and accepts
  dict values for keyvalue-list options without splitting on
  commas inside the value.

Tests cover both paths: that ``demuxer_lavf_o`` is NOT in the init
kwargs (regression guard), and that ``lavf_options`` returns the
expected protocol set.

Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
2026-04-11 16:34:50 -05:00
pax
160db1f12a docs: TODO.md follow-ups deferred from the 2026-04-10 audit
Captures the lock-file generation work (audit #9) and the
core/images.py dead-code cleanup (audit #15) as explicit
follow-ups so they don't get lost between branches.
2026-04-11 16:27:55 -05:00
pax
ec781141b3 docs: changelog entry for 2026-04-10 security audit batch
Adds an [Unreleased] Security section listing the 12 fixed findings
(2 High, 4 Medium, 4 Low, 2 Informational), the 4 skipped
Informational items with reasons, and the user-facing behavior
changes.

Audit-Ref: SECURITY_AUDIT.md (full batch)
2026-04-11 16:27:50 -05:00
pax
5a511338c8 security: fix #14 — cap category_fetcher HTML body before regex walk
CategoryFetcher.fetch_post pulls a post-view HTML page and runs
_TAG_ELEMENT_RE.finditer over the full body. The regex itself is
linear (no catastrophic backtracking shape), but a hostile server
returning hundreds of MB of HTML still pegs CPU walking the buffer.
Caps the body the regex sees at 2MB — well above any legit
Gelbooru/Moebooru post page (~30-150KB).

Truncation rather than streaming because httpx already buffers the
body before _request returns; the cost we're cutting is the regex
walk, not the memory hit. A full streaming refactor of fetch_post
is a follow-up that the audit explicitly flagged as out of scope
("not catastrophic — defense in depth").

Audit-Ref: SECURITY_AUDIT.md finding #14
Severity: Informational
2026-04-11 16:26:00 -05:00
pax
b65f8da837 security: fix #10 — validate media magic in first 16 bytes of stream
The previous flow streamed the full body to disk and called
_is_valid_media after completion. A hostile server that omits
Content-Type (so the early text/html guard doesn't fire) could
burn up to MAX_DOWNLOAD_BYTES (500MB) of bandwidth and cache-dir
write/delete churn before the post-download check rejected.

Refactors _do_download to accumulate chunks into a small header
buffer until at least 16 bytes have arrived, then runs
_looks_like_media against the buffer before committing to writing
the full payload. The 16-byte minimum handles servers that send
tiny chunks (chunked encoding with 1-byte chunks, slow trickle,
TCP MSS fragmentation) without false-failing on the first chunk.

Extracts _looks_like_media(bytes) as a sibling to _is_valid_media
(path) sharing the same magic-byte recognition. _looks_like_media
fails closed on empty input — when called from the streaming
validator, an empty header means the server returned nothing
useful. _is_valid_media keeps its OSError-fallback open behavior
for the on-disk path so transient EBUSY doesn't trigger a delete
+ re-download loop.

Audit-Ref: SECURITY_AUDIT.md finding #10
Severity: Low
2026-04-11 16:24:59 -05:00
pax
fef3c237f1 security: fix #9 — add upper bounds on runtime dependencies
The previous floors-only scheme would let a future `pip install` pull
in any new major release of httpx, Pillow, PySide6, or python-mpv —
including ones that loosen safety guarantees we depend on (e.g.
Pillow's MAX_IMAGE_PIXELS, httpx's redirect-following defaults).

Caps each at the next major version. Lock-file generation is still
deferred — see TODO.md for the follow-up (would require adding
pip-tools as a new dev dep, out of scope for this branch).

Audit-Ref: SECURITY_AUDIT.md finding #9
Severity: Low
2026-04-11 16:22:34 -05:00
pax
8f9e4f7e65 security: fix #8 — drop duplicate MAX_IMAGE_PIXELS set from cache.py
The cap is now installed by core/__init__.py (previous commit), so
the line in cache.py is redundant. Removing it leaves a single
authoritative location for the security-critical PIL setting.

Audit-Ref: SECURITY_AUDIT.md finding #8
Severity: Low
2026-04-11 16:21:37 -05:00
pax
2bb6352141 security: fix #8 — install MAX_IMAGE_PIXELS cap in core/__init__.py
PIL's decompression-bomb cap previously lived as a side effect of
importing core/cache.py. Any future code path that touched core/images
(or any other core submodule) without first importing cache would
silently revert to PIL's default 89M-pixel *warning* (not an error),
re-opening the bomb surface.

Moves the cap into core/__init__.py so any import of any
booru_viewer.core.* submodule installs it first. The duplicate set
in cache.py is left in place by this commit and removed in the next
one — both writes are idempotent so this commit is bisect-safe.

Audit-Ref: SECURITY_AUDIT.md finding #8
Severity: Low
2026-04-11 16:21:32 -05:00
pax
6ff1f726d4 security: fix #7 — reject Windows reserved device names in template
render_filename_template's sanitization stripped reserved chars,
control codes, whitespace, and `..` prefixes — but did not catch
Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
On Windows, opening `con.jpg` for writing redirects to the CON
device, so a tag value of `con` from a hostile booru would silently
break Save to Library.

Adds a frozenset of reserved stems and prefixes the rendered name
with `_` if its lowercased stem matches. The check runs
unconditionally (not Windows-gated) so a library saved on Linux
can be copied to a Windows machine without breaking on these
filenames.

Audit-Ref: SECURITY_AUDIT.md finding #7
Severity: Low
2026-04-11 16:20:27 -05:00
pax
b8cb47badb security: fix #6 — escape source via build_source_html in InfoPanel
Replaces the inline f-string concatenation of post.source into the
RichText document with a call through build_source_html(), which
escapes both the href value and the visible display text.

Also escapes the filetype field for defense-in-depth — the value
comes from a parsed URL suffix (effectively booru-controlled) and
the previous code interpolated it raw.

Removes the dead duplicate setText() call that wrote a plain-text
version before being overwritten by the RichText version on the
next line.

Audit-Ref: SECURITY_AUDIT.md finding #6
Severity: Medium
2026-04-11 16:19:17 -05:00
pax
fa4f2cb270 security: fix #6 — add pure source HTML escape helper
Extracts the rich-text Source-line builder out of info_panel.py
into a Qt-free module so it can be unit-tested under CI (which
installs only httpx + Pillow + pytest, no PySide6).

The helper html.escape()s both the href and the visible display
text, and only emits an <a> tag for http(s) URLs — non-URL
sources (including javascript: and data: schemes) get rendered
as escaped plain text without a clickable anchor.

Not yet wired into InfoPanel.set_post; that lands in the next
commit.

Audit-Ref: SECURITY_AUDIT.md finding #6
Severity: Medium
2026-04-11 16:19:06 -05:00
pax
5d348fa8be security: fix #5 — LRU cap on _url_locks to prevent memory leak
Replaces the unbounded defaultdict(asyncio.Lock) with an OrderedDict
guarded by _get_url_lock() and _evict_url_locks(). The cap is 4096
entries; LRU semantics keep the hot working set alive and oldest-
unlocked-first eviction trims back toward the cap on each new
insertion.

Eviction skips locks that are currently held — popping a lock that
a coroutine is mid-`async with` on would break its __aexit__. The
inner loop's evicted-flag handles the edge case where every
remaining entry is either the freshly inserted hash or held; in
that state the cap is briefly exceeded and the next insertion
retries, instead of looping forever.

Audit-Ref: SECURITY_AUDIT.md finding #5
Severity: Medium
2026-04-11 16:16:52 -05:00
pax
a6a73fed61 security: fix #4 — chmod SQLite DB + WAL/SHM sidecars to 0o600
The sites table stores api_key + api_user in plaintext. Previous
behavior left the DB file at the inherited umask (0o644 on most
Linux systems) so any other local user could sqlite3 it open and
exfiltrate every booru API key.

Adds Database._restrict_perms(), called from the lazy conn init
right after _migrate(). Tightens the main file plus the -wal and
-shm sidecars to 0o600. The sidecars only exist after the first
write, so the FileNotFoundError path is expected and silenced.
Filesystem chmod failures are also swallowed for FUSE-mount
compatibility.

behavior change from v0.2.5: ~/.local/share/booru-viewer/booru.db
is now 0o600 even if a previous version created it 0o644.

Audit-Ref: SECURITY_AUDIT.md finding #4
Severity: Medium
2026-04-11 16:15:41 -05:00
pax
6801a0b45e security: fix #4 — chmod data_dir to 0o700 on POSIX
The data directory holds the SQLite database whose `sites` table
stores api_key and api_user in plaintext. Previous behavior used
the inherited umask (typically 0o755), which leaves the dir
world-traversable on shared workstations and on networked home
dirs whose home is 0o755. Tighten to 0o700 unconditionally on
every data_dir() call so the fix is applied even when an older
version (or external tooling) left the directory loose.

Failures from filesystems that don't support chmod (some FUSE
mounts) are swallowed — better to keep working than refuse to
start. Windows: no-op, NTFS ACLs handle this separately.

behavior change from v0.2.5: ~/.local/share/booru-viewer is now
0o700 even if it was previously 0o755.

Audit-Ref: SECURITY_AUDIT.md finding #4
Severity: Medium
2026-04-11 16:14:30 -05:00
pax
19a22be59c security: fix #3 — redact params in GelbooruClient debug log
Same fix as danbooru.py and e621.py — Gelbooru's params dict
carries api_key + user_id when configured. Route through
redact_params() before the debug log emits them.

Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
2026-04-11 16:13:25 -05:00
pax
49fa2c5b7a security: fix #3 — redact params in E621Client debug log
Same fix as danbooru.py — the search() log.debug params line
previously emitted login + api_key. Route through redact_params().

Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
2026-04-11 16:13:06 -05:00
pax
c0c8fdadbf drop unused httpx[http2] extra
http2 was declared in the dependency spec but no httpx client
actually passes http2=True, so the extra (and its h2 pull-in) was
dead weight.
2026-04-11 16:12:50 -05:00
pax
9a3bb697ec security: fix #3 — redact params in DanbooruClient debug log
The log.debug(f"  params: {params}") line in search() previously
dumped login + api_key to the booru logger at DEBUG level. Route
the params dict through redact_params() so the keys are replaced
with *** before formatting.

Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
2026-04-11 16:12:47 -05:00
pax
d6909bf4d7 security: fix #3 — redact URL in BooruClient._log_request
The httpx request event hook converts request.url to a str so
log_connection can parse it — at that point the credential query
params (login, api_key, etc.) are in scope and could be captured
by any traceback, debug hook, or monitoring agent observing the
hook call. Pipe through redact_url() first so the rendered string
never carries the secrets, even transiently.

Audit-Ref: SECURITY_AUDIT.md finding #3
Severity: Medium
2026-04-11 16:12:28 -05:00
pax
c735db0c68 security: fix #1 — wire SSRF hook into detect_site_type client
detect_site_type constructs a fresh BooruClient._shared_client
directly (bypassing the BooruClient.client property) for the
/posts.json, /index.php, and /post.json probes. The hooks set
here are the ones installed on that initial construction — if
detection runs before any BooruClient instance's .client is
accessed, the shared singleton must still have SSRF validation
and connection logging.

This additionally closes finding #16 for the detect client — site
detection requests now appear in the connection log instead of
being invisible.

behavior change from v0.2.5: Test Connection from the site dialog
now rejects private-IP targets. Adding a local/RFC1918 booru via
the "auto-detect type" dialog will fail with "blocked request
target ..." instead of probing it. Explicit api_type selection
still goes through the BooruClient.client path, which is also
now protected.

Audit-Ref: SECURITY_AUDIT.md finding #1
Also-Closes: SECURITY_AUDIT.md finding #16 (detect half)
Severity: High
2026-04-11 16:11:37 -05:00
pax
ef95509551 security: fix #1 — wire SSRF hook into E621Client custom client
E621 maintains its own httpx.AsyncClient because their TOS requires
a per-user User-Agent string that BooruClient's shared client can't
carry. The client is rebuilt on User-Agent change, so the hook must
be installed in the same construction path.

Also installs BooruClient._log_request as a second hook (this
additionally closes finding #16 for the e621 client — e621 requests
previously bypassed the connection log entirely, and this wires
them in consistently with the base client).

Audit-Ref: SECURITY_AUDIT.md finding #1
Also-Closes: SECURITY_AUDIT.md finding #16 (e621 half)
Severity: High
2026-04-11 16:11:12 -05:00
pax
ec79be9c83 security: fix #1 — wire SSRF hook into cache download client
Adds validate_public_request to the cache module's shared httpx
client event_hooks. Covers image/video/thumbnail downloads, which
are the most likely exfil path — file_url comes straight from the
booru JSON response and previously followed any 3xx that landed,
so a hostile booru could point downloads at a private IP. Every
redirect hop is now rejected if the target is non-public.

The import is lazy inside _get_shared_client because
core.api.base imports log_connection from this module; a top-level
`from .api._safety import ...` would circular-import through
api/__init__.py during cache.py load. By the time
_get_shared_client is called the api package is fully loaded.

Audit-Ref: SECURITY_AUDIT.md finding #1
Severity: High
2026-04-11 16:10:50 -05:00
pax
6eebb77ae5 security: fix #1 — wire SSRF hook into BooruClient shared client
Adds validate_public_request to the BooruClient event_hooks list so
every request (and every redirect hop) is checked against the block
list from _safety.py. Danbooru, Gelbooru, and Moebooru subclasses
all go through BooruClient.client and inherit the protection.

Preserves the existing _log_request hook by listing both hooks in
order: validate first (so blocked hops never reach the log), then
log.

Audit-Ref: SECURITY_AUDIT.md finding #1
Severity: High
2026-04-11 16:10:12 -05:00
pax
013fe43f95 security: fix #1 — add public-host validator helper
Introduces core/api/_safety.py containing check_public_host and the
validate_public_request async request-hook. The hook rejects any URL
whose host is (or resolves to) loopback, RFC1918, link-local
(including 169.254.169.254 cloud metadata), CGNAT, unique-local v6,
or multicast. Called on every request hop so it covers both the
initial URL and every redirect target that httpx would otherwise
follow blindly.

Also exports redact_url / redact_params for finding #3 — the
secret-key set lives in the same module since both #1 and #3 work
is wired through httpx client event_hooks. Helper is stdlib-only
(ipaddress, socket, urllib.parse) plus httpx; no new deps.

Not yet wired into any httpx client; per-file wiring commits follow.

Audit-Ref: SECURITY_AUDIT.md finding #1
Severity: High
2026-04-11 16:09:53 -05:00
pax
72803f0b14 security: fix #2 — wire hardened mpv options into _MpvGLWidget
Replaces the inline mpv.MPV(...) literal kwargs with a call through
build_mpv_kwargs(), which adds ytdl=no, load_scripts=no, a lavf
protocol whitelist (file,http,https,tls,tcp), and POSIX input_conf
lockdown. 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 from v0.2.5: any file_url whose host is only
handled by yt-dlp (youtube.com, reddit.com, etc.) will no longer
play. Boorus do not legitimately return such URLs, so in practice
this only affects hostile responses. Cached local files and direct
https .mp4/.webm/.mkv continue to work.

Manually smoke tested: played a cached local .mp4 from the library
(file: protocol) and a fresh network .webm from a danbooru search
(https: protocol) — both work.

Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
2026-04-11 16:07:33 -05:00
pax
22744c48af security: fix #2 — add pure mpv options builder helper
Extracts the mpv.MPV() kwargs into a Qt-free pure function so the
security-relevant options can be unit-tested on CI (which lacks
PySide6 and libmpv). The builder embeds the audit #2 hardening —
ytdl="no", load_scripts="no", and a lavf protocol whitelist of
file,http,https,tls,tcp — alongside the existing playback tuning.
Not yet wired into _MpvGLWidget; that lands in the next commit.

Audit-Ref: SECURITY_AUDIT.md finding #2
Severity: High
2026-04-11 16:06:33 -05:00
pax
0aa3d8113d README: add AUR install instructions
booru-viewer-git is now on the AUR — lead the Linux install section
with it for Arch-family distros, keep the source-build path for other
distros and dev use.
2026-04-11 16:00:42 -05:00
pax
75bbcc5d76 strip 'v' prefix from version strings
pyproject.toml and installer.iss both used 'v0.2.5' — not PEP 440
compliant, so hatchling silently normalized it to '0.2.5' in wheel
builds. Align the source strings with what actually gets shipped.
2026-04-11 15:59:57 -05:00
pax
c91326bf4b fix issue template field: about -> description
GitHub's YAML issue forms require `description:`, not `about:` (which
is for the legacy markdown templates). GitHub silently ignores forms
with invalid top-level fields, so only the config.yml contact links
were showing in the new-issue picker.
2026-04-10 22:58:35 -05:00
pax
b1e4efdd0b add GitHub issue templates 2026-04-10 22:54:04 -05:00
pax
836e2a97e3 update HYPRLAND.md to reflect anchor point setting 2026-04-10 22:41:21 -05:00
pax
4bc7037222 point README Hyprland section to HYPRLAND.md 2026-04-10 22:35:55 -05:00
pax
cb4d0ac851 add HYPRLAND.md with integration reference and ricer examples 2026-04-10 22:35:51 -05:00
pax
10c2dcb8aa fix popout menu flash on wrong monitor and preview unsave button
- preview_pane: unsave button now checks self._is_saved instead of
  self._save_btn.text() == "Unsave", which stopped matching after the
  button text became a Unicode icon (✕ / ⤓)
- popout: new _exec_menu_at_button helper uses menu.popup() +
  QEventLoop blocked on aboutToHide instead of menu.exec(globalPos).
  On Hyprland the popout gets moved via hyprctl after Qt maps it and
  Qt's window-position tracking stays stale, so exec(btn.mapToGlobal)
  resolved to a global point on the wrong monitor, flashing the menu
  there before the compositor corrected it. popup() routes through a
  different positioning path that anchors correctly.
2026-04-10 22:10:27 -05:00
pax
a90aa2dc77 rebuild CHANGELOG.md from Gitea release bodies 2026-04-10 21:53:16 -05:00
pax
5bf85f223b add v prefix to version strings 2026-04-10 21:25:27 -05:00
pax
5e6361c31b release 0.2.5 2026-04-10 21:17:10 -05:00
pax
35135c9a5b video controls: 1x icon, responsive layout, EOF replay, autoplay icon fix
- Render "Once" loop icon as bold "1×" text via QPainter drawText
  instead of the hand-drawn line art
- Responsive controls bar: hide volume slider below 320px, duration
  label below 240px, current time label below 200px
- _toggle_play seeks to 0 if paused at EOF so pressing play replays
  the video in Once mode instead of doing nothing
- Fix stray "Auto" text leaking through the autoplay icon — the
  autoplay property setter was still calling setText
2026-04-10 21:09:49 -05:00
pax
fa9fcc3db0 rubber band from cell padding with 30px drag threshold
- ThumbnailWidget detects clicks outside the pixmap and calls
  grid.on_padding_click() via parent walk (signals + event filters
  both failed on Wayland/QScrollArea)
- Grid tracks a pending rubber band origin; only activates past 30px
  manhattan distance so small clicks deselect cleanly
- Move/release events forwarded from ThumbnailWidget to grid for both
  the pending-drag check and the active rubber band drag
- Fixed mapFrom/mapTo direction (mapFrom's first arg must be a parent)
2026-04-10 20:54:37 -05:00
pax
c440065513 install event filter on each ThumbnailWidget for reliable padding detection 2026-04-10 20:36:54 -05:00
pax
00b8e352ea use viewport event filter for cell padding detection instead of signals 2026-04-10 20:34:36 -05:00
pax
c8b21305ba fix padding click: pass no args through signal, just deselect 2026-04-10 20:31:56 -05:00
pax
9081208170 cell padding clicks deselect via signal instead of broken event propagation 2026-04-10 20:27:54 -05:00
pax
b541f64374 fix cell padding hit-test: use mapFrom instead of broken mapToGlobal on Wayland 2026-04-10 20:25:00 -05:00
pax
9c42b4fdd7 fix coordinate mapping for cell padding hit-test in grid 2026-04-10 20:23:36 -05:00
pax
a1ea2b8727 remove dead enterEvent, reset cursor in leaveEvent 2026-04-10 20:22:17 -05:00
pax
4ba9990f3a pixmap-aware double-click and dynamic cursor on hover 2026-04-10 20:21:58 -05:00
pax
868b1a7708 cell padding starts rubber band and deselects, not just flow gaps 2026-04-10 20:20:23 -05:00
pax
09fadcf3c2 hover only when cursor is over the pixmap, not cell padding 2026-04-10 20:18:49 -05:00
pax
88a3fe9528 fix stuck hover state when mouse exits grid on Wayland 2026-04-10 20:16:49 -05:00
pax
e28ae6f4af Reapply "only select cell when clicking the pixmap, not the surrounding padding"
This reverts commit 6aa8677a2d28af2eb00961fb16169128df72d2fc.
2026-04-10 20:15:50 -05:00
pax
6aa8677a2d Revert "only select cell when clicking the pixmap, not the surrounding padding"
This reverts commit cc616d1cf4ab460f204095af44607b7fce5a2dad.
2026-04-10 20:15:24 -05:00
pax
cc616d1cf4 only select cell when clicking the pixmap, not the surrounding padding 2026-04-10 20:14:49 -05:00
pax
42e7f2b529 add Escape to deselect in grid 2026-04-10 20:13:54 -05:00
pax
0b4fc9fa49 click empty grid space to deselect, reset stuck drag cursor on release 2026-04-10 20:12:08 -05:00
pax
0f2e800481 skip media reload when clicking already-selected post 2026-04-10 20:10:04 -05:00
pax
15870daae5 fix stuck forbidden cursor after drag-and-drop 2026-04-10 20:07:52 -05:00
pax
27c53cb237 prevent info panel from pushing splitter on long source URLs 2026-04-10 20:05:57 -05:00
pax
b1139cbea6 update README settings list with new options 2026-04-10 19:58:26 -05:00
pax
93459dfff6 UI overhaul: icon buttons, video controls, popout anchor, layout flip, compact top bar
- Preview/popout toolbar: icon buttons (☆/★, ↓/✕, ⊘, ⊗, ⧉) with QSS
  object names (#_tb_bookmark, #_tb_save, etc.) for theme targeting
- Video controls: QPainter-drawn icons for play/pause, volume/mute;
  text labels for loop/once/next and autoplay
- Popout anchor setting: resize pivot (center/tl/tr/bl/br) controls
  which corner stays fixed on aspect change, works on all platforms
- Hyprland monitor reserved areas: reads waybar exclusive zones from
  hyprctl monitors -j for correct edge positioning
- Layout flip setting: swap grid and preview sides
- Compact top bar: AdjustToContents combos, tighter spacing, named
  containers (#_top_bar, #_nav_bar) for QSS targeting
- Reduced main window minimum size from 900x600 to 740x400
- Trimmed bundled QSS: removed 12 unused widget selectors, added
  popout overlay font-weight/size, regenerated all 12 theme files
- Updated themes/README.md with icon button reference
2026-04-10 19:58:11 -05:00
pax
d7b3c304d7 add B/S keybinds to popout, refactor toggle_save 2026-04-10 18:32:57 -05:00
pax
28c40bc1f5 document B/F and S keybinds in KEYBINDS.md 2026-04-10 18:30:39 -05:00
pax
094a22db25 add B and S keyboard shortcuts for bookmark and save 2026-04-10 18:29:58 -05:00
pax
faf9657ed9 add thumbnail fade-in animation 2026-04-10 18:18:17 -05:00
pax
5261fa176d add search history setting
New setting "Record recent searches" (on by default). When disabled,
searches are not recorded and the Recent section is hidden from the
history dropdown. Saved searches are unaffected.

behavior change: opt-in setting, on by default (preserves existing behavior)
2026-04-10 16:28:43 -05:00
pax
94588e324c add unbookmark-on-save setting
New setting "Remove bookmark when saved to library" (off by default).
When enabled, _maybe_unbookmark runs directly in each save callback
after save_post_file succeeds -- handles DB removal, grid dot, preview
state, popout sync, and bookmarks tab refresh. Wired into all 4 save
paths: save_to_library, bulk_save, save_as, batch_download_to.

behavior change: opt-in setting, off by default
2026-04-10 16:23:54 -05:00
pax
9cc294a16a Revert "add unbookmark-on-save setting"
This reverts commit 08f99a61011532202b22d05750416aa1e754f9c9.
2026-04-10 16:20:26 -05:00
pax
08f99a6101 add unbookmark-on-save setting
New setting "Remove bookmark when saved to library" (off by default).
When enabled, saving a post to the library automatically removes its
bookmark. Handles both single saves (on_bookmark_done) and bulk saves
(on_batch_done). UI toggle in Settings > General.

behavior change: opt-in setting, off by default
2026-04-10 16:19:00 -05:00
pax
ba49a59385 updated README.md and fixed redundant entries 2026-04-10 16:06:44 -05:00
pax
aac7b08787 create KEYBINDS.md 2026-04-10 16:02:37 -05:00
pax
d4bad47d42 add themes screanshots to README.md 2026-04-10 16:02:15 -05:00
pax
df301c754c condense README.md 2026-04-10 16:01:51 -05:00
pax
de6961da37 fix: move PySide6 imports to lazy in controllers for CI compat
CI installs httpx + Pillow + pytest but not PySide6. The Phase C
tests import pure functions from controller modules, which had
top-level PySide6 imports (QTimer, QPixmap, QApplication, QMessageBox).
Move these to lazy imports inside the methods that need them so the
module-level pure functions remain importable without Qt.
2026-04-10 15:39:50 -05:00
pax
f9977b61e6 fix: restore collateral-damage methods and fix controller init order
1. Move controller construction before _setup_signals/_setup_ui —
   signals reference controller methods at connect time.

2. Restore _post_id_from_library_path, _set_library_info,
   _on_library_selected, _on_library_activated — accidentally deleted
   in the commit 4/6 line-range removals (they lived adjacent to
   methods being extracted and got caught in the sweep).

behavior change: none (restores lost code, fixes startup crash)
2026-04-10 15:24:01 -05:00
pax
562c03071b test: Phase 2 — add 64 tests for extracted pure functions
5 new test files covering the pure-function extractions from Phase 1:
- test_search_controller.py (24): tag building, blacklist filtering, backfill
- test_window_state.py (16): geometry parsing, splitter parsing, hyprctl cmds
- test_media_controller.py (9): prefetch ring-expansion ordering
- test_post_actions.py (10): batch message detection, library membership
- test_popout_controller.py (3): video sync dict shape

All import-pure (no PySide6, no mpv, no httpx). Total suite: 186 tests.
2026-04-10 15:20:57 -05:00
pax
b858b4ac43 refactor: cleanup pass — remove dead imports from main_window.py
Remove 11 imports no longer needed after controller extractions:
QMenu, QFileDialog, QScrollArea, QMessageBox, QColor, QObject,
Property, dataclass, download_thumbnail, cache_size_bytes,
evict_oldest, evict_oldest_thumbnails, MEDIA_EXTENSIONS, SearchState.

main_window.py: 1140 -> 1128 lines (final Phase 1 state).

behavior change: none
2026-04-10 15:16:30 -05:00
pax
87be4eb2a6 refactor: extract ContextMenuHandler from main_window.py
Move _on_context_menu, _on_multi_context_menu, _is_child_of_menu into
gui/context_menus.py. Pure dispatch to already-extracted controllers.

main_window.py: 1400 -> 1140 lines.

behavior change: none
2026-04-10 15:15:21 -05:00
pax
8e9dda8671 refactor: extract PostActionsController from main_window.py
Move 26 bookmark/save/library/batch/blacklist methods and _batch_dest
state into gui/post_actions.py. Rewire 8 signal connections and update
popout_controller signal targets.

Extract is_batch_message and is_in_library as pure functions for
Phase 2 tests. main_window.py: 1935 -> 1400 lines.

behavior change: none
2026-04-10 15:13:29 -05:00
pax
0a8d392158 refactor: extract PopoutController from main_window.py
Move 5 popout lifecycle methods (_open_fullscreen_preview,
_on_fullscreen_closed, _navigate_fullscreen, _update_fullscreen,
_update_fullscreen_state) and 4 state attributes (_fullscreen_window,
_popout_active, _info_was_visible, _right_splitter_sizes) into
gui/popout_controller.py.

Rename pass across ALL gui/ files: self._fullscreen_window ->
self._popout_ctrl.window (or self._app._popout_ctrl.window in other
controllers), self._popout_active -> self._popout_ctrl.is_active.
Zero remaining references outside popout_controller.py.

Extract build_video_sync_dict as a pure function for Phase 2 tests.

main_window.py: 2145 -> 1935 lines.

behavior change: none
2026-04-10 15:03:42 -05:00
pax
20fc6f551e fix: restore _update_fullscreen and _update_fullscreen_state
These two methods were accidentally deleted in the commit 4 line-range
removal (they lived between _set_preview_media and _on_image_done).
Restored from pre-commit-4 state.

behavior change: none (restores lost code)
2026-04-10 15:00:42 -05:00
pax
71d426e0cf refactor: extract MediaController from main_window.py
Move 10 media loading methods (_on_post_activated, _on_image_done,
_on_video_stream, _on_download_progress, _set_preview_media,
_prefetch_adjacent, _on_prefetch_progress, _auto_evict_cache,
_image_dimensions) and _prefetch_pause state into
gui/media_controller.py.

Extract compute_prefetch_order as a pure function for Phase 2 tests.
Update search_controller.py cross-references to use media_ctrl.

main_window.py: 2525 -> 2114 lines.

behavior change: none
2026-04-10 14:55:32 -05:00
pax
446abe6ba9 refactor: extract SearchController from main_window.py
Move 21 search/pagination/scroll/blacklist methods and 8 state
attributes (_current_page, _current_tags, _current_rating, _min_score,
_loading, _search, _last_scroll_page, _infinite_scroll) into
gui/search_controller.py.

Extract pure functions for Phase 2 tests: build_search_tags,
filter_posts, should_backfill. Replace inline _filter closures with
calls to the module-level filter_posts function.

Rewire 11 signal connections and update _on_site_changed,
_on_rating_changed, _navigate_preview, _apply_settings to use the
controller. main_window.py: 3068 -> 2525 lines.

behavior change: none
2026-04-10 14:51:17 -05:00
pax
cb2445a90a refactor: extract PrivacyController from main_window.py
Move _toggle_privacy and its lazy state (_privacy_on, _privacy_overlay,
_popout_was_visible) into gui/privacy.py. Rewire menu action, popout
signal, resizeEvent, and keyPressEvent to use the controller.

No behavior change. main_window.py: 3111 -> 3068 lines.
2026-04-10 14:41:10 -05:00
pax
321ba8edfa refactor: extract WindowStateController from main_window.py
Move 6 geometry/splitter persistence methods into gui/window_state.py:
_save_main_window_state, _restore_main_window_state,
_hyprctl_apply_main_state, _hyprctl_main_window,
_save_main_splitter_sizes, _save_right_splitter_sizes.

Extract pure functions for Phase 2 tests: parse_geometry,
format_geometry, build_hyprctl_restore_cmds, parse_splitter_sizes.

Controller uses app-reference pattern (self._app). No behavior change.
main_window.py: 3318 -> 3111 lines.

behavior change: none
2026-04-10 14:39:37 -05:00
pax
3f7981a8c6 Update README.md 2026-04-10 14:18:41 -05:00
pax
d66dc14454 db: fix orphan rows — cascade delete_site, wire up reconcile on startup
delete_site() leaked rows in tag_types, search_history, and
saved_searches; reconcile_library_meta() was implemented but never
called. Add tests for both fixes plus tag cache pruning.
2026-04-10 14:10:57 -05:00
pax
e5a33739c9 Update README.md 2026-04-10 12:34:12 +00:00
pax
60867cfa37 Update readme.md 2026-04-10 00:44:51 -05:00
pax
df3b1d06d8 main_window: reset browse tab on site change 2026-04-10 00:37:53 -05:00
pax
127ee4315c popout/window: add right-click context menu
Popout now has a full context menu matching the embedded preview:
Bookmark as (folder submenu) / Unbookmark, Save to Library (folder
submenu), Unsave, Copy File, Open in Default App, Open in Browser,
Reset View (images), and Close Popout. Signals wired to the same
main_window handlers as the embedded preview.
2026-04-10 00:27:44 -05:00
pax
48feafa977 preview_pane: fix bookmark state in context menu, add folder submenu
behavior change: right-click context menu now shows "Unbookmark" when
the post is already bookmarked, and "Bookmark as" with a folder submenu
(Unfiled / existing folders / + New Folder) when not. Previously showed
a stateless "Bookmark" action regardless of state.
2026-04-10 00:27:36 -05:00
pax
38c5aefa27 fix releases link in readme 2026-04-10 00:14:44 -05:00
pax
a632f1b961 ci: use PYTHONPATH instead of editable install 2026-04-10 00:06:35 -05:00
pax
80607835d1 ci: install only test deps (skip PySide6/mpv build) 2026-04-10 00:04:28 -05:00
pax
8c1266ab0d ci: add GitHub Actions test workflow + README badge
Runs pytest tests/ on every push and PR. Ubuntu runner with
Python 3.11, libmpv, and QT_QPA_PLATFORM=offscreen for headless
Qt. Badge in README links to the Actions tab.

117 tests, ~0.2s locally. CI time depends on PySide6 install
(~2 min) + apt deps (~30s) + tests (~1s).
2026-04-10 00:01:28 -05:00
pax
a90d71da47 tests: add 36 tests for CategoryFetcher (parser, cache, probe, dispatch)
New test_category_fetcher.py covering:
  HTML parser (10): Rule34/Moebooru/Konachan markup, Gelbooru-empty,
    metadata->Meta mapping, URL-encoded names, edge cases
  Tag API parser (6): JSON, XML, empty, flat list, malformed
  Canonical ordering (4): standard order, species, unknown, empty
  Cache compose (6): full/partial/zero coverage, empty tags, order,
    per-site isolation
  Probe persistence (5): save/load True/False, per-site, clear wipes
  Batch API availability (3): URL+auth combinations
  Map coverage (2): label and type map constants

All pure Python — synthetic HTML, FakePost/FakeClient/FakeResponse.
No network, no Qt. Uses tmp_db fixture from conftest.

Total suite: 117 tests, 0.19s.
2026-04-09 23:58:56 -05:00
pax
ecda09152c ship tests/ (81 tests, was gitignored)
Remove tests/ from .gitignore and track the existing test suite:
  tests/core/test_db.py         — DB schema, migration, CRUD
  tests/core/test_cache.py      — cache helpers
  tests/core/test_config.py     — config/path helpers
  tests/core/test_concurrency.py — app loop accessor
  tests/core/api/test_base.py   — Post dataclass, BooruClient
  tests/gui/popout/test_state.py — 57 state machine tests

All pure Python, no secrets, no external deps. Uses temp DBs and
synthetic data. Run with: pytest tests/
2026-04-09 23:55:38 -05:00
pax
9a8e6037c3 settings: update template help text (all tokens work on all sites now) 2026-04-09 23:37:20 -05:00
pax
33227f3795 fix releases link in readme 2026-04-09 23:33:59 -05:00
pax
ee9d67e853 fix releases links again 2026-04-09 23:28:05 -05:00
pax
8ee7a2704b fix releases link in readme 2026-04-09 23:14:51 -05:00
pax
bda21a2615 changelog: update v0.2.4 with tag category, bug fix, and UI changes 2026-04-09 23:12:22 -05:00
pax
9b30e742c7 main_window: swap score and media filter positions in toolbar 2026-04-09 23:10:50 -05:00
pax
31089adf7d library: fix thumbnail lookup for templated filenames
Library thumbnails are saved by post_id (_copy_library_thumb uses
f"{post.id}.jpg") but the library viewer looked them up by file
stem (f"{filepath.stem}.jpg"). For digit-stem files (12345.jpg)
these are the same. For templated files (artist_12345.jpg) the
stem is "artist_12345" which doesn't match the thumbnail named
"12345.jpg" — wrong or missing thumbnails.

Fix: resolve post_id from the filename via
get_library_post_id_by_filename, then look up the thumbnail as
f"{post_id}.jpg". Generated thumbnails (for files without a
cached browse thumbnail) also store by post_id now, so
everything stays consistent.
2026-04-09 23:04:02 -05:00
pax
64f0096f32 library: fix tag search for templated filenames
The tag search filter in refresh() used f.stem.isdigit() to
extract post_id — templated filenames like artist_12345.jpg
failed the check and got filtered out even when their post_id
matched the search query.

Fix: look up post_id via db.get_library_post_id_by_filename
first (handles templated filenames), fall back to int(stem) for
legacy digit-stem files. Same pattern as the delete and saved-dot
fixes from earlier in this refactor.
2026-04-09 23:01:58 -05:00
pax
c02cc4fc38 Update README.md 2026-04-10 03:39:08 +00:00
pax
f63ac4c6d8 Releases URL points to gitea/github respectively 2026-04-10 03:34:28 +00:00
pax
6833ae701d Releases URL points to gitea/github respectively 2026-04-09 22:32:21 -05:00
pax
cc7ac67cac Update readme for v0.2.4 2026-04-09 22:29:36 -05:00
97 changed files with 10063 additions and 5250 deletions

55
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Bug Report
description: Something broken or misbehaving
title: "[BUG] "
labels: ["bug"]
body:
- type: textarea
id: summary
attributes:
label: Summary
description: What's broken?
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
value: |
1.
2.
3.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected vs actual behavior
validations:
required: true
- type: dropdown
id: os
attributes:
label: OS
options: [Linux, Windows, Other]
validations:
required: true
- type: input
id: version
attributes:
label: booru-viewer version / commit
validations:
required: true
- type: input
id: python
attributes:
label: Python & PySide6 version
- type: dropdown
id: backend
attributes:
label: Booru backend
options: [Danbooru, Gelbooru, Safebooru, e621, Other]
- type: textarea
id: logs
attributes:
label: Logs / traceback
render: shell

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions and general discussion
url: https://github.com/pxlwh/booru-viewer/discussions
about: For usage questions, setup help, and general chat that isn't a bug
- name: Gitea mirror
url: https://git.pax.moe/pax/booru-viewer
about: Primary development repo — same codebase, also accepts issues

22
.github/ISSUE_TEMPLATE/docs.yaml vendored Normal file
View File

@ -0,0 +1,22 @@
name: Documentation Issue
description: Typos, unclear sections, missing docs, broken links
title: "[DOCS] "
labels: ["documentation"]
body:
- type: input
id: file
attributes:
label: File or page
description: README.md, themes/README.md, HYPRLAND.md, KEYBINDS.md, in-app help, etc.
validations:
required: true
- type: textarea
id: problem
attributes:
label: What's wrong or missing?
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested fix or addition

View File

@ -0,0 +1,28 @@
name: Feature Request
description: Suggest a new feature or enhancement
title: "[FEAT] "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What's the use case or pain point?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
- type: checkboxes
id: scope
attributes:
label: Scope check
options:
- label: I've checked this isn't already implemented or tracked

View File

@ -0,0 +1,70 @@
name: Hyprland / Wayland Issue
description: Compositor-specific issues (window positioning, popout math, Waybar, multi-monitor)
title: "[HYPR] "
labels: ["hyprland", "wayland"]
body:
- type: textarea
id: summary
attributes:
label: What's happening?
description: Describe the compositor-specific behavior you're seeing
validations:
required: true
- type: dropdown
id: compositor
attributes:
label: Compositor
options: [Hyprland, Sway, KDE/KWin Wayland, GNOME/Mutter Wayland, Other Wayland, Other]
validations:
required: true
- type: input
id: compositor_version
attributes:
label: Compositor version
description: e.g. Hyprland v0.42.0
- type: dropdown
id: monitors
attributes:
label: Monitor setup
options: [Single monitor, Dual monitor, 3+ monitors, Mixed scaling, Mixed refresh rates]
- type: dropdown
id: area
attributes:
label: What area is affected?
options:
- Main window geometry / position
- Popout window positioning
- Popout aspect-ratio lock
- Popout anchor (resize pivot)
- Context menu / popup positioning
- Waybar exclusive zone handling
- Fullscreen (F11)
- Privacy screen overlay
- Other
validations:
required: true
- type: textarea
id: envvars
attributes:
label: Relevant env vars set
description: BOORU_VIEWER_NO_HYPR_RULES, BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK, etc.
placeholder: "BOORU_VIEWER_NO_HYPR_RULES=1"
render: shell
- type: textarea
id: windowrules
attributes:
label: Any windowrules targeting booru-viewer?
description: Paste relevant rules from your compositor config
render: shell
- type: textarea
id: hyprctl
attributes:
label: hyprctl output (if applicable)
description: "`hyprctl monitors -j`, `hyprctl clients -j` filtered to booru-viewer"
render: json
- type: input
id: version
attributes:
label: booru-viewer version / commit
validations:
required: true

72
.github/ISSUE_TEMPLATE/performance.yaml vendored Normal file
View File

@ -0,0 +1,72 @@
name: Performance Issue
description: Slowdowns, lag, high memory/CPU, UI freezes (distinct from broken features)
title: "[PERF] "
labels: ["performance"]
body:
- type: textarea
id: summary
attributes:
label: What's slow?
description: Describe what feels sluggish and what you'd expect
validations:
required: true
- type: dropdown
id: area
attributes:
label: What area?
options:
- Grid scroll / infinite scroll
- Thumbnail loading
- Search / API requests
- Image preview / pan-zoom
- Video playback
- Popout open / close
- Popout navigation
- Settings / dialogs
- Startup
- Other
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
value: |
1.
2.
3.
validations:
required: true
- type: input
id: timings
attributes:
label: Approximate timings
description: How long does the slow operation take? How long would you expect?
- type: input
id: library_size
attributes:
label: Library / bookmark size
description: Number of saved files and/or bookmarks, if relevant
- type: dropdown
id: os
attributes:
label: OS
options: [Linux, Windows, Other]
validations:
required: true
- type: input
id: hardware
attributes:
label: Hardware (CPU / RAM / GPU)
- type: textarea
id: logs
attributes:
label: Relevant DEBUG logs
description: Launch with Ctrl+L open and reproduce — paste anything that looks slow
render: shell
- type: input
id: version
attributes:
label: booru-viewer version / commit
validations:
required: true

View File

@ -0,0 +1,26 @@
name: Site Support Request
description: Request support for a new booru backend
title: "[SITE] "
labels: ["site-support"]
body:
- type: input
id: site
attributes:
label: Site name and URL
validations:
required: true
- type: dropdown
id: api
attributes:
label: API type
options: [Danbooru-compatible, Gelbooru-compatible, Moebooru, Shimmie2, Unknown, Other]
validations:
required: true
- type: input
id: api_docs
attributes:
label: Link to API documentation (if any)
- type: textarea
id: notes
attributes:
label: Auth, rate limits, or quirks worth knowing

View File

@ -0,0 +1,30 @@
name: Theme Submission
description: Submit a palette for inclusion
title: "[THEME] "
labels: ["theme"]
body:
- type: input
id: name
attributes:
label: Theme name
validations:
required: true
- type: textarea
id: palette
attributes:
label: Palette file contents
description: Paste the full @palette block or the complete .qss file
render: css
validations:
required: true
- type: input
id: screenshot
attributes:
label: Screenshot URL
- type: checkboxes
id: license
attributes:
label: Licensing
options:
- label: I'm okay with this being distributed under the project's license
required: true

39
.github/ISSUE_TEMPLATE/ux_feedback.yaml vendored Normal file
View File

@ -0,0 +1,39 @@
name: UX Feedback
description: Non-bug UX suggestions, workflow friction, small polish
title: "[UX] "
labels: ["ux"]
body:
- type: textarea
id: context
attributes:
label: What were you trying to do?
description: The workflow or action where the friction happened
validations:
required: true
- type: textarea
id: friction
attributes:
label: What felt awkward or wrong?
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: What would feel better?
description: Optional — a rough idea is fine
- type: dropdown
id: area
attributes:
label: Area
options:
- Grid / thumbnails
- Preview pane
- Popout window
- Top bar / filters
- Search
- Bookmarks
- Library
- Settings
- Keyboard shortcuts
- Theming
- Other

14
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,14 @@
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install test deps
run: pip install httpx[http2] Pillow pytest
- name: Run tests
run: PYTHONPATH=. pytest tests/ -v

1
.gitignore vendored
View File

@ -9,6 +9,5 @@ build/
venv/ venv/
docs/ docs/
project.md project.md
tests/
*.bak/ *.bak/
*.dll *.dll

View File

@ -1,11 +1,205 @@
# Changelog # Changelog
## 0.2.4 (pre-release) ## [Unreleased]
### 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
- 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
- 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
## 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`
## 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`.
## 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.
### 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.
## v0.2.4
Library filename templates, tag category fetching for all backends, and a popout video streaming overhaul. 50+ commits since v0.2.3. Library filename templates, tag category fetching for all backends, and a popout video streaming overhaul. 50+ commits since v0.2.3.
## Changes since v0.2.3
### New: library filename templates ### New: library filename templates
Save files with custom names instead of bare post IDs. Templates use `%id%`, `%artist%`, `%character%`, `%copyright%`, `%general%`, `%meta%`, `%species%`, `%md5%`, `%rating%`, `%score%`, `%ext%` tokens. Set in Settings > Paths. Save files with custom names instead of bare post IDs. Templates use `%id%`, `%artist%`, `%character%`, `%copyright%`, `%general%`, `%meta%`, `%species%`, `%md5%`, `%rating%`, `%score%`, `%ext%` tokens. Set in Settings > Paths.
@ -58,5 +252,535 @@ Click-to-first-frame latency on uncached video posts with the popout open is rou
- README updated, unused Windows screenshots dropped from the repo. - README updated, unused Windows screenshots dropped from the repo.
- Tightened thumbnail spacing in the grid from 8px to 2px. - Tightened thumbnail spacing in the grid from 8px to 2px.
- Max thumbnail size at 200px.
--- ## v0.2.3
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.
### 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.
## v0.2.2
A hardening + decoupling release. Bookmark folders and library folders are no longer the same thing under the hood, the `core/` layers get a defensive hardening pass, the async/DB layers get a real concurrency refactor, and the README finally articulates what this project is.
### Bookmarks ↔ Library decoupling
- **Bookmark folders and library folders are now independent name spaces.** Used to share identity through `_db.get_folders()` — the same string was both a row in `favorite_folders` and a directory under `saved_dir`. The cross-bleed produced a duplicate-on-move bug and made "Save to Library" silently re-file the bookmark. Now they're two stores: bookmark folders are DB-backed labels for organizing your bookmark list, library folders are real subdirectories of `saved/` for organizing files on disk.
- **`library_folders()`** in `core.config` is the new source of truth for every Save-to-Library menu — reads filesystem subdirs of `saved_dir` directly.
- **`find_library_files(post_id)`** is the new "is this saved?" / delete primitive — walks the library shallowly by post id.
- **Move-aware Save to Library.** If the post is already in another library folder, atomic `Path.rename()` into the destination instead of re-copying from cache. Also fixes the duplicate-on-move bug.
- **Library tab right-click: Move to Folder submenu** for both single and multi-select, using `Path.rename` for atomic moves.
- **Bookmarks tab: Folder button** next to + Folder for deleting the selected bookmark folder. DB-only, library filesystem untouched.
- **Browse tab right-click: "Bookmark as" submenu** when a post is not yet bookmarked (Unfiled / your bookmark folders / + New); flat "Remove Bookmark" when already bookmarked.
- **Embedded preview Bookmark button** got the same submenu shape via a new `bookmark_to_folder` signal + `set_bookmark_folders_callback`.
- **Popout Bookmark and Save buttons** both got the submenu treatment; works in both Browse and Bookmarks tab modes.
- **Popout in library mode** keeps the Save button visible as Unsave; the rest of the toolbar (Bookmark / BL Tag / BL Post) is hidden since they don't apply.
- **Popout state drift fixed.** `_update_fullscreen_state` now mirrors the embedded preview's `_is_bookmarked` / `_is_saved` instead of re-querying DB+filesystem, eliminating a state race during async bookmark adds.
- **"Unsorted" renamed to "Unfiled"** everywhere user-facing. Library Unfiled and bookmarks Unfiled now share one label.
- `favorite_folders` table preserved for backward compatibility — no migration required.
### Concurrency refactor
The earlier worker pattern of `threading.Thread + asyncio.run` was a real loop-affinity bug. The first throwaway loop a worker constructed would bind the shared httpx clients, and the next call from the persistent loop would fail with "Event loop is closed". This release routes everything through one loop and adds the locking and cleanup that should have been there from the start.
- **`core/concurrency.py`** is a new module: `set_app_loop()` / `get_app_loop()` / `run_on_app_loop()`. Every async piece of work in the GUI now schedules through one persistent loop, registered at startup by `BooruApp`.
- **`gui/sites.py` SiteDialog** Detect and Test buttons now route through `run_on_app_loop` instead of spawning a daemon thread. Results marshal back via Qt Signals with `QueuedConnection`. The dialog tracks in-flight futures and cancels them on close so a mid-detect dialog dismissal doesn't poke a destroyed QObject.
- **`gui/bookmarks.py` thumbnail loader** got the same swap. The existing `thumb_ready` signal already marshaled correctly.
- **Lazy-init lock on shared httpx clients.** `BooruClient._shared_client`, `E621Client._e621_client`, and `cache._shared_client` all use a fast-path / locked-slow-path lazy init. Concurrent first-callers can no longer both build a client and leak one.
- **`E621Client` UA-change leftover tracking.** When the User-Agent changes (api_user edit) and a new client is built, the old one is stashed in `_e621_to_close` and drained at shutdown instead of leaking.
- **`aclose_shared` on shutdown.** `BooruApp.closeEvent` now runs an `_close_all` coroutine via `run_coroutine_threadsafe(...).result(timeout=5)` before stopping the loop. Connection pools, keepalive sockets, and TLS state release cleanly instead of being abandoned.
- **`Database._write_lock` (RLock) + new `_write()` context manager.** Every write method now serializes through one lock so the asyncio thread and the Qt main thread can't interleave multi-statement writes. RLock so a writing method can call another writing method on the same thread without self-deadlocking. Reads stay lock-free under WAL.
### Defensive hardening
- **DB transactions.** `delete_site`, `add_search_history`, `remove_folder`, `rename_folder`, and `_migrate` now wrap their multi-statement bodies in `with self.conn:` so a crash mid-method can't leave orphan rows.
- **`add_bookmark` lastrowid fix.** When `INSERT OR IGNORE` collides on `(site_id, post_id)`, `lastrowid` is stale; the method now re-`SELECT`s the existing id. Was returning `Bookmark(id=0)` silently, which then no-op'd `update_bookmark_cache_path` on the next bookmark.
- **LIKE wildcard escape.** `get_bookmarks` LIKE clauses now `ESCAPE '\\'` so user search literals stop acting as SQL wildcards (`cat_ear` no longer matches `catear`).
- **Path traversal guard on folder names.** New `_validate_folder_name` rejects `..`, path separators, and leading `.`/`~` at write time. `saved_folder_dir()` resolves the candidate and refuses anything that doesn't `relative_to` the saved-images base.
- **Download size cap and streaming.** `download_image` enforces a 500 MB hard cap against the advertised Content-Length and the running total inside the chunk loop (servers can lie). Payloads ≥ 50 MB stream to a tempfile and atomic `os.replace` instead of buffering in RAM.
- **Per-URL coalesce lock.** `defaultdict[str, asyncio.Lock]` keyed by URL hash so concurrent callers downloading the same URL don't race `write_bytes`.
- **`Image.MAX_IMAGE_PIXELS = 256M`** with `DecompressionBombError` handling in both PIL converters.
- **Ugoira zip-bomb caps.** Frame count and cumulative uncompressed size checked from `ZipInfo` headers before any decompression.
- **`_convert_animated_to_gif` failure cache.** Writes a `.convfailed` sentinel sibling on failure to break the re-decode-every-paint loop for malformed animated PNGs/WebPs.
- **`_is_valid_media` distinguishes IO errors from "definitely invalid".** Returns `True` (don't delete) on `OSError` so a transient EBUSY/permissions hiccup no longer triggers a delete + re-download loop.
- **Hostname suffix matching for Referer.** Was using substring `in` matching, which meant `imgblahgelbooru.attacker.com` falsely mapped to `gelbooru.com`. Now uses proper suffix check.
- **`_request` retries on `httpx.NetworkError` and `httpx.ConnectError`** in addition to `TimeoutException`. A single DNS hiccup or RST no longer blows up the whole search.
- **`test_connection` no longer echoes the response body** in error strings. It was a body-leak gadget when used via `detect_site_type`'s redirect-following client.
- **Exception logging across `detect`, `search`, and `autocomplete`** in every API client. Previously every failure was a silent `return []`; now every swallowed exception logs at WARNING with type, message, and (where relevant) the response body prefix.
- **`main_gui.py`** `file_dialog_platform` DB probe failure now prints to stderr instead of vanishing.
- **Folder name validation surfaced as `QMessageBox.warning`** in `gui/bookmarks.py` and `gui/app.py` instead of crashing when a user types something the validator rejects.
### Popout overlay fix
- **`WA_StyledBackground` set on `_slideshow_toolbar` and `_slideshow_controls`.** Plain `QWidget` parents silently ignore QSS `background:` declarations without this attribute, which is why the popout overlay strip was rendering fully transparent (buttons styled, but the bar behind them showing the letterbox color).
- **Base popout overlay style baked into the QSS loader.** `_BASE_POPOUT_OVERLAY_QSS` is prepended before the user's `custom.qss` so themes that don't define overlay rules still get a usable translucent black bar with white text. Bundled themes still override on the same selectors.
### Popout aspect-ratio handling
The popout viewer's aspect handling had been patch-thrashing for ~20 commits since 0.2.0. A cold-context audit mapped 13 distinct failure modes still live in the code; this release closes the four highest-impact ones.
- **Width-anchor ratchet broken.** The previous `_fit_to_content` was width-anchored: `start_w = self.width()` read the current window width and derived height from aspect, with a back-derive if height exceeded the cap. Width was the only stable reference, and because portrait content has aspect < 1 and the height cap (90% of screen) was tighter than the width cap (100%), every portrait visit ran the back-derive and permanently shrunk the window. Going PLPLP on a 1080p screen produced a visibly smaller landscape on each loop.
- **New `Viewport(center_x, center_y, long_side)` model.** Three numbers, no aspect. Aspect is recomputed from content on every nav. The new `_compute_window_rect(viewport, content_aspect, screen)` is a pure static method: symmetric across portrait/landscape (`long_side` becomes width for landscape and height for portrait), proportional clamp shrinks both edges by the same factor when either would exceed its 0.90 ceiling, no asymmetric clamp constants, no back-derive step.
- **Viewport derived per-call from existing state.** No persistent field, no `moveEvent`/`resizeEvent` hooks needed for the basic ratchet fix. Three priority sources: pending one-shots (first fit after open or F11 exit) → current Hyprland window position+size → current Qt geometry. The Hyprland-current source captures whatever the user has dragged the popout to, so the next nav respects manual resizes.
- **First-fit aspect-lock race fixed.** `_fit_to_content` used to call `_is_hypr_floating` which returned `None` for both "not Hyprland" and "Hyprland but the window isn't visible to hyprctl yet". The latter happens on the very first popout open because the `wm:openWindow` event hasn't been processed when `set_media` fires. The method then fell through to a plain Qt resize and skipped the `keep_aspect_ratio` setprop, so the first image always opened unlocked and only subsequent navigations got the right shape. Now inlines the env-var check, distinguishes the two `None` cases, and retries on Hyprland with a 40ms backoff (capped at 5 attempts / 200ms total) when the window isn't registered yet.
- **Non-Hyprland top-left drift fixed.** The Qt fallback branch used to call `self.resize(w, h)`, which anchors top-left and lets bottom-right drift. The popout center walked toward the upper-left of the screen across navigations on Qt-driven WMs. Now uses `self.setGeometry(QRect(x, y, w, h))` with the computed top-left so the center stays put.
### Image fill in popout and embedded preview
- **`ImageViewer._fit_to_view` no longer caps zoom at native pixel size.** Used `min(scale_w, scale_h, 1.0)` so a smaller image in a larger window centered with letterbox space around it. The `1.0` cap is gone — images scale up to fill the available view, matching how the video player fills its widget. Combined with the popout's `keep_aspect_ratio`, the window matches the image's aspect AND the image fills it cleanly. Tiled popouts with mismatched aspect still letterbox (intentional — the layout owns the window shape).
### Main app flash and popout resize speed
- **Suppress dl_progress widget when the popout is open.** The download progress bar at the bottom of the right splitter was unconditionally `show()`'d on every grid click, including when the popout was open and the right splitter had been collapsed to give the grid full width. The show/hide pulse forced a layout pass on the right splitter that briefly compressed the main grid before the download finished and `hide()` fired. Visible flash on every click in the main app, even when clicking the same post that was already loaded (because `download_image` still runs against the cache). Three callsites now skip the widget entirely when the popout is visible. The status bar still updates with `Loading #X...` so the user has feedback in the main window.
- **Cache `_hyprctl_get_window` across one fit call.** `_fit_to_content` used to call `hyprctl clients -j` three times per popout navigation: once at the top for the floating check, once inside `_derive_viewport_for_fit` for the position/size read, and once inside `_hyprctl_resize_and_move` for the address lookup. Each call is a ~3ms `subprocess.run` that blocks the Qt event loop, totalling ~9ms of UI freeze per nav. The two helpers now accept an optional `win=None` parameter; `_fit_to_content` fetches the window dict once and threads it down. Per-fit subprocess count drops from 3 to 1 (~6ms saved per navigation), making rapid clicking and aspect-flip transitions feel snappier.
- **Show download progress on the active thumbnail when the embedded preview is hidden.** After the dl_progress suppression above landed, the user lost all visible download feedback in the main app whenever the popout was open. `_on_post_activated` now decides per call whether to use the dl_progress widget at the bottom of the right splitter or fall back to drawing the download progress on the active thumbnail in the main grid via the existing prefetch-progress paint path (`set_prefetch_progress(0.0..1.0)` to fill, `set_prefetch_progress(-1)` to clear). The decision is captured at function entry as `preview_hidden = not (self._preview.isVisible() and self._preview.width() > 0)` and closed over by the `_progress` callback and the `_load` coroutine, so the indicator that starts on a download stays on the same target even if the user opens or closes the popout mid-download. Generalizes to any reason the preview is hidden, not just popout-open: a user who has dragged the main splitter to collapse the preview gets the thumbnail indicator now too.
### Popout overlay stays hidden across navigation
- **Stop auto-showing the popout overlay on every `set_media`.** `FullscreenPreview.set_media` ended with an unconditional `self._show_overlay()` call, which meant the floating toolbar and video controls bar popped back into view on every left/right/hjkl navigation between posts. Visually noisy and not what the overlay is for — it's supposed to be a hover-triggered surface, not a per-post popup. Removed the call. The overlay is still shown by `__init__` default state (`_ui_visible = True`, so the user sees it for ~2 seconds on first popout open and the auto-hide timer hides it after that), by `eventFilter` mouse-move-into-top/bottom-edge-zone (the intended hover trigger, unchanged), by volume scroll on the video stack (unchanged), and by `Ctrl+H` toggle (unchanged). After this, the only way the overlay appears mid-session is hover or `Ctrl+H` — navigation through posts no longer flashes it back into view.
### Discord screen-share audio capture
- **`ao=pulse` in the mpv constructor.** mpv defaults to `ao=pipewire` (native PipeWire audio output) on Linux. Discord's screen-share-with-audio capture on Linux only enumerates clients connected via the libpulse API; native PipeWire clients are invisible to it. Visible symptom: video plays locally fine but audio is silently dropped from any Discord screen share. Firefox works because Firefox uses libpulse to talk to PipeWire's pulseaudio compat layer. Setting `ao="pulse,wasapi,"` in the MPV constructor (comma-separated priority list, mpv tries each in order) routes mpv through the same pulseaudio compat layer Firefox uses. `pulse` works on Linux; `wasapi` is the Windows fallback; trailing empty falls through to mpv's compiled-in default. No platform branch needed — mpv silently skips audio outputs that aren't available. Verified by inspection: with the fix, mpv's sink-input has `module-stream-restore.id = "sink-input-by-application-name:booru-viewer"` (the pulse-protocol form, identical to Firefox) instead of `"sink-input-by-application-id:booru-viewer"` (the native-pipewire form). References: [mpv #11100](https://github.com/mpv-player/mpv/issues/11100), [edisionnano/Screenshare-with-audio-on-Discord-with-Linux](https://github.com/edisionnano/Screenshare-with-audio-on-Discord-with-Linux).
- **`audio_client_name="booru-viewer"` in the mpv constructor.** mpv now registers in pulseaudio/pipewire introspection as `booru-viewer` instead of the default "mpv Media Player". Sets `application.name`, `application.id`, `application.icon_name`, `node.name`, and `device.description` to `booru-viewer` so capture tools group mpv's audio under the same identity as the Qt application.
### Docs
- **README repositioning.** New "Why booru-viewer" section between Screenshots and Features that names ahoviewer, Grabber, and Hydrus, lays out the labor axis (who does the filing) and the desktop axis (Hyprland/Wayland targeting), and explains the bookmark/library two-tier model with the browser-bookmark analogy.
- **New tagline** that does positioning instead of category description.
- **Bookmarks and Library Features sections split** to remove the previous intertwining; each now describes its own folder concept clearly.
- **Backup recipe** in Data Locations explaining the `saved/` + `booru.db` split and the recovery path.
- **Theming section** notes that each bundled theme ships in `*-rounded.qss` and `*-square.qss` variants.
### Fixes & polish
- **Drop the unused "Size: WxH" line from the InfoPanel** — bookmarks and library never had width/height plumbed and the field just showed 0×0.
- **Tighter combo and button padding across all 12 bundled themes.** `QPushButton` padding 2px 8px → 2px 6px, `QComboBox` padding 2px 6px → 2px 4px, `QComboBox::drop-down` width 18px → 14px. Saves 8px non-text width per combo and 4px per button.
- **Library sort combo: new "Post ID" entry** with a numeric stem sort that handles non-digit stems gracefully. Fits in 75px instead of needing 90px after the padding tightening.
- **Score and page spinboxes 50px → 40px** in the top toolbar to recover horizontal space. The internal range (099999) is unchanged; values >9999 will visually clip at the right edge but the stored value is preserved.
## v0.2.1
A theme + persistence + ricer-friendliness release. The whole stylesheet system was rebuilt around a runtime preprocessor with `@palette` / `${name}` vars, every bundled theme was rewritten end-to-end, and 12 theme variants ship instead of 6. Lots of UI state now survives a restart, and Hyprland ricers get an explicit opt-out for the in-code window management.
This release does not ship a fresh Windows installer — the previous v0.2.0 installer remains the latest installable binary. Run from source to get 0.2.1, or wait for the next release.
### Theming System
- **`@palette` / `${name}` preprocessor** — themes start with a `/* @palette */` header block listing color slots, the body uses `${name}` placeholders that the app substitutes at load time. Edit the 17-slot palette block at the top of any theme to recolor the entire app — no hunting through hex literals.
- **All 6 bundled themes rewritten** with comprehensive Fusion-style QSS covering every widget the app uses, every state (hover, focus, disabled, checked), every control variant
- **Two corner-radius variants per theme**`*-rounded.qss` (4px radius, default Fusion-style look) and `*-square.qss` (every border-radius stripped except radio buttons, which stay circular)
- **Native Fusion sizing** — themed widgets shrunk to match Qt+Fusion defaults, toolbar row height is now ~23px instead of 30px, matching what `no-custom.qss` renders
- **Bundled themes** — catppuccin-mocha, nord, gruvbox, solarized-dark, tokyo-night, everforest. 12 files total (6 themes × 2 variants)
### QSS-Targetable Surfaces
Many things hardcoded in Python paint code can now be overridden from a `custom.qss` without touching the source:
- **InfoPanel tag category colors**`qproperty-tagArtistColor`, `tagCharacterColor`, `tagCopyrightColor`, `tagSpeciesColor`, `tagMetaColor`, `tagLoreColor`
- **ThumbnailWidget selection paint**`qproperty-selectionColor`, `multiSelectColor`, `hoverColor`, `idleColor` (in addition to existing `savedColor` and `bookmarkedColor`)
- **VideoPlayer letterbox color**`qproperty-letterboxColor`. mpv paints the area around the video frame in this color instead of hardcoded black. Defaults to `QPalette.Window` so KDE color schemes, qt6ct, Windows dark/light mode, and any system Qt theme automatically produce a matching letterbox
- **Popout overlay bars** — translucent background for the floating top toolbar and bottom controls bar via the `overlay_bg` palette slot
- **Library count label states**`QLabel[libraryCountState="..."]` attribute selector distinguishes "N files" / "no items match" / "directory unreachable" with QSS-controlled colors instead of inline red
### Hyprland Integration
- **Two opt-out env vars** for users with their own windowrules:
- `BOORU_VIEWER_NO_HYPR_RULES=1` — disables every in-code hyprctl dispatch except the popout's keep_aspect_ratio lock
- `BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK=1` — independently disables the popout's aspect ratio enforcement
- **Popout overlays themed** — top toolbar and bottom controls bar now look themed instead of hardcoded translucent black, respect the `@palette` `overlay_bg` slot
- **Popout video letterbox tracks the theme's bg color** via the new `qproperty-letterboxColor`
- **Wayland app_id** set via `setDesktopFileName("booru-viewer")` so compositors can target windows by class — `windowrule = float, class:^(booru-viewer)$` — instead of by the volatile window title
### State Persistence
- **Main window** — geometry, floating mode, tiled mode (Hyprland)
- **Splitter sizes** — main splitter (grid vs preview), right splitter (preview vs dl_progress vs info panel)
- **Info panel visibility**
- **Cache spinbox** auto-derived dialog min height (no more clipping when dragging the settings dialog small)
- **Popout window** position, dimensions, and F11 fullscreen state restored via Hyprland floating cache prime
### UX
- **Live debounced search** in bookmarks and library tabs — type to filter, press Enter to commit immediately. 150ms debounce on bookmarks (cheap SQLite), 250ms on library (filesystem scan)
- **Search button removed** from bookmarks toolbar (live search + Enter)
- **Score field +/- buttons removed** from main search bar — type the value directly
- **Embedded preview video controls** moved out of the overlay style and into the panel layout, sitting under the media instead of floating on top of it. Popout still uses the floating overlay
- **Next-mode loop wraps** to the start of the bookmarks/library list at the end of the last item instead of stopping
- **Splitter handle margins** — 4px breathing margin on either side so toolbar buttons don't sit flush against the splitter line
### Performance
- **Page-load thumbnails** pre-fetch bookmarks + cache state into set lookups instead of N synchronous SQLite queries per page
- **Animated PNG/WebP conversion** off-loaded to a worker thread via `asyncio.to_thread` so it doesn't block the asyncio event loop during downloads
### Fixes
- **Open in Browser/Default App** on the bookmarks tab now opens the bookmark's actual source post (was opening unrelated cached files)
- **Cache settings spinboxes** can no longer be vertically clipped at the dialog's minimum size; spinboxes use Python-side `setMinimumHeight()` to propagate floors up the layout chain
- **Settings dialog** uses side-by-side `+`/`-` buttons instead of QSpinBox's default vertical arrows for clearer interaction
- **Bookmarks tab BL Tag** refreshes correctly when navigating bookmarked posts (was caching stale tags from the first selection)
- **Popout F11 → windowed** restores its previous windowed position and dimensions
- **Popout flicker on F11** transitions eliminated via `no_anim` setprop + deferred fit + dedupe of mpv `video-params` events
- **Bookmark + saved indicator dots** in the thumbnail grid: bookmark star on left, saved dot on right, both vertically aligned in a fixed-size box
- **Selection border** on thumbnail cells redrawn pen-aware: square geometry (no rounded corner artifacts), even line width on all sides, no off-by-one anti-aliasing seams
- **Toolbar buttons in narrow slots** no longer clip text (Bookmark/Unbookmark, Save/Unsave, BL Tag, BL Post, Popout, + Folder, Refresh) — all bumped to fit "Unbookmark" comfortably under the bundled themes' button padding
- **Toolbar rows** on bookmarks/library/preview panels now sit at a uniform 23px height matching the inputs/combos in the same row
- **Score and Page spinbox heights** forced to 23px via `setFixedHeight` to work around QSpinBox reserving vertical space for arrow buttons even when `setButtonSymbols(NoButtons)` is set
- **Library Open in Default App** uses the actual file path instead of routing through `cached_path_for` (which would return a hash path that doesn't exist for library files)
### Cleanup
- Deleted unused `booru_viewer/gui/theme.py` (222 lines of legacy stylesheet template that was never imported)
- Deleted `GREEN`/`DARK_GREEN`/`DIM_GREEN`/`BG`/`BG_LIGHT` etc constants from `booru_viewer/core/config.py` (only `theme.py` used them)
- Removed dead missing-indicator code (`set_missing`, `_missing_color`, `missingColor` Qt Property, the unreachable `if not filepath.exists()` branch in `library.refresh`)
- Removed dead score `+`/`-` buttons code path
## v0.2.0
### New: mpv video backend
- Replaced Qt Multimedia (QMediaPlayer/QVideoWidget) with embedded mpv via `python-mpv`
- OpenGL render API (`MpvRenderContext`) for Wayland-native compositing — no XWayland needed
- Proper hardware-accelerated decoding (`hwdec=auto`)
- Reliable aspect ratio handling — portrait videos scale correctly
- Proper end-of-file detection via `eof-reached` property observer instead of fragile position-jump heuristic
- Frame-accurate seeking with `absolute+exact` and `relative+exact`
- `keep-open=yes` holds last frame on video end instead of flashing black
- Windows: bundle `mpv-2.dll` in PyInstaller build
### New: popout viewer (renamed from slideshow)
- Renamed "Slideshow" to "Popout" throughout UI
- Toolbar and video controls float over media with translucent background (`rgba(0,0,0,160)`)
- Auto-hide after 2 seconds of inactivity, reappear on mouse move
- Ctrl+H manual toggle
- Media fills entire window — no layout shift when UI appears/disappears
- Video controls only show for video posts, hidden for images/GIFs
- Smart F11 exit: window sizes to 60% of monitor, maintaining content aspect ratio
- Window auto-resizes to content aspect ratio on navigation (height adjusts, position stays)
- Window geometry and fullscreen state persisted to DB across sessions
- Hyprland-specific: uses `hyprctl resizewindowpixel` + `setprop keep_aspect_ratio` to lock window to content aspect ratio (works both floating and tiled)
- Default site setting in Settings > General
### New: preview toolbar
- Action bar above the preview panel: Bookmark, Save, BL Tag, BL Post, Popout
- Appears when a post is active, hidden when preview is cleared
- Save button opens folder picker menu (Unsorted / existing folders / + New Folder)
- Save/Unsave state shown on button text
- Bookmark/Unbookmark state shown on button text
- Per-tab button visibility: Library tab only shows Save + Popout
- All actions work from any tab (Browse, Bookmarks, Library)
- Blacklist tag and blacklist post show confirmation dialogs
- "Unsave from Library" only appears in context menu when post is saved
### New: media type filter
- Replaced "Animated" checkbox with dropdown: All / Animated / Video / GIF / Audio
- Each option appends the corresponding booru tag to the search query
### New: thumbnail cache limits
- Added "Max thumbnail cache" setting (default 500 MB)
- Auto-evicts oldest thumbnails when limit is reached
### Improved: state synchronization
- Saving/unsaving updates grid thumbnail dots instantly (browse, bookmarks, library)
- Unbookmarking refreshes the bookmarks tab immediately
- Saving from browse/bookmarks refreshes the library tab when async save completes
- Library items set `_current_post` on click so toolbar actions work correctly
- Preview toolbar tracks bookmark and save state across all tabs
- Tab switching clears grid selections to prevent cross-tab action conflicts
- Bookmark state updates after async bookmark completes (not before)
### Improved: infinite scroll
- Fixed missing posts when media type filters reduce results per page
- Local dedup set (`seen`) prevents cross-page duplicates within backfill without polluting `shown_post_ids`
- Page counter only advances when results are returned, not when filtering empties them
- Backfill loop increased to 10 max pages with 300ms delay between API calls (first call instant)
### Improved: pagination
- Status bar shows "(end)" when search returns fewer results than page size
- Prev/Next buttons hide when at page boundaries instead of just disabling
- Source URLs clickable in info panel, truncated at 60 chars for display
### Improved: video controls
- Seek step changed from 5s to ~3s for `,` and `.` keys
- `,` and `.` seek keys now work in the main preview panel, not just popout
- Translucent overlay style on video controls in both preview and popout
- Volume slider fixed at 60px to not compete with seek slider at small sizes
### New: API retry logic
- Single retry with backoff on HTTP 429 (rate limit) and 503 (service unavailable)
- Retries on request timeout
- Respects `Retry-After` header (capped at 5s)
- Applied to all API requests (search, get_post, autocomplete) across all four clients
- Downloads are not retried (large payloads, separate client)
### Refactor: SearchState dataclass
- Consolidated 8 scattered search state attributes into a single `SearchState` dataclass
- Eliminated all defensive `getattr`/`hasattr` patterns (8 instances)
- State resets cleanly on new search — no stale infinite scroll data
### Dependencies
- Added `python-mpv>=1.0`
- Removed dependency on `PySide6.QtMultimedia` and `PySide6.QtMultimediaWidgets`
## v0.1.9
### New Features
- **Animated filter** — checkbox to only show animated/video posts (server-side `animated` tag)
- **Start from page** — page number field in top bar, jump to any page on search
- **Post date** — creation date shown in the info line
- **Prefetch modes** — Off / Nearby (4 cardinals) / Aggressive (3 row radius)
- **Animated PNG/WebP** — auto-converted to GIF for Qt playback
### Improvements
- Thumbnail selection/hover box hugs the actual image content
- Video controls locked to bottom of preview panel
- Score filter uses +/- buttons instead of spinbox arrows
- Cache eviction triggers after infinite scroll page drain
- Combobox dropdown styling fixed on Windows dark mode
- Saved thumbnail size applied on startup
### Fixes
- Infinite scroll no longer stops early from false exhaustion
- Infinite scroll triggers when viewport isn't full (initial load, splitter resize, window resize)
- Shared HTTP clients reset on startup (prevents stale event loop errors)
- Non-JSON API responses handled gracefully instead of crashing
## v0.1.8
### Windows Installer
- **Inno Setup installer** — proper Windows installer with Start Menu shortcut, optional desktop icon, and uninstaller
- **`--onedir` build** — instant startup, no temp extraction (was `--onefile`)
- **`optimize=2`** — stripped docstrings/asserts for smaller, faster bytecode
- **No UPX** — trades disk space for faster launch (no decompression overhead)
- **`noarchive`** — loose .pyc files, no zip decompression at startup
### Performance
- **Shared HTTP client for API calls** — single TLS handshake for all Danbooru/Gelbooru/Moebooru requests
- **E621 shared client** — separate pooled client (custom User-Agent required)
- **Site detection reuses shared client** — no extra TLS for auto-detect
- **Priority downloads** — clicking a post pauses prefetch, downloads at full speed, resumes after
- **Referer header per-request** — fixes Gelbooru CDN returning HTML captcha pages
### Infinite Scroll
- **Auto-fill viewport** — if first page doesn't fill the screen, auto-loads more
- **Auto-load after drain** — checks if still at bottom after staggered append finishes
- **Content-aware trigger** — fires when scrollbar max is 0 (no scroll needed)
### Library
- **Tag categories stored** — saved as JSON in both library_meta and bookmarks DB
- **Categorized tags in info panel** — Library and Bookmarks show Artist/Character/Copyright etc.
- **Tag search in Library** — search box filters by stored tags
- **Browse thumbnail copied on save** — Library tab shows thumbnails instantly
- **Unsave from Library** in bookmarks right-click menu
### Bugfixes
- **Clear preview on new search**
- **Fixed diagonal grid navigation** — viewport width used for column count
- **Fixed Gelbooru CDN** — Referer header passed per-request with shared client
- **Crash guards** — pop(0) on empty queue, bounds checks in API clients
- **Page cache capped** — 10 pages max in pagination mode
- **Missing DB migrations** — tag_categories column added to existing tables
- **Tag click switches to Browse** — clears preview and searches clicked tag
## v0.1.7
### Infinite Scroll
- **New mode** — toggle in Settings > General, applies live
- Auto-loads more posts when scrolling to bottom
- **Staggered loading** — posts appear one at a time as thumbnails arrive
- **Stops at end** — gracefully handles API exhaustion
- Arrow keys at bottom don't break the grid
- Loading locked during drain to prevent multi-page burst
- Triggered one row from bottom for seamless experience
### Page Cache & Deduplication
- Page results cached in memory — prev/next loads instantly
- Backfilled posts don't repeat on subsequent pages
- Page label updates on cached loads
### Prefetch
- **Ring expansion** — prefetches in all 8 directions (including diagonals)
- **Auto-start on search** — begins from top of page immediately
- **Re-centers on click** — restarts spiral from clicked post
- **Triggers on infinite scroll** — new appended posts prefetch automatically
### Clipboard
- **Copy File to Clipboard** — works in grid, preview, bookmarks, and library
- **Ctrl+C shortcut** — global shortcut via QShortcut
- **QMimeData** — uses same mechanism as drag-and-drop for universal compatibility
- Sets both file URL (for file managers) and image data (for Discord/image apps)
- Videos copy as file URIs
### Slideshow
- **Blacklist Tag button** — opens categorized tag menu
- **Blacklist Post button** — blacklists current post
### Blacklist
- **In-place removal** — blacklisting removes matching posts from grid without re-searching
- Preserves infinite scroll state
- Only clears preview when the blacklisted post is the one being viewed
### UI Polish
- **QProxyStyle dark arrows** — spinbox/combobox arrows visible on all dark QSS themes
- **Diagonal nav fix** — column count reads viewport width correctly
- **Status bar** — shows result count with action confirmations
- **Live settings** — infinite scroll, library dir, thumbnail size apply without restart
### Stability
- All silent exceptions logged
- Missing defaults added for fresh installs
- Git history cleaned
## v0.1.6
### Infinite Scroll
- **New mode** — toggle in Settings > General: "Infinite scroll (replaces page buttons)"
- Hides prev/next buttons, auto-loads more posts when scrolling to bottom
- Posts appended to grid, deduped, blacklist filtered
- Stops gracefully when API runs out of results (shows "end")
- Arrow keys at bottom don't nuke the grid — page turn disabled in infinite scroll
- Applies live — no restart needed
### Page Cache & Deduplication
- **Page results cached** — prev/next loads instantly from memory within a search session
- **Post deduplication** — backfilled posts don't repeat on subsequent pages
- **Page label updates** on cached page loads
### Prefetch
- **Ring expansion** — prefetches in all 8 directions (up, down, left, right, diagonals)
- **Auto-start on search** — begins prefetching from top of page immediately
- **Re-centers on click** — clicking a post restarts the spiral from that position
- **Triggers on infinite scroll** — new appended posts start prefetching automatically
### Slideshow
- **Blacklist Tag button** — opens categorized tag menu in slideshow toolbar
- **Blacklist Post button** — blacklists current post from slideshow toolbar
- **Blacklisting clears slideshow** — both preview and slideshow cleared when previewed post is blacklisted
### Copy to Clipboard
- **Ctrl+C** — copies preview image to clipboard (falls back to cached file)
- **Right-click grid** — "Copy Image to Clipboard" option
- **Right-click preview** — "Copy Image to Clipboard" always available
### Live Settings
- **Most settings apply instantly** — infinite scroll, library directory, thumbnail size, rating, score
- Removed "restart required" labels
### Bugfixes
- **Blacklisting doesn't clear unrelated preview** — only clears when the previewed post matches
- **Backfill confirmed working** — debug logging added
- **Status bar keeps result count** — shows "N results — Loaded" instead of just "Loaded"
- **Fixed README code block formatting** and added ffmpeg back to Linux deps

109
HYPRLAND.md Normal file
View File

@ -0,0 +1,109 @@
# Hyprland integration
I daily-drive booru-viewer on Hyprland and I've baked in my own opinions
on how the app should behave there. By default, a handful of `hyprctl`
dispatches run at runtime to:
- Restore the main window's last floating mode + dimensions on launch
- Restore the popout's position and keep it anchored to its configured
anchor point (center or any corner) as its content resizes during
navigation, and suppress F11 / fullscreen-transition flicker
- "Prime" Hyprland's per-window floating cache at startup so a mid-session
toggle to floating uses your saved dimensions
- Lock the popout's aspect ratio to its content so you can't accidentally
stretch mpv playback by dragging the popout corner
## Opting out
If you're a ricer with your own `windowrule`s targeting
`class:^(booru-viewer)$` and you'd rather the app keep its hands off your
setup, there are two independent opt-out env vars:
- **`BOORU_VIEWER_NO_HYPR_RULES=1`** — disables every in-code hyprctl
dispatch *except* the popout's `keep_aspect_ratio` lock. Use this if
you want app-side window management out of the way but you still want
the popout to size itself to its content.
- **`BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK=1`** — independently disables
the popout's aspect ratio enforcement. Useful if you want to drag the
popout to whatever shape you like (square, panoramic, monitor-aspect,
whatever) and accept that mpv playback will letterbox or stretch to
match.
For the full hands-off experience, set both:
```ini
[Desktop Entry]
Name=booru-viewer
Exec=env BOORU_VIEWER_NO_HYPR_RULES=1 BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK=1 /path/to/booru-viewer/.venv/bin/booru-viewer
Icon=/path/to/booru-viewer/icon.png
Type=Application
Categories=Graphics;
```
Or for one-off launches from a shell:
```bash
BOORU_VIEWER_NO_HYPR_RULES=1 booru-viewer
```
## Writing your own rules
If you're running with `BOORU_VIEWER_NO_HYPR_RULES=1` (or layering rules
on top of the defaults), here's the reference.
### Window identity
- Main window — class `booru-viewer`
- Popout — class `booru-viewer`, title `booru-viewer — Popout`
> ⚠ The popout title uses an em dash (`—`, U+2014), not a hyphen. A rule
> like `match:title = ^booru-viewer - Popout$` will silently match
> nothing. Either paste the em dash verbatim or match the tail:
> `match:title = Popout$`.
### Example rules
```ini
# Float the popout with aspect-locked resize and no animation flicker
windowrule {
match:class = ^(booru-viewer)$
match:title = Popout$
float = yes
keep_aspect_ratio = on
no_anim = on
}
# Per-window scroll factor if your global is too aggressive
windowrule {
match:class = ^(booru-viewer)$
match:title = Popout$
scroll_mouse = 0.65
}
```
### What the env vars actually disable
`BOORU_VIEWER_NO_HYPR_RULES=1` suppresses the in-code calls to:
- `dispatch resizeactive` / `moveactive` batches that restore saved
popout geometry
- `dispatch togglefloating` on the main window at launch
- `dispatch setprop address:<addr> no_anim 1` applied during popout
transitions (skipped on the first fit after open so Hyprland's
`windowsIn` / `popin` animation can play — subsequent navigation
fits still suppress anim to avoid resize flicker)
- The startup "prime" sequence that warms Hyprland's per-window
floating cache
`BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK=1` suppresses only
`dispatch setprop address:<addr> keep_aspect_ratio 1` on the popout.
Everything else still runs.
Read-only queries (`hyprctl clients -j`, `hyprctl monitors -j`) always
run regardless — the app needs them to know where it is.
### Hyprland requirements
The `keep_aspect_ratio` windowrule and `dispatch setprop
keep_aspect_ratio` both require a recent Hyprland. On older builds the
aspect lock is silently a no-op.

50
KEYBINDS.md Normal file
View File

@ -0,0 +1,50 @@
# Keybinds
## Grid
| Key | Action |
|-----|--------|
| Arrow keys / `h`/`j`/`k`/`l` | Navigate grid |
| `Ctrl+A` | Select all |
| `Ctrl+Click` / `Shift+Click` | Multi-select |
| `Home` / `End` | Jump to first / last |
| Scroll tilt left / right | Previous / next thumbnail (one cell) |
| `Ctrl+C` | Copy file to clipboard |
| Right click | Context menu |
## Preview
| Key | Action |
|-----|--------|
| Scroll wheel | Zoom (image) / volume (video) |
| Scroll tilt left / right | Previous / next post |
| Middle click / `0` | Reset view |
| Arrow keys / `h`/`j`/`k`/`l` | Navigate posts |
| `,` / `.` | Seek 3s back / forward (video) |
| `Space` | Play / pause (video, hover to activate) |
| Right click | Context menu (bookmark, save, popout) |
## Popout
| Key | Action |
|-----|--------|
| Arrow keys / `h`/`j`/`k`/`l` | Navigate posts |
| Scroll tilt left / right | Previous / next post |
| `,` / `.` | Seek 3s (video) |
| `Space` | Play / pause (video) |
| Scroll wheel | Volume up / down (video) |
| `B` / `F` | Toggle bookmark on selected post |
| `S` | Toggle save to library (Unfiled) |
| `F11` | Toggle fullscreen / windowed |
| `Ctrl+H` | Hide / show UI |
| `Ctrl+P` | Privacy screen |
| `Escape` / `Q` | Close popout |
## Global
| Key | Action |
|-----|--------|
| `B` / `F` | Toggle bookmark on selected post |
| `S` | Toggle save to library (Unfiled) |
| `Ctrl+P` | Privacy screen |
| `F11` | Toggle fullscreen |

215
README.md
View File

@ -1,14 +1,7 @@
# booru-viewer # booru-viewer
A Qt6 booru client for people who keep what they save and rice what they run. Browse, search, and archive Danbooru, e621, Gelbooru, and Moebooru on Linux and Windows. Fully themeable.
A booru client for people who keep what they save and rice what they run. <img src="screenshots/linux.png" alt="Linux — System Qt6 theme" width="700">
Qt6 desktop app for Linux and Windows. Browse, search, and archive Danbooru, e621, Gelbooru, and Moebooru. Fully themeable.
## Screenshot
**Linux — Styled via system Qt6 theme**
<picture><img src="screenshots/linux.png" alt="Linux — System Qt6 theme" width="700"></picture>
Supports custom styling via `custom.qss` — see [Theming](#theming). Supports custom styling via `custom.qss` — see [Theming](#theming).
@ -16,87 +9,52 @@ Supports custom styling via `custom.qss` — see [Theming](#theming).
booru-viewer has three tabs that map to three commitment levels: **Browse** for live search against booru APIs, **Bookmarks** for posts you've starred for later, **Library** for files you've actually saved to disk. booru-viewer has three tabs that map to three commitment levels: **Browse** for live search against booru APIs, **Bookmarks** for posts you've starred for later, **Library** for files you've actually saved to disk.
### Browsing **Browsing** — Danbooru, e621, Gelbooru, and Moebooru. Tag search with autocomplete, rating/score/media-type filters, blacklist with backfill, infinite scroll, page cache, keyboard grid navigation, multi-select with bulk actions, drag thumbnails out as files.
- Supports **Danbooru, e621, Gelbooru, and Moebooru**
- Auto-detect site API type — just paste the URL
- Tag search with autocomplete, history dropdown, and saved searches
- Rating and score filtering (server-side `score:>=N`)
- **Media type filter** — dropdown: All / Animated / Video / GIF / Audio
- Blacklisted tags and posts (client-side filtering with backfill)
- Thumbnail grid with keyboard navigation, multi-select (Ctrl/Shift+Click, Ctrl+A), bulk context menus, and drag thumbnails out as files
- **Infinite scroll** — optional, auto-loads more posts at bottom
- **Start from page** — jump to any page number on search
- **Page cache** — prev/next loads from memory, no duplicates
- **Copy File to Clipboard** — Ctrl+C, works for images and videos
### Preview **Preview** — Image zoom/pan, GIF/APNG/WebP animation, video via mpv (stream from CDN, seamless loop, seek, volume), ugoira auto-conversion, color-coded tag categories in info panel.
- Image viewer with zoom (scroll wheel), pan (drag), and reset (middle click)
- GIF animation, Pixiv ugoira auto-conversion (zip to animated GIF)
- Animated PNG/WebP auto-conversion to GIF
- Video playback via mpv (MP4, WebM, MKV) with play/pause, seek, volume, mute, and seamless looping
- Info panel with post details, date, clickable tags, and filetype
- **Preview toolbar** — Bookmark, Save, BL Tag, BL Post, and Popout buttons above the preview panel
### Popout Viewer **Popout** — Dedicated viewer window. Arrow/vim keys navigate posts during video. Auto-hiding overlay UI. F11 fullscreen, Ctrl+H hide UI, Ctrl+P privacy screen. Syncs bidirectionally with main grid.
- Right-click preview → "Popout" or click the Popout button in the preview toolbar
- Arrow keys / `h`/`j`/`k`/`l` navigate posts (including during video playback)
- `,` / `.` seek 3 seconds in videos, `Space` toggles play/pause
- Floating overlay UI — toolbar and video controls auto-hide after 2 seconds, reappear on mouse move
- `F11` toggles fullscreen/windowed, `Ctrl+H` hides all UI, `Ctrl+P` privacy screen
- Window auto-sizes to content aspect ratio; state persisted across sessions
- Hyprland: `keep_aspect_ratio` prop locks window to content proportions
- Bidirectional sync — clicking posts in the main grid updates the popout
- Video position and player state synced between preview and popout
### Bookmarks **Bookmarks** — Star posts for later. Folder organization, tag search, bulk save/remove, JSON import/export.
- **Bookmark** posts you might want later — lightweight pointers in the database, like clicking the star in your browser
- Group bookmarks into folders, separate from Library's folders
- Search bookmarks by tag
- Bulk save, unbookmark, or remove from the multi-select context menu
- Import/export bookmarks as JSON
- Unbookmark from grid, preview, or popout
### Library **Library** — Save to disk with metadata indexing. Customizable filename templates (`%id%`, `%artist%`, `%md5%`, etc). Folder organization, tag search, sort by date/name/size.
- **Save** posts you want to keep — real files on disk in `saved/`, named by post ID, browsable in any file manager
- One-click promotion from bookmark to library when you decide to commit
- **Tag search across saved metadata** — type to filter by indexed tags, no filename conventions required
- On-disk folder organization with configurable library directory and folder sidebar — save unsorted or to a named subfolder
- Sort by date, name, or size
- Video thumbnail generation (ffmpeg if available, placeholder fallback)
- Unsave from grid, preview, and popout (only shown when post is saved)
- Unreachable directory detection
### Search **Search** — Inline history dropdown, saved searches, session cache mode.
- Inline history dropdown inside the search bar
- Saved searches with management dialog
- Click empty search bar to open history
- Session cache mode clears history on exit (keeps saved searches)
## Install ## Install
### Windows ### Windows
Download `booru-viewer-setup.exe` from [Releases](https://git.pax.moe/pax/booru-viewer/releases) and run the installer. It installs to AppData with Start Menu and optional desktop shortcuts. To update, just run the new installer over the old one — your data in `%APPDATA%\booru-viewer\` is preserved. Download `booru-viewer-setup.exe` from Releases and run the installer. It installs to AppData with Start Menu and optional desktop shortcuts. To update, just run the new installer over the old one. Your data in `%APPDATA%\booru-viewer\` is preserved.
Github: [/pxlwh/booru-viewer/releases](https://github.com/pxlwh/booru-viewer/releases)
Gitea: [/pax/booru-viewer/releases](https://git.pax.moe/pax/booru-viewer/releases)
Windows 10 dark mode is automatically detected and applied. Windows 10 dark mode is automatically detected and applied.
### Linux ### Linux
Requires Python 3.11+ and pip. Most distros ship Python but you may need to install pip and the Qt6 system libraries. **Arch / CachyOS / Manjaro** — install from the AUR:
**Arch / CachyOS:**
```sh ```sh
sudo pacman -S python python-pip qt6-base mpv ffmpeg yay -S booru-viewer-git
# or: paru -S booru-viewer-git
``` ```
**Ubuntu / Debian (24.04+):** The AUR package tracks the gitea `main` branch, so `yay -Syu` pulls the latest commit. Desktop entry and icon are installed automatically.
AUR: [/packages/booru-viewer-git](https://aur.archlinux.org/packages/booru-viewer-git)
**Other distros** — build from source. Requires Python 3.11+ and Qt6 system libraries.
Ubuntu / Debian (24.04+):
```sh ```sh
sudo apt install python3 python3-pip python3-venv mpv libmpv-dev ffmpeg sudo apt install python3 python3-pip python3-venv mpv libmpv-dev
``` ```
**Fedora:** Fedora:
```sh ```sh
sudo dnf install python3 python3-pip qt6-qtbase mpv mpv-libs-devel ffmpeg sudo dnf install python3 python3-pip qt6-qtbase mpv mpv-libs-devel
``` ```
Then clone and install: Then clone and install:
@ -106,16 +64,10 @@ cd booru-viewer
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -e . pip install -e .
```
Run it:
```sh
booru-viewer booru-viewer
``` ```
Or without installing: `python3 -m booru_viewer.main_gui` To add a launcher entry, create `~/.local/share/applications/booru-viewer.desktop`:
**Desktop entry:** To add booru-viewer to your app launcher, create `~/.local/share/applications/booru-viewer.desktop`:
```ini ```ini
[Desktop Entry] [Desktop Entry]
Name=booru-viewer Name=booru-viewer
@ -127,47 +79,11 @@ Categories=Graphics;
### Hyprland integration ### Hyprland integration
I daily-drive booru-viewer on Hyprland and I've baked in my own opinions on booru-viewer ships with built-in Hyprland window management (popout
how the app should behave there. By default, a handful of `hyprctl` dispatches geometry restore, aspect ratio lock, animation suppression, etc.) that
run at runtime to: can be fully or partially opted out of via env vars. See
[HYPRLAND.md](HYPRLAND.md) for the full details, opt-out flags, and
- Restore the main window's last floating mode + dimensions on launch example `windowrule` reference.
- Restore the popout's position, center-pin it around its content during
navigation, and suppress F11 / fullscreen-transition flicker
- "Prime" Hyprland's per-window floating cache at startup so a mid-session
toggle to floating uses your saved dimensions
- Lock the popout's aspect ratio to its content so you can't accidentally
stretch mpv playback by dragging the popout corner
If you're a ricer with your own `windowrule`s targeting `class:^(booru-viewer)$`
and you'd rather the app keep its hands off your setup, there are two
independent opt-out env vars:
- **`BOORU_VIEWER_NO_HYPR_RULES=1`** — disables every in-code hyprctl dispatch
*except* the popout's `keep_aspect_ratio` lock. Use this if you want app-side
window management out of the way but you still want the popout to size itself
to its content.
- **`BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK=1`** — independently disables the popout's
aspect ratio enforcement. Useful if you want to drag the popout to whatever
shape you like (square, panoramic, monitor-aspect, whatever) and accept that
mpv playback will letterbox or stretch to match.
For the full hands-off experience, set both:
```ini
[Desktop Entry]
Name=booru-viewer
Exec=env BOORU_VIEWER_NO_HYPR_RULES=1 BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK=1 /path/to/booru-viewer/.venv/bin/booru-viewer
Icon=/path/to/booru-viewer/icon.png
Type=Application
Categories=Graphics;
```
Or for one-off launches from a shell:
```bash
BOORU_VIEWER_NO_HYPR_RULES=1 booru-viewer
```
### Dependencies ### Dependencies
@ -176,54 +92,11 @@ BOORU_VIEWER_NO_HYPR_RULES=1 booru-viewer
- httpx - httpx
- Pillow - Pillow
- python-mpv - python-mpv
- mpv (system package on Linux, bundled DLL on Windows) - mpv
## Keybinds ## Keybinds
### Grid See [KEYBINDS.md](KEYBINDS.md) for the full list.
| Key | Action |
|-----|--------|
| Arrow keys / `h`/`j`/`k`/`l` | Navigate grid |
| `Ctrl+A` | Select all |
| `Ctrl+Click` / `Shift+Click` | Multi-select |
| `Home` / `End` | Jump to first / last |
| Scroll tilt left / right | Previous / next thumbnail (one cell) |
| `Ctrl+C` | Copy file to clipboard |
| Right click | Context menu |
### Preview
| Key | Action |
|-----|--------|
| Scroll wheel | Zoom (image) / volume (video) |
| Scroll tilt left / right | Previous / next post |
| Middle click / `0` | Reset view |
| Arrow keys / `h`/`j`/`k`/`l` | Navigate posts |
| `,` / `.` | Seek 3s back / forward (video) |
| `Space` | Play / pause (video, hover to activate) |
| Right click | Context menu (bookmark, save, popout) |
### Popout
| Key | Action |
|-----|--------|
| Arrow keys / `h`/`j`/`k`/`l` | Navigate posts |
| Scroll tilt left / right | Previous / next post |
| `,` / `.` | Seek 3s (video) |
| `Space` | Play / pause (video) |
| Scroll wheel | Volume up / down (video) |
| `F11` | Toggle fullscreen / windowed |
| `Ctrl+H` | Hide / show UI |
| `Ctrl+P` | Privacy screen |
| `Escape` / `Q` | Close popout |
### Global
| Key | Action |
|-----|--------|
| `Ctrl+P` | Privacy screen |
| `F11` | Toggle fullscreen |
## Adding Sites ## Adding Sites
@ -249,22 +122,14 @@ The app uses your OS native theme by default. To customize, copy a `.qss` file f
A template is also available in Settings > Theme > Create from Template. A template is also available in Settings > Theme > Create from Template.
### Included Themes Six themes included, each in rounded and square variants. See [`themes/`](themes/) for screenshots and the full QSS reference.
Each theme ships in two variants: `*-rounded.qss` (4px corner radius) and `*-square.qss` (no corner radius except radio buttons). Same colors, different geometry.
<picture><img src="screenshots/themes/nord.png" alt="Nord" width="400"></picture> <picture><img src="screenshots/themes/catppuccin-mocha.png" alt="Catppuccin Mocha" width="400"></picture>
<picture><img src="screenshots/themes/gruvbox.png" alt="Gruvbox" width="400"></picture> <picture><img src="screenshots/themes/solarized-dark.png" alt="Solarized Dark" width="400"></picture>
<picture><img src="screenshots/themes/tokyo-night.png" alt="Tokyo Night" width="400"></picture> <picture><img src="screenshots/themes/everforest.png" alt="Everforest" width="400"></picture>
## Settings ## Settings
- **General** — page size, thumbnail size, default site, default rating/score, prefetch mode (Off / Nearby / Aggressive), infinite scroll, popout monitor, file dialog platform - **General** — page size, thumbnail size (100-200px), default site, default rating/score, prefetch mode (Off / Nearby / Aggressive), infinite scroll, unbookmark on save, search history, flip layout, popout monitor, popout anchor (resize pivot), file dialog platform
- **Cache** — max cache size, max thumbnail cache, auto-evict, clear cache on exit (session-only mode) - **Cache** — max cache size, max thumbnail cache, auto-evict, clear cache on exit (session-only mode)
- **Blacklist** — tag blacklist with toggle, post URL blacklist - **Blacklist** — tag blacklist with toggle, post URL blacklist
- **Paths** — data directory, cache, database, configurable library directory - **Paths** — data directory, cache, database, configurable library directory, library filename template
- **Theme** — custom.qss editor, template generator, CSS guide - **Theme** — custom.qss editor, template generator, CSS guide
- **Network** — connection log showing all hosts contacted this session - **Network** — connection log showing all hosts contacted this session
@ -279,11 +144,7 @@ Each theme ships in two variants: `*-rounded.qss` (4px corner radius) and `*-squ
To back up everything: copy `saved/` for the files themselves and `booru.db` for bookmarks, folders, and tag metadata. The two are independent — restoring one without the other still works. The `saved/` folder is browsable on its own in any file manager, and the database can be re-populated from the booru sites for any post IDs you still have on disk. To back up everything: copy `saved/` for the files themselves and `booru.db` for bookmarks, folders, and tag metadata. The two are independent — restoring one without the other still works. The `saved/` folder is browsable on its own in any file manager, and the database can be re-populated from the booru sites for any post IDs you still have on disk.
## Privacy **Privacy:** No telemetry, analytics, or update checks. Only connects to booru sites you configure. Verify in Settings > Network.
booru-viewer makes **no connections** except to the booru sites you configure. There is no telemetry, analytics, update checking, or phoning home. All data stays local on your machine.
Every outgoing request is logged in Settings > Network so you can verify this yourself — you will only see requests to the booru API endpoints and CDNs you chose to connect to.
## Support ## Support

View File

@ -0,0 +1,18 @@
"""booru_viewer.core package — pure-Python data + I/O layer (no Qt).
Side effect on import: install the project-wide PIL decompression-bomb
cap. PIL's default warns silently above ~89M pixels; we want a hard
fail above 256M pixels so DecompressionBombError can be caught and
treated as a download failure.
Setting it here (rather than as a side effect of importing
``core.cache``) means any code path that touches PIL via any
``booru_viewer.core.*`` submodule gets the cap installed first,
regardless of submodule import order. Audit finding #8.
"""
from PIL import Image as _PILImage
_PILImage.MAX_IMAGE_PIXELS = 256 * 1024 * 1024
del _PILImage

View File

@ -0,0 +1,150 @@
"""Network-safety helpers for httpx clients.
Keeps SSRF guards and secret redaction in one place so every httpx
client in the project can share a single implementation. All helpers
here are pure stdlib + httpx; no Qt, no project-side imports.
"""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from typing import Any, Mapping
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import httpx
# ---------------------------------------------------------------------------
# SSRF guard — finding #1
# ---------------------------------------------------------------------------
_BLOCKED_V4 = [
ipaddress.ip_network("0.0.0.0/8"), # this-network
ipaddress.ip_network("10.0.0.0/8"), # RFC1918
ipaddress.ip_network("100.64.0.0/10"), # CGNAT
ipaddress.ip_network("127.0.0.0/8"), # loopback
ipaddress.ip_network("169.254.0.0/16"), # link-local (incl. 169.254.169.254 metadata)
ipaddress.ip_network("172.16.0.0/12"), # RFC1918
ipaddress.ip_network("192.0.0.0/24"), # IETF protocol assignments
ipaddress.ip_network("192.168.0.0/16"), # RFC1918
ipaddress.ip_network("198.18.0.0/15"), # benchmark
ipaddress.ip_network("224.0.0.0/4"), # multicast
ipaddress.ip_network("240.0.0.0/4"), # reserved
]
_BLOCKED_V6 = [
ipaddress.ip_network("::1/128"), # loopback
ipaddress.ip_network("::/128"), # unspecified
ipaddress.ip_network("::ffff:0:0/96"), # IPv4-mapped (covers v4 via v6)
ipaddress.ip_network("64:ff9b::/96"), # well-known NAT64
ipaddress.ip_network("fc00::/7"), # unique local
ipaddress.ip_network("fe80::/10"), # link-local
ipaddress.ip_network("ff00::/8"), # multicast
]
def _is_blocked_ip(ip: ipaddress._BaseAddress) -> bool:
nets = _BLOCKED_V4 if isinstance(ip, ipaddress.IPv4Address) else _BLOCKED_V6
return any(ip in net for net in nets)
def check_public_host(host: str) -> None:
"""Raise httpx.RequestError if ``host`` is (or resolves to) a non-public IP.
Blocks loopback, RFC1918, link-local (including the 169.254.169.254
cloud-metadata endpoint), unique-local v6, and similar. Used by both
the initial request and every redirect hop see
``validate_public_request`` for the async wrapper.
"""
if not host:
return
try:
ip = ipaddress.ip_address(host)
except ValueError:
ip = None
if ip is not None:
if _is_blocked_ip(ip):
raise httpx.RequestError(f"blocked address: {host}")
return
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror as e:
raise httpx.RequestError(f"DNS resolution failed for {host}: {e}")
seen: set[str] = set()
for info in infos:
addr = info[4][0]
if addr in seen:
continue
seen.add(addr)
try:
resolved = ipaddress.ip_address(addr.split("%", 1)[0])
except ValueError:
continue
if _is_blocked_ip(resolved):
raise httpx.RequestError(
f"blocked request target {host} -> {addr}"
)
async def validate_public_request(request: httpx.Request) -> None:
"""httpx request event hook — rejects private/metadata targets.
Fires on every hop including redirects. The initial request to a
user-configured booru base_url is also validated; this intentionally
blocks users from pointing the app at ``http://localhost/`` or an
RFC1918 address (behavior change from v0.2.5).
Limitation: TOCTOU / DNS rebinding. We resolve the host here, but
the kernel will re-resolve when the TCP connection actually opens,
and a rebinder that returns a public IP on first query and a
private IP on the second can bypass this hook. The project's threat
model is a *malicious booru returning a 3xx to a private address*
not an active rebinder controlling the DNS recursor so this check
is the intended defense line. If the threat model ever widens, the
follow-up is a custom httpx transport that validates post-connect.
"""
host = request.url.host
if not host:
return
await asyncio.to_thread(check_public_host, host)
# ---------------------------------------------------------------------------
# Credential redaction — finding #3
# ---------------------------------------------------------------------------
# Case-sensitive; matches the literal param names every booru client
# uses today (verified via grep across danbooru/e621/gelbooru/moebooru).
SECRET_KEYS: frozenset[str] = frozenset({
"login",
"api_key",
"user_id",
"password_hash",
})
def redact_url(url: str) -> str:
"""Replace secret query params with ``***`` in a URL string.
Preserves ordering and non-secret params. Empty-query URLs pass
through unchanged.
"""
parts = urlsplit(url)
if not parts.query:
return url
pairs = parse_qsl(parts.query, keep_blank_values=True)
redacted = [(k, "***" if k in SECRET_KEYS else v) for k, v in pairs]
return urlunsplit((
parts.scheme,
parts.netloc,
parts.path,
urlencode(redacted),
parts.fragment,
))
def redact_params(params: Mapping[str, Any]) -> dict[str, Any]:
"""Return a copy of ``params`` with secret keys replaced by ``***``."""
return {k: ("***" if k in SECRET_KEYS else v) for k, v in params.items()}

View File

@ -10,8 +10,9 @@ from dataclasses import dataclass, field
import httpx import httpx
from ..config import USER_AGENT, DEFAULT_PAGE_SIZE from ..config import DEFAULT_PAGE_SIZE
from ..cache import log_connection from ..cache import log_connection
from ._safety import redact_url
log = logging.getLogger("booru") log = logging.getLogger("booru")
@ -99,16 +100,11 @@ class BooruClient(ABC):
return c return c
# Slow path: build it. Lock so two coroutines on the same loop don't # Slow path: build it. Lock so two coroutines on the same loop don't
# both construct + leak. # both construct + leak.
from ..http import make_client
with BooruClient._shared_client_lock: with BooruClient._shared_client_lock:
c = BooruClient._shared_client c = BooruClient._shared_client
if c is None or c.is_closed: if c is None or c.is_closed:
c = httpx.AsyncClient( c = make_client(extra_request_hooks=[self._log_request])
headers={"User-Agent": USER_AGENT},
follow_redirects=True,
timeout=20.0,
event_hooks={"request": [self._log_request]},
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
BooruClient._shared_client = c BooruClient._shared_client = c
return c return c
@ -127,7 +123,11 @@ class BooruClient(ABC):
@staticmethod @staticmethod
async def _log_request(request: httpx.Request) -> None: async def _log_request(request: httpx.Request) -> None:
log_connection(str(request.url)) # Redact api_key / login / user_id / password_hash from the
# URL before it ever crosses the function boundary — the
# rendered URL would otherwise land in tracebacks, debug logs,
# or in-app connection-log views as plaintext.
log_connection(redact_url(str(request.url)))
_RETRYABLE_STATUS = frozenset({429, 503}) _RETRYABLE_STATUS = frozenset({429, 503})
@ -152,9 +152,18 @@ class BooruClient(ABC):
wait = 2.0 wait = 2.0
log.info(f"Retrying {url} after {resp.status_code} (wait {wait}s)") log.info(f"Retrying {url} after {resp.status_code} (wait {wait}s)")
await asyncio.sleep(wait) await asyncio.sleep(wait)
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError) as e: except (
# Retry on transient DNS/TCP/timeout failures. Without this, httpx.TimeoutException,
# a single DNS hiccup or RST blows up the whole search. httpx.ConnectError,
httpx.NetworkError,
httpx.RemoteProtocolError,
httpx.ReadError,
) as e:
# Retry on transient DNS/TCP/timeout failures plus
# mid-response drops — RemoteProtocolError and ReadError
# are common when an overloaded booru closes the TCP
# connection between headers and body. Without them a
# single dropped response blows up the whole search.
if attempt == 1: if attempt == 1:
raise raise
log.info(f"Retrying {url} after {type(e).__name__}: {e}") log.info(f"Retrying {url} after {type(e).__name__}: {e}")

View File

@ -76,6 +76,13 @@ _LABEL_MAP: dict[str, str] = {
"style": "Style", "style": "Style",
} }
# Sentinel cap on the HTML body the regex walks over. A real
# Gelbooru/Moebooru post page is ~30-150KB; capping at 2MB gives
# any legit page comfortable headroom while preventing a hostile
# server from feeding the regex hundreds of MB and pegging CPU.
# Audit finding #14.
_FETCH_POST_HTML_CAP = 2 * 1024 * 1024
# Gelbooru tag DAPI integer code -> Capitalized label (for fetch_via_tag_api) # Gelbooru tag DAPI integer code -> Capitalized label (for fetch_via_tag_api)
_GELBOORU_TYPE_MAP: dict[int, str] = { _GELBOORU_TYPE_MAP: dict[int, str] = {
0: "General", 0: "General",
@ -206,6 +213,31 @@ class CategoryFetcher:
and bool(self._client.api_user) and bool(self._client.api_user)
) )
def _build_tag_api_params(self, chunk: list[str]) -> dict:
"""Params dict for a tag-DAPI batch request.
The ``lstrip("&")`` and ``startswith("api_key=")`` guards
accommodate users who paste their credentials with a leading
``&`` or as ``api_key=VALUE`` either form gets normalised
to a clean namevalue mapping.
"""
params: dict = {
"page": "dapi",
"s": "tag",
"q": "index",
"json": "1",
"names": " ".join(chunk),
"limit": len(chunk),
}
if self._client.api_key and self._client.api_user:
key = self._client.api_key.strip().lstrip("&")
user = self._client.api_user.strip().lstrip("&")
if key and not key.startswith("api_key="):
params["api_key"] = key
if user and not user.startswith("user_id="):
params["user_id"] = user
return params
async def fetch_via_tag_api(self, posts: list["Post"]) -> int: async def fetch_via_tag_api(self, posts: list["Post"]) -> int:
"""Batch-fetch tag types via the booru's tag DAPI. """Batch-fetch tag types via the booru's tag DAPI.
@ -237,21 +269,7 @@ class CategoryFetcher:
BATCH = 500 BATCH = 500
for i in range(0, len(missing), BATCH): for i in range(0, len(missing), BATCH):
chunk = missing[i:i + BATCH] chunk = missing[i:i + BATCH]
params: dict = { params = self._build_tag_api_params(chunk)
"page": "dapi",
"s": "tag",
"q": "index",
"json": "1",
"names": " ".join(chunk),
"limit": len(chunk),
}
if self._client.api_key and self._client.api_user:
key = self._client.api_key.strip().lstrip("&")
user = self._client.api_user.strip().lstrip("&")
if key and not key.startswith("api_key="):
params["api_key"] = key
if user and not user.startswith("user_id="):
params["user_id"] = user
try: try:
resp = await self._client._request("GET", tag_api_url, params=params) resp = await self._client._request("GET", tag_api_url, params=params)
resp.raise_for_status() resp.raise_for_status()
@ -290,7 +308,12 @@ class CategoryFetcher:
log.warning("Category HTML fetch for #%d failed: %s: %s", log.warning("Category HTML fetch for #%d failed: %s: %s",
post.id, type(e).__name__, e) post.id, type(e).__name__, e)
return False return False
cats, labels = _parse_post_html(resp.text) # Cap the HTML the regex walks over (audit #14). Truncation
# vs. full read: the body is already buffered by httpx, so
# this doesn't prevent a memory hit — but it does cap the
# CPU spent in _TAG_ELEMENT_RE.finditer for a hostile server
# returning hundreds of MB of HTML.
cats, labels = _parse_post_html(resp.text[:_FETCH_POST_HTML_CAP])
if not cats: if not cats:
return False return False
post.tag_categories = _canonical_order(cats) post.tag_categories = _canonical_order(cats)
@ -334,29 +357,41 @@ class CategoryFetcher:
async def _do_ensure(self, post: "Post") -> None: async def _do_ensure(self, post: "Post") -> None:
"""Inner dispatch for ensure_categories. """Inner dispatch for ensure_categories.
Tries the batch API when it's known to work (True) OR not yet Dispatch:
probed (None). The result doubles as an inline probe: if the - ``_batch_api_works is True``: call ``fetch_via_tag_api``
batch produced categories, it works (save True); if it directly. If it populates categories we're done; a
returned nothing useful, it's broken (save False). Falls transient failure leaves them empty and we fall through
through to HTML scrape as the universal fallback. to the HTML scrape.
- ``_batch_api_works is None``: route through
``_probe_batch_api``, which only flips the flag to
True/False on a clean HTTP response. Transient errors
leave it ``None`` so the next call retries the probe.
Previously this path called ``fetch_via_tag_api`` and
inferred the result from empty ``tag_categories`` but
``fetch_via_tag_api`` swallows per-chunk failures with
``continue``, so a mid-call network drop poisoned
``_batch_api_works = False`` for the site permanently.
- ``_batch_api_works is False`` or unavailable: straight
to HTML scrape.
""" """
if self._batch_api_works is not False and self._batch_api_available(): if self._batch_api_works is True and self._batch_api_available():
try: try:
await self.fetch_via_tag_api([post]) await self.fetch_via_tag_api([post])
except Exception as e: except Exception as e:
log.debug("Batch API ensure failed (transient): %s", e) log.debug("Batch API ensure failed (transient): %s", e)
# Leave _batch_api_works at None → retry next call
else:
if post.tag_categories: if post.tag_categories:
if self._batch_api_works is None:
self._batch_api_works = True
self._save_probe_result(True)
return return
# Batch returned nothing → broken API (Rule34) or elif self._batch_api_works is None and self._batch_api_available():
# the specific post has only unknown tags (very rare). try:
if self._batch_api_works is None: result = await self._probe_batch_api([post])
self._batch_api_works = False except Exception as e:
self._save_probe_result(False) log.info("Batch API probe error (will retry next call): %s: %s",
type(e).__name__, e)
result = None
if result is True:
# Probe succeeded — results cached and post composed.
return
# result is False (broken API) or None (transient) — fall through
# HTML scrape fallback (works on Rule34/Safebooru.org/Moebooru, # HTML scrape fallback (works on Rule34/Safebooru.org/Moebooru,
# returns empty on Gelbooru proper which is fine because the # returns empty on Gelbooru proper which is fine because the
# batch path above covers Gelbooru) # batch path above covers Gelbooru)
@ -468,21 +503,7 @@ class CategoryFetcher:
# Send one batch request # Send one batch request
chunk = missing[:500] chunk = missing[:500]
params: dict = { params = self._build_tag_api_params(chunk)
"page": "dapi",
"s": "tag",
"q": "index",
"json": "1",
"names": " ".join(chunk),
"limit": len(chunk),
}
if self._client.api_key and self._client.api_user:
key = self._client.api_key.strip().lstrip("&")
user = self._client.api_user.strip().lstrip("&")
if key and not key.startswith("api_key="):
params["api_key"] = key
if user and not user.startswith("user_id="):
params["user_id"] = user
try: try:
resp = await self._client._request("GET", tag_api_url, params=params) resp = await self._client._request("GET", tag_api_url, params=params)
@ -581,6 +602,9 @@ def _parse_tag_response(resp) -> list[tuple[str, int]]:
return [] return []
out: list[tuple[str, int]] = [] out: list[tuple[str, int]] = []
if body.startswith("<"): if body.startswith("<"):
if "<!DOCTYPE" in body or "<!ENTITY" in body:
log.warning("XML response contains DOCTYPE/ENTITY, skipping")
return []
try: try:
root = ET.fromstring(body) root = ET.fromstring(body)
except ET.ParseError as e: except ET.ParseError as e:

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from ..config import DEFAULT_PAGE_SIZE from ..config import DEFAULT_PAGE_SIZE
from ._safety import redact_params
from .base import BooruClient, Post, _parse_date from .base import BooruClient, Post, _parse_date
log = logging.getLogger("booru") log = logging.getLogger("booru")
@ -23,7 +24,7 @@ class DanbooruClient(BooruClient):
url = f"{self.base_url}/posts.json" url = f"{self.base_url}/posts.json"
log.info(f"GET {url}") log.info(f"GET {url}")
log.debug(f" params: {params}") log.debug(f" params: {redact_params(params)}")
resp = await self._request("GET", url, params=params) resp = await self._request("GET", url, params=params)
log.info(f" -> {resp.status_code}") log.info(f" -> {resp.status_code}")
if resp.status_code != 200: if resp.status_code != 200:

View File

@ -4,9 +4,7 @@ from __future__ import annotations
import logging import logging
import httpx from ..http import make_client
from ..config import USER_AGENT
from .danbooru import DanbooruClient from .danbooru import DanbooruClient
from .gelbooru import GelbooruClient from .gelbooru import GelbooruClient
from .moebooru import MoebooruClient from .moebooru import MoebooruClient
@ -28,16 +26,12 @@ async def detect_site_type(
url = url.rstrip("/") url = url.rstrip("/")
from .base import BooruClient as _BC from .base import BooruClient as _BC
# Reuse shared client for site detection # Reuse shared client for site detection. Event hooks mirror
# BooruClient.client so detection requests get the same SSRF
# validation and connection logging as regular API calls.
if _BC._shared_client is None or _BC._shared_client.is_closed: if _BC._shared_client is None or _BC._shared_client.is_closed:
_BC._shared_client = httpx.AsyncClient( _BC._shared_client = make_client(extra_request_hooks=[_BC._log_request])
headers={"User-Agent": USER_AGENT},
follow_redirects=True,
timeout=20.0,
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
client = _BC._shared_client client = _BC._shared_client
if True: # keep indent level
# Try Danbooru / e621 first — /posts.json is a definitive endpoint # Try Danbooru / e621 first — /posts.json is a definitive endpoint
try: try:
params: dict = {"limit": 1} params: dict = {"limit": 1}

View File

@ -8,6 +8,7 @@ import threading
import httpx import httpx
from ..config import DEFAULT_PAGE_SIZE, USER_AGENT from ..config import DEFAULT_PAGE_SIZE, USER_AGENT
from ._safety import redact_params, validate_public_request
from .base import BooruClient, Post, _parse_date from .base import BooruClient, Post, _parse_date
log = logging.getLogger("booru") log = logging.getLogger("booru")
@ -47,6 +48,12 @@ class E621Client(BooruClient):
headers={"User-Agent": ua}, headers={"User-Agent": ua},
follow_redirects=True, follow_redirects=True,
timeout=20.0, timeout=20.0,
event_hooks={
"request": [
validate_public_request,
BooruClient._log_request,
],
},
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5), limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
) )
E621Client._e621_client = c E621Client._e621_client = c
@ -77,7 +84,7 @@ class E621Client(BooruClient):
url = f"{self.base_url}/posts.json" url = f"{self.base_url}/posts.json"
log.info(f"GET {url}") log.info(f"GET {url}")
log.debug(f" params: {params}") log.debug(f" params: {redact_params(params)}")
resp = await self._request("GET", url, params=params) resp = await self._request("GET", url, params=params)
log.info(f" -> {resp.status_code}") log.info(f" -> {resp.status_code}")
if resp.status_code != 200: if resp.status_code != 200:
@ -85,7 +92,7 @@ class E621Client(BooruClient):
resp.raise_for_status() resp.raise_for_status()
try: try:
data = resp.json() data = resp.json()
except Exception as e: except ValueError as e:
log.warning("e621 search JSON parse failed: %s: %s — body: %s", log.warning("e621 search JSON parse failed: %s: %s — body: %s",
type(e).__name__, e, resp.text[:200]) type(e).__name__, e, resp.text[:200])
return [] return []

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from ..config import DEFAULT_PAGE_SIZE from ..config import DEFAULT_PAGE_SIZE
from ._safety import redact_params
from .base import BooruClient, Post, _parse_date from .base import BooruClient, Post, _parse_date
log = logging.getLogger("booru") log = logging.getLogger("booru")
@ -43,7 +44,7 @@ class GelbooruClient(BooruClient):
url = f"{self.base_url}/index.php" url = f"{self.base_url}/index.php"
log.info(f"GET {url}") log.info(f"GET {url}")
log.debug(f" params: {params}") log.debug(f" params: {redact_params(params)}")
resp = await self._request("GET", url, params=params) resp = await self._request("GET", url, params=params)
log.info(f" -> {resp.status_code}") log.info(f" -> {resp.status_code}")
if resp.status_code != 200: if resp.status_code != 200:

View File

@ -28,7 +28,7 @@ class MoebooruClient(BooruClient):
resp.raise_for_status() resp.raise_for_status()
try: try:
data = resp.json() data = resp.json()
except Exception as e: except ValueError as e:
log.warning("Moebooru search JSON parse failed: %s: %s — body: %s", log.warning("Moebooru search JSON parse failed: %s: %s — body: %s",
type(e).__name__, e, resp.text[:200]) type(e).__name__, e, resp.text[:200])
return [] return []

View File

@ -9,7 +9,7 @@ import os
import tempfile import tempfile
import threading import threading
import zipfile import zipfile
from collections import OrderedDict, defaultdict from collections import OrderedDict
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@ -17,7 +17,7 @@ from urllib.parse import urlparse
import httpx import httpx
from PIL import Image from PIL import Image
from .config import cache_dir, thumbnails_dir, USER_AGENT from .config import cache_dir, thumbnails_dir
log = logging.getLogger("booru") log = logging.getLogger("booru")
@ -33,10 +33,8 @@ MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024 # 500 MB
# regression risk of the streaming rewrite is zero. # regression risk of the streaming rewrite is zero.
STREAM_TO_DISK_THRESHOLD = 50 * 1024 * 1024 # 50 MB STREAM_TO_DISK_THRESHOLD = 50 * 1024 * 1024 # 50 MB
# Cap PIL's auto-DOS guard at 256M pixels (~1 GB raw). Default warns # PIL's MAX_IMAGE_PIXELS cap is set in core/__init__.py so any
# silently above ~89M; we want a hard fail so DecompressionBombError # `booru_viewer.core.*` import installs it first — see audit #8.
# can be caught and treated as a download failure.
Image.MAX_IMAGE_PIXELS = 256 * 1024 * 1024
# Defends `_convert_ugoira_to_gif` against zip bombs. A real ugoira is # Defends `_convert_ugoira_to_gif` against zip bombs. A real ugoira is
# typically <500 frames at 1080p; these caps comfortably allow legit # typically <500 frames at 1080p; these caps comfortably allow legit
@ -79,18 +77,14 @@ def _get_shared_client(referer: str = "") -> httpx.AsyncClient:
c = _shared_client c = _shared_client
if c is not None and not c.is_closed: if c is not None and not c.is_closed:
return c return c
# Lazy import: core.http imports from core.api._safety, which
# lives inside the api package that imports this module, so a
# top-level import would circular through cache.py's load.
from .http import make_client
with _shared_client_lock: with _shared_client_lock:
c = _shared_client c = _shared_client
if c is None or c.is_closed: if c is None or c.is_closed:
c = httpx.AsyncClient( c = make_client(timeout=60.0, accept="image/*,video/*,*/*")
headers={
"User-Agent": USER_AGENT,
"Accept": "image/*,video/*,*/*",
},
follow_redirects=True,
timeout=60.0,
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
_shared_client = c _shared_client = c
return c return c
@ -119,6 +113,33 @@ _IMAGE_MAGIC = {
b'PK\x03\x04': True, # ZIP (ugoira) b'PK\x03\x04': True, # ZIP (ugoira)
} }
# Header size used by both _looks_like_media (in-memory bytes) and the
# in-stream early validator in _do_download. 16 bytes covers JPEG (3),
# PNG (8), GIF (6), WebP (12), MP4/MOV (8), WebM/MKV (4), and ZIP (4)
# magics with comfortable margin.
_MEDIA_HEADER_MIN = 16
def _looks_like_media(header: bytes) -> bool:
"""Return True if the leading bytes match a known media magic.
Conservative on the empty case: an empty header is "unknown",
not "valid", because the streaming validator (audit #10) calls us
before any bytes have arrived means the server returned nothing
useful. The on-disk validator wraps this with an OSError fallback
that returns True instead see _is_valid_media.
"""
if not header:
return False
if header.startswith(b'<') or header.startswith(b'<!'):
return False
for magic in _IMAGE_MAGIC:
if header.startswith(magic):
return True
# Not a known magic and not HTML: treat as ok (some boorus serve
# exotic-but-legal containers we don't enumerate above).
return b'<html' not in header.lower() and b'<!doctype' not in header.lower()
def _is_valid_media(path: Path) -> bool: def _is_valid_media(path: Path) -> bool:
"""Check if a file looks like actual media, not an HTML error page. """Check if a file looks like actual media, not an HTML error page.
@ -130,18 +151,11 @@ def _is_valid_media(path: Path) -> bool:
""" """
try: try:
with open(path, "rb") as f: with open(path, "rb") as f:
header = f.read(16) header = f.read(_MEDIA_HEADER_MIN)
except OSError as e: except OSError as e:
log.warning("Cannot read %s for validation (%s); treating as valid", path, e) log.warning("Cannot read %s for validation (%s); treating as valid", path, e)
return True return True
if not header or header.startswith(b'<') or header.startswith(b'<!'): return _looks_like_media(header)
return False
# Check for known magic bytes
for magic in _IMAGE_MAGIC:
if header.startswith(magic):
return True
# If not a known type but not HTML, assume it's ok
return b'<html' not in header.lower() and b'<!doctype' not in header.lower()
def _ext_from_url(url: str) -> str: def _ext_from_url(url: str) -> str:
@ -271,7 +285,59 @@ def _referer_for(parsed) -> str:
# does the actual download; the other waits and reads the cached file. # does the actual download; the other waits and reads the cached file.
# Loop-bound, but the existing module is already loop-bound, so this # Loop-bound, but the existing module is already loop-bound, so this
# doesn't make anything worse and is fixed cleanly in PR2. # doesn't make anything worse and is fixed cleanly in PR2.
_url_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) #
# Capped at _URL_LOCKS_MAX entries (audit finding #5). The previous
# defaultdict grew unbounded over a long browsing session, and an
# adversarial booru returning cache-buster query strings could turn
# the leak into an OOM DoS.
_URL_LOCKS_MAX = 4096
_url_locks: "OrderedDict[str, asyncio.Lock]" = OrderedDict()
def _get_url_lock(h: str) -> asyncio.Lock:
"""Return the asyncio.Lock for URL hash *h*, creating it if needed.
Touches LRU order on every call so frequently-accessed hashes
survive eviction. The first call for a new hash inserts it and
triggers _evict_url_locks() to trim back toward the cap.
"""
lock = _url_locks.get(h)
if lock is None:
lock = asyncio.Lock()
_url_locks[h] = lock
_evict_url_locks(skip=h)
else:
_url_locks.move_to_end(h)
return lock
def _evict_url_locks(skip: str) -> None:
"""Trim _url_locks back toward _URL_LOCKS_MAX, oldest first.
Each pass skips:
- the hash *skip* we just inserted (it's the youngest — evicting
it immediately would be self-defeating), and
- any entry whose lock is currently held (we can't drop a lock
that a coroutine is mid-`async with` on without that coroutine
blowing up on exit).
Stops as soon as one pass finds no evictable entries that
handles the edge case where every remaining entry is either
*skip* or currently held. In that state the cap is temporarily
exceeded; the next insertion will retry eviction.
"""
while len(_url_locks) > _URL_LOCKS_MAX:
evicted = False
for old_h in list(_url_locks.keys()):
if old_h == skip:
continue
if _url_locks[old_h].locked():
continue
_url_locks.pop(old_h, None)
evicted = True
break
if not evicted:
return
async def download_image( async def download_image(
@ -288,7 +354,7 @@ async def download_image(
filename = _url_hash(url) + _ext_from_url(url) filename = _url_hash(url) + _ext_from_url(url)
local = dest_dir / filename local = dest_dir / filename
async with _url_locks[_url_hash(url)]: async with _get_url_lock(_url_hash(url)):
# Check if a ugoira zip was already converted to gif # Check if a ugoira zip was already converted to gif
if local.suffix.lower() == ".zip": if local.suffix.lower() == ".zip":
gif_path = local.with_suffix(".gif") gif_path = local.with_suffix(".gif")
@ -374,7 +440,30 @@ async def _do_download(
f"Download too large: {total} bytes (cap {MAX_DOWNLOAD_BYTES})" f"Download too large: {total} bytes (cap {MAX_DOWNLOAD_BYTES})"
) )
if total >= STREAM_TO_DISK_THRESHOLD: # Audit #10: accumulate the leading bytes (≥16) before
# committing to writing the rest. A hostile server that omits
# Content-Type and ignores the HTML check could otherwise
# stream up to MAX_DOWNLOAD_BYTES of garbage to disk before
# the post-download _is_valid_media check rejects and deletes
# it. We accumulate across chunks because slow servers (or
# chunked encoding with tiny chunks) can deliver fewer than
# 16 bytes in the first chunk and validation would false-fail.
use_large = total >= STREAM_TO_DISK_THRESHOLD
chunk_iter = resp.aiter_bytes(64 * 1024 if use_large else 8192)
header_buf = bytearray()
async for chunk in chunk_iter:
header_buf.extend(chunk)
if len(header_buf) >= _MEDIA_HEADER_MIN:
break
if len(header_buf) > MAX_DOWNLOAD_BYTES:
raise ValueError(
f"Download exceeded cap mid-stream: {len(header_buf)} bytes"
)
if not _looks_like_media(bytes(header_buf)):
raise ValueError("Downloaded data is not valid media")
if use_large:
# Large download: stream to tempfile in the same dir, atomic replace. # Large download: stream to tempfile in the same dir, atomic replace.
local.parent.mkdir(parents=True, exist_ok=True) local.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp( fd, tmp_name = tempfile.mkstemp(
@ -382,9 +471,12 @@ async def _do_download(
) )
tmp_path = Path(tmp_name) tmp_path = Path(tmp_name)
try: try:
downloaded = 0 downloaded = len(header_buf)
with os.fdopen(fd, "wb") as out: with os.fdopen(fd, "wb") as out:
async for chunk in resp.aiter_bytes(64 * 1024): out.write(header_buf)
if progress_callback:
progress_callback(downloaded, total)
async for chunk in chunk_iter:
out.write(chunk) out.write(chunk)
downloaded += len(chunk) downloaded += len(chunk)
if downloaded > MAX_DOWNLOAD_BYTES: if downloaded > MAX_DOWNLOAD_BYTES:
@ -395,6 +487,8 @@ async def _do_download(
progress_callback(downloaded, total) progress_callback(downloaded, total)
os.replace(tmp_path, local) os.replace(tmp_path, local)
except BaseException: except BaseException:
# BaseException on purpose: also clean up the .part file on
# Ctrl-C / task cancellation, not just on Exception.
try: try:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
except OSError: except OSError:
@ -402,9 +496,11 @@ async def _do_download(
raise raise
else: else:
# Small/unknown size: buffer in memory, write whole. # Small/unknown size: buffer in memory, write whole.
chunks: list[bytes] = [] chunks: list[bytes] = [bytes(header_buf)]
downloaded = 0 downloaded = len(header_buf)
async for chunk in resp.aiter_bytes(8192): if progress_callback:
progress_callback(downloaded, total)
async for chunk in chunk_iter:
chunks.append(chunk) chunks.append(chunk)
downloaded += len(chunk) downloaded += len(chunk)
if downloaded > MAX_DOWNLOAD_BYTES: if downloaded > MAX_DOWNLOAD_BYTES:
@ -496,23 +592,36 @@ def cache_file_count(include_thumbnails: bool = True) -> tuple[int, int]:
return images, thumbs return images, thumbs
def evict_oldest(max_bytes: int, protected_paths: set[str] | None = None) -> int: def evict_oldest(max_bytes: int, protected_paths: set[str] | None = None,
"""Delete oldest non-protected cached images until under max_bytes. Returns count deleted.""" current_bytes: int | None = None) -> int:
protected = protected_paths or set() """Delete oldest non-protected cached images until under max_bytes. Returns count deleted.
files = sorted(cache_dir().iterdir(), key=lambda f: f.stat().st_mtime)
deleted = 0
current = cache_size_bytes(include_thumbnails=False)
for f in files: *current_bytes* avoids a redundant directory scan when the caller
already measured the cache size.
"""
protected = protected_paths or set()
# Single directory walk: collect (path, stat) pairs, sort by mtime,
# and sum sizes — avoids the previous pattern of iterdir() for the
# sort + a second full iterdir()+stat() inside cache_size_bytes().
entries = []
total = 0
for f in cache_dir().iterdir():
if not f.is_file():
continue
st = f.stat()
entries.append((f, st))
total += st.st_size
current = current_bytes if current_bytes is not None else total
entries.sort(key=lambda e: e[1].st_mtime)
deleted = 0
for f, st in entries:
if current <= max_bytes: if current <= max_bytes:
break break
if not f.is_file() or str(f) in protected or f.suffix == ".part": if str(f) in protected or f.suffix == ".part":
continue continue
size = f.stat().st_size
f.unlink() f.unlink()
current -= size current -= st.st_size
deleted += 1 deleted += 1
return deleted return deleted
@ -521,17 +630,23 @@ def evict_oldest_thumbnails(max_bytes: int) -> int:
td = thumbnails_dir() td = thumbnails_dir()
if not td.exists(): if not td.exists():
return 0 return 0
files = sorted(td.iterdir(), key=lambda f: f.stat().st_mtime) entries = []
deleted = 0 current = 0
current = sum(f.stat().st_size for f in td.iterdir() if f.is_file()) for f in td.iterdir():
for f in files:
if current <= max_bytes:
break
if not f.is_file(): if not f.is_file():
continue continue
size = f.stat().st_size st = f.stat()
entries.append((f, st))
current += st.st_size
if current <= max_bytes:
return 0
entries.sort(key=lambda e: e[1].st_mtime)
deleted = 0
for f, st in entries:
if current <= max_bytes:
break
f.unlink() f.unlink()
current -= size current -= st.st_size
deleted += 1 deleted += 1
return deleted return deleted

View File

@ -15,6 +15,18 @@ if TYPE_CHECKING:
APPNAME = "booru-viewer" APPNAME = "booru-viewer"
IS_WINDOWS = sys.platform == "win32" IS_WINDOWS = sys.platform == "win32"
# Windows reserved device names (audit finding #7). Filenames whose stem
# (before the first dot) lower-cases to one of these are illegal on
# Windows because the OS routes opens of `con.jpg` to the CON device.
# Checked by render_filename_template() unconditionally so a library
# saved on Linux can still be copied to a Windows machine without
# breaking on these stems.
_WINDOWS_RESERVED_NAMES = frozenset({
"con", "prn", "aux", "nul",
*{f"com{i}" for i in range(1, 10)},
*{f"lpt{i}" for i in range(1, 10)},
})
def hypr_rules_enabled() -> bool: def hypr_rules_enabled() -> bool:
"""Whether the in-code hyprctl dispatches that change window state """Whether the in-code hyprctl dispatches that change window state
@ -44,7 +56,15 @@ def popout_aspect_lock_enabled() -> bool:
def data_dir() -> Path: def data_dir() -> Path:
"""Return the platform-appropriate data/cache directory.""" """Return the platform-appropriate data/cache directory.
On POSIX, the directory is chmod'd to 0o700 after creation so the
SQLite DB inside (and the api_key/api_user columns it stores) are
not exposed to other local users on shared workstations or
networked home dirs with permissive umasks. On Windows the chmod
is a no-op NTFS ACLs handle access control separately and the
OS already restricts AppData\\Roaming\\<app> to the owning user.
"""
if IS_WINDOWS: if IS_WINDOWS:
base = Path.home() / "AppData" / "Roaming" base = Path.home() / "AppData" / "Roaming"
else: else:
@ -55,6 +75,13 @@ def data_dir() -> Path:
) )
path = base / APPNAME path = base / APPNAME
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
if not IS_WINDOWS:
try:
os.chmod(path, 0o700)
except OSError:
# Filesystem may not support chmod (e.g. some FUSE mounts).
# Better to keep working than refuse to start.
pass
return path return path
@ -276,6 +303,16 @@ def render_filename_template(template: str, post: "Post", ext: str) -> str:
if len(rendered) > 200: if len(rendered) > 200:
rendered = rendered[:200].rstrip("._ ") rendered = rendered[:200].rstrip("._ ")
# Reject Windows reserved device names (audit finding #7). On Windows,
# opening `con.jpg` or `prn.png` for writing redirects to the device,
# so a tag value of `con` from a hostile booru would silently break
# save. Prefix with `_` to break the device-name match while keeping
# the user's intended name visible.
if rendered:
stem_lower = rendered.split(".", 1)[0].lower()
if stem_lower in _WINDOWS_RESERVED_NAMES:
rendered = "_" + rendered
if not rendered: if not rendered:
return f"{post.id}{ext}" return f"{post.id}{ext}"

View File

@ -11,7 +11,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .config import db_path from .config import IS_WINDOWS, db_path
def _validate_folder_name(name: str) -> str: def _validate_folder_name(name: str) -> str:
@ -152,6 +152,8 @@ _DEFAULTS = {
"library_dir": "", "library_dir": "",
"infinite_scroll": "0", "infinite_scroll": "0",
"library_filename_template": "", "library_filename_template": "",
"unbookmark_on_save": "0",
"search_history_enabled": "1",
} }
@ -183,10 +185,6 @@ class Bookmark:
tag_categories: dict = field(default_factory=dict) tag_categories: dict = field(default_factory=dict)
# Back-compat alias — will be removed in a future version.
Favorite = Bookmark
class Database: class Database:
def __init__(self, path: Path | None = None) -> None: def __init__(self, path: Path | None = None) -> None:
self._path = path or db_path() self._path = path or db_path()
@ -208,8 +206,30 @@ class Database:
self._conn.execute("PRAGMA foreign_keys=ON") self._conn.execute("PRAGMA foreign_keys=ON")
self._conn.executescript(_SCHEMA) self._conn.executescript(_SCHEMA)
self._migrate() self._migrate()
self._restrict_perms()
return self._conn return self._conn
def _restrict_perms(self) -> None:
"""Tighten the DB file (and WAL/SHM sidecars) to 0o600 on POSIX.
The sites table stores api_key + api_user in plaintext, so the
file must not be readable by other local users. Sidecars only
exist after the first WAL checkpoint, so we tolerate
FileNotFoundError. Windows: NTFS ACLs handle this; chmod is a
no-op there. Filesystem-level chmod failures are swallowed
better to keep working than refuse to start.
"""
if IS_WINDOWS:
return
for suffix in ("", "-wal", "-shm"):
target = Path(str(self._path) + suffix) if suffix else self._path
try:
os.chmod(target, 0o600)
except FileNotFoundError:
pass
except OSError:
pass
@contextmanager @contextmanager
def _write(self): def _write(self):
"""Context manager for write methods. """Context manager for write methods.
@ -328,6 +348,9 @@ class Database:
def delete_site(self, site_id: int) -> None: def delete_site(self, site_id: int) -> None:
with self._write(): with self._write():
self.conn.execute("DELETE FROM tag_types WHERE site_id = ?", (site_id,))
self.conn.execute("DELETE FROM search_history WHERE site_id = ?", (site_id,))
self.conn.execute("DELETE FROM saved_searches WHERE site_id = ?", (site_id,))
self.conn.execute("DELETE FROM favorites WHERE site_id = ?", (site_id,)) self.conn.execute("DELETE FROM favorites WHERE site_id = ?", (site_id,))
self.conn.execute("DELETE FROM sites WHERE id = ?", (site_id,)) self.conn.execute("DELETE FROM sites WHERE id = ?", (site_id,))
@ -740,9 +763,14 @@ class Database:
def search_library_meta(self, query: str) -> set[int]: def search_library_meta(self, query: str) -> set[int]:
"""Search library metadata by tags. Returns matching post IDs.""" """Search library metadata by tags. Returns matching post IDs."""
escaped = (
query.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
rows = self.conn.execute( rows = self.conn.execute(
"SELECT post_id FROM library_meta WHERE tags LIKE ?", "SELECT post_id FROM library_meta WHERE tags LIKE ? ESCAPE '\\'",
(f"%{query}%",), (f"%{escaped}%",),
).fetchall() ).fetchall()
return {r["post_id"] for r in rows} return {r["post_id"] for r in rows}

73
booru_viewer/core/http.py Normal file
View File

@ -0,0 +1,73 @@
"""Shared httpx.AsyncClient constructor.
Three call sites build near-identical clients: the cache module's
download pool, ``BooruClient``'s shared API pool, and
``detect.detect_site_type``'s reach into that same pool. Centralising
the construction in one place means a future change (new SSRF hook,
new connection limit, different default UA) doesn't have to be made
three times and kept in sync.
The module does NOT manage the singletons themselves each call site
keeps its own ``_shared_client`` and its own lock, so the cache
pool's long-lived large transfers don't compete with short JSON
requests from the API layer. ``make_client`` is a pure constructor.
"""
from __future__ import annotations
from typing import Callable, Iterable
import httpx
from .config import USER_AGENT
from .api._safety import validate_public_request
# Connection pool limits are identical across all three call sites.
# Keeping the default here centralises any future tuning.
_DEFAULT_LIMITS = httpx.Limits(max_connections=10, max_keepalive_connections=5)
def make_client(
*,
timeout: float = 20.0,
accept: str | None = None,
extra_request_hooks: Iterable[Callable] | None = None,
) -> httpx.AsyncClient:
"""Return a fresh ``httpx.AsyncClient`` with the project's defaults.
Defaults applied unconditionally:
- ``User-Agent`` header from ``core.config.USER_AGENT``
- ``follow_redirects=True``
- ``validate_public_request`` SSRF hook (always first on the
request-hook chain; extras run after it)
- Connection limits: 10 max, 5 keepalive
Parameters:
timeout: per-request timeout in seconds. Cache downloads pass
60s for large videos; the API pool uses 20s.
accept: optional ``Accept`` header value. The cache pool sets
``image/*,video/*,*/*``; the API pool leaves it unset so
httpx's ``*/*`` default takes effect.
extra_request_hooks: optional extra callables to run after
``validate_public_request``. The API clients pass their
connection-logging hook here; detect passes the same.
Call sites are responsible for their own singleton caching
``make_client`` always returns a fresh instance.
"""
headers: dict[str, str] = {"User-Agent": USER_AGENT}
if accept is not None:
headers["Accept"] = accept
hooks: list[Callable] = [validate_public_request]
if extra_request_hooks:
hooks.extend(extra_request_hooks)
return httpx.AsyncClient(
headers=headers,
follow_redirects=True,
timeout=timeout,
event_hooks={"request": hooks},
limits=_DEFAULT_LIMITS,
)

View File

@ -1,31 +0,0 @@
"""Image thumbnailing and format helpers."""
from __future__ import annotations
from pathlib import Path
from PIL import Image
from .config import DEFAULT_THUMBNAIL_SIZE, thumbnails_dir
def make_thumbnail(
source: Path,
size: tuple[int, int] = DEFAULT_THUMBNAIL_SIZE,
dest: Path | None = None,
) -> Path:
"""Create a thumbnail, returning its path. Returns existing if already made."""
dest = dest or thumbnails_dir() / f"thumb_{source.stem}_{size[0]}x{size[1]}.jpg"
if dest.exists():
return dest
with Image.open(source) as img:
img.thumbnail(size, Image.Resampling.LANCZOS)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(dest, "JPEG", quality=85)
return dest
def image_dimensions(path: Path) -> tuple[int, int]:
with Image.open(path) as img:
return img.size

View File

@ -24,6 +24,7 @@ from .db import Database
if TYPE_CHECKING: if TYPE_CHECKING:
from .api.base import Post from .api.base import Post
from .api.category_fetcher import CategoryFetcher
_CATEGORY_TOKENS = {"%artist%", "%character%", "%copyright%", "%general%", "%meta%", "%species%"} _CATEGORY_TOKENS = {"%artist%", "%character%", "%copyright%", "%general%", "%meta%", "%species%"}
@ -36,7 +37,8 @@ async def save_post_file(
db: Database, db: Database,
in_flight: set[str] | None = None, in_flight: set[str] | None = None,
explicit_name: str | None = None, explicit_name: str | None = None,
category_fetcher=None, *,
category_fetcher: "CategoryFetcher | None",
) -> Path: ) -> Path:
"""Copy a Post's already-cached media file into `dest_dir`. """Copy a Post's already-cached media file into `dest_dir`.
@ -89,6 +91,13 @@ async def save_post_file(
explicit_name: optional override. When set, the template is explicit_name: optional override. When set, the template is
bypassed and this basename (already including extension) bypassed and this basename (already including extension)
is used as the starting point for collision resolution. is used as the starting point for collision resolution.
category_fetcher: keyword-only, required. The CategoryFetcher
for the post's site, or None when the site categorises tags
inline (Danbooru, e621) so ``post.tag_categories`` is always
pre-populated. Pass ``None`` explicitly rather than omitting
the argument the ``=None`` default was removed so saves
can't silently render templates with empty category tokens
just because a caller forgot to plumb the fetcher through.
Returns: Returns:
The actual `Path` the file landed at after collision The actual `Path` the file landed at after collision

View File

@ -0,0 +1,34 @@
"""Pure helper for the info-panel Source line.
Lives in its own module so the helper can be unit-tested from CI
without pulling in PySide6. ``info_panel.py`` imports it.
"""
from __future__ import annotations
from html import escape
def build_source_html(source: str | None) -> str:
"""Build the rich-text fragment for the Source line in the info panel.
The fragment is inserted into a QLabel set to RichText format with
setOpenExternalLinks(True) that means QTextBrowser parses any HTML
in *source* as markup. Without escaping, a hostile booru can break
out of the href attribute, inject ``<img>`` tracking pixels, or make
the visible text disagree with the click target.
The href is only emitted for an http(s) URL; everything else is
rendered as escaped plain text. Both the href value and the visible
display text are HTML-escaped (audit finding #6).
"""
if not source:
return "none"
# Truncate display text but keep the full URL for the link target.
display = source if len(source) <= 60 else source[:57] + "..."
if source.startswith(("http://", "https://")):
return (
f'<a href="{escape(source, quote=True)}" '
f'style="color: #4fc3f7;">{escape(display)}</a>'
)
return escape(display)

View File

@ -119,6 +119,8 @@ QWidget#_slideshow_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover { QWidget#_slideshow_controls QPushButton:hover {
@ -146,6 +148,15 @@ QWidget#_slideshow_controls QLabel {
background: transparent; background: transparent;
color: white; color: white;
} }
/* Hide the standard icon column on every QMessageBox (question mark,
* warning triangle, info circle) so confirm dialogs are text-only. */
QMessageBox QLabel#qt_msgboxex_icon_label {
image: none;
max-width: 0px;
max-height: 0px;
margin: 0px;
padding: 0px;
}
""" """
@ -295,9 +306,37 @@ def run() -> None:
except Exception as e: except Exception as e:
log.warning(f"Operation failed: {e}") log.warning(f"Operation failed: {e}")
else: else:
# No custom.qss — still install the popout overlay defaults so the # No custom.qss — force Fusion widgets so distro pyside6 builds linked
# floating toolbar/controls have a sane background instead of bare # against system Qt don't pick up Breeze (or whatever the platform
# letterbox color. # theme plugin supplies) and diverge from the bundled-Qt look that
# source-from-pip users get.
app.setStyle("Fusion")
# If no system theme is detected, apply a dark Fusion palette so
# fresh installs don't land on blinding white. KDE/GNOME users
# keep their palette (dark or light) — we only intervene when
# Qt is running on its built-in defaults with no Trolltech.conf.
from PySide6.QtGui import QPalette, QColor
pal = app.palette()
_has_system_theme = Path("~/.config/Trolltech.conf").expanduser().exists()
if not _has_system_theme and pal.color(QPalette.ColorRole.Window).lightness() > 128:
dark = QPalette()
dark.setColor(QPalette.ColorRole.Window, QColor("#2b2b2b"))
dark.setColor(QPalette.ColorRole.WindowText, QColor("#d4d4d4"))
dark.setColor(QPalette.ColorRole.Base, QColor("#232323"))
dark.setColor(QPalette.ColorRole.AlternateBase, QColor("#2b2b2b"))
dark.setColor(QPalette.ColorRole.Text, QColor("#d4d4d4"))
dark.setColor(QPalette.ColorRole.Button, QColor("#353535"))
dark.setColor(QPalette.ColorRole.ButtonText, QColor("#d4d4d4"))
dark.setColor(QPalette.ColorRole.BrightText, QColor("#ff4444"))
dark.setColor(QPalette.ColorRole.Highlight, QColor("#3daee9"))
dark.setColor(QPalette.ColorRole.HighlightedText, QColor("#1e1e1e"))
dark.setColor(QPalette.ColorRole.ToolTipBase, QColor("#353535"))
dark.setColor(QPalette.ColorRole.ToolTipText, QColor("#d4d4d4"))
dark.setColor(QPalette.ColorRole.PlaceholderText, QColor("#7a7a7a"))
dark.setColor(QPalette.ColorRole.Link, QColor("#3daee9"))
app.setPalette(dark)
# Install the popout overlay defaults so the floating toolbar/controls
# have a sane background instead of bare letterbox color.
app.setStyleSheet(_BASE_POPOUT_OVERLAY_QSS) app.setStyleSheet(_BASE_POPOUT_OVERLAY_QSS)
# Set app icon (works in taskbar on all platforms) # Set app icon (works in taskbar on all platforms)

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Callable, TYPE_CHECKING
from PySide6.QtCore import Qt, Signal, QObject, QTimer from PySide6.QtCore import Qt, Signal, QObject, QTimer
from PySide6.QtGui import QPixmap from PySide6.QtGui import QPixmap
@ -27,11 +28,15 @@ from ..core.cache import download_thumbnail
from ..core.concurrency import run_on_app_loop from ..core.concurrency import run_on_app_loop
from .grid import ThumbnailGrid from .grid import ThumbnailGrid
if TYPE_CHECKING:
from ..core.api.category_fetcher import CategoryFetcher
log = logging.getLogger("booru") log = logging.getLogger("booru")
class BookmarkThumbSignals(QObject): class BookmarkThumbSignals(QObject):
thumb_ready = Signal(int, str) thumb_ready = Signal(int, str)
save_done = Signal(int) # post_id
class BookmarksView(QWidget): class BookmarksView(QWidget):
@ -42,12 +47,23 @@ class BookmarksView(QWidget):
bookmarks_changed = Signal() # emitted after bookmark add/remove/unsave bookmarks_changed = Signal() # emitted after bookmark add/remove/unsave
open_in_browser_requested = Signal(int, int) # (site_id, post_id) open_in_browser_requested = Signal(int, int) # (site_id, post_id)
def __init__(self, db: Database, parent: QWidget | None = None) -> None: def __init__(
self,
db: Database,
category_fetcher_factory: Callable[[], "CategoryFetcher | None"],
parent: QWidget | None = None,
) -> None:
super().__init__(parent) super().__init__(parent)
self._db = db self._db = db
# Factory returns the fetcher for the currently-active site, or
# None when the site categorises tags inline (Danbooru, e621).
# Called at save time so a site switch between BookmarksView
# construction and a save picks up the new site's fetcher.
self._category_fetcher_factory = category_fetcher_factory
self._bookmarks: list[Bookmark] = [] self._bookmarks: list[Bookmark] = []
self._signals = BookmarkThumbSignals() self._signals = BookmarkThumbSignals()
self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection) self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection)
self._signals.save_done.connect(self._on_save_done, Qt.ConnectionType.QueuedConnection)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@ -213,7 +229,7 @@ class BookmarksView(QWidget):
elif fav.cached_path and Path(fav.cached_path).exists(): elif fav.cached_path and Path(fav.cached_path).exists():
pix = QPixmap(fav.cached_path) pix = QPixmap(fav.cached_path)
if not pix.isNull(): if not pix.isNull():
thumb.set_pixmap(pix) thumb.set_pixmap(pix, fav.cached_path)
def _load_thumb_async(self, index: int, url: str) -> None: def _load_thumb_async(self, index: int, url: str) -> None:
# Schedule the download on the persistent event loop instead of # Schedule the download on the persistent event loop instead of
@ -234,7 +250,14 @@ class BookmarksView(QWidget):
if 0 <= index < len(thumbs): if 0 <= index < len(thumbs):
pix = QPixmap(path) pix = QPixmap(path)
if not pix.isNull(): if not pix.isNull():
thumbs[index].set_pixmap(pix) thumbs[index].set_pixmap(pix, path)
def _on_save_done(self, post_id: int) -> None:
"""Light the saved-locally dot on the thumbnail for post_id."""
for i, fav in enumerate(self._bookmarks):
if fav.post_id == post_id and i < len(self._grid._thumbs):
self._grid._thumbs[i].set_saved_locally(True)
break
def _do_search(self) -> None: def _do_search(self) -> None:
text = self._search_input.text().strip() text = self._search_input.text().strip()
@ -287,9 +310,15 @@ class BookmarksView(QWidget):
src = Path(fav.cached_path) src = Path(fav.cached_path)
post = self._bookmark_to_post(fav) post = self._bookmark_to_post(fav)
fetcher = self._category_fetcher_factory()
async def _do(): async def _do():
try: try:
await save_post_file(src, post, dest_dir, self._db) await save_post_file(
src, post, dest_dir, self._db,
category_fetcher=fetcher,
)
self._signals.save_done.emit(fav.post_id)
except Exception as e: except Exception as e:
log.warning(f"Bookmark→library save #{fav.post_id} failed: {e}") log.warning(f"Bookmark→library save #{fav.post_id} failed: {e}")
@ -329,25 +358,25 @@ class BookmarksView(QWidget):
menu.addSeparator() menu.addSeparator()
save_as = menu.addAction("Save As...") save_as = menu.addAction("Save As...")
# Save to Library submenu — folders come from the library # Save to Library / Unsave — mutually exclusive based on
# filesystem, not the bookmark folder DB. # whether the post is already in the library.
from ..core.config import library_folders from ..core.config import library_folders
save_lib_menu = None
save_lib_unsorted = None
save_lib_new = None
save_lib_folders = {}
unsave_lib = None
if self._db.is_post_in_library(fav.post_id):
unsave_lib = menu.addAction("Unsave from Library")
else:
save_lib_menu = menu.addMenu("Save to Library") save_lib_menu = menu.addMenu("Save to Library")
save_lib_unsorted = save_lib_menu.addAction("Unfiled") save_lib_unsorted = save_lib_menu.addAction("Unfiled")
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_folders = {}
for folder in library_folders(): for folder in library_folders():
a = save_lib_menu.addAction(folder) a = save_lib_menu.addAction(folder)
save_lib_folders[id(a)] = folder save_lib_folders[id(a)] = folder
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...") save_lib_new = save_lib_menu.addAction("+ New Folder...")
unsave_lib = None
# Only show unsave if the post is actually saved. is_post_in_library
# is the format-agnostic DB check — works for digit-stem and
# templated filenames alike.
if self._db.is_post_in_library(fav.post_id):
unsave_lib = menu.addAction("Unsave from Library")
copy_file = menu.addAction("Copy File to Clipboard") copy_file = menu.addAction("Copy File to Clipboard")
copy_url = menu.addAction("Copy Image URL") copy_url = menu.addAction("Copy Image URL")
copy_tags = menu.addAction("Copy Tags") copy_tags = menu.addAction("Copy Tags")
@ -373,13 +402,9 @@ class BookmarksView(QWidget):
if action == save_lib_unsorted: if action == save_lib_unsorted:
self._copy_to_library_unsorted(fav) self._copy_to_library_unsorted(fav)
self.refresh()
elif action == save_lib_new: elif action == save_lib_new:
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip(): if ok and name.strip():
# Validate the name via saved_folder_dir() which mkdir's
# the library subdir and runs the path-traversal check.
# No DB folder write — bookmark folders are independent.
try: try:
from ..core.config import saved_folder_dir from ..core.config import saved_folder_dir
saved_folder_dir(name.strip()) saved_folder_dir(name.strip())
@ -387,11 +412,9 @@ class BookmarksView(QWidget):
QMessageBox.warning(self, "Invalid Folder Name", str(e)) QMessageBox.warning(self, "Invalid Folder Name", str(e))
return return
self._copy_to_library(fav, name.strip()) self._copy_to_library(fav, name.strip())
self.refresh()
elif id(action) in save_lib_folders: elif id(action) in save_lib_folders:
folder_name = save_lib_folders[id(action)] folder_name = save_lib_folders[id(action)]
self._copy_to_library(fav, folder_name) self._copy_to_library(fav, folder_name)
self.refresh()
elif action == open_browser: elif action == open_browser:
self.open_in_browser_requested.emit(fav.site_id, fav.post_id) self.open_in_browser_requested.emit(fav.site_id, fav.post_id)
elif action == open_default: elif action == open_default:
@ -408,12 +431,14 @@ class BookmarksView(QWidget):
dest = save_file(self, "Save Image", default_name, f"Images (*{src.suffix})") dest = save_file(self, "Save Image", default_name, f"Images (*{src.suffix})")
if dest: if dest:
dest_path = Path(dest) dest_path = Path(dest)
fetcher = self._category_fetcher_factory()
async def _do_save_as(): async def _do_save_as():
try: try:
await save_post_file( await save_post_file(
src, post, dest_path.parent, self._db, src, post, dest_path.parent, self._db,
explicit_name=dest_path.name, explicit_name=dest_path.name,
category_fetcher=fetcher,
) )
except Exception as e: except Exception as e:
log.warning(f"Bookmark Save As #{fav.post_id} failed: {e}") log.warning(f"Bookmark Save As #{fav.post_id} failed: {e}")
@ -421,12 +446,11 @@ class BookmarksView(QWidget):
run_on_app_loop(_do_save_as()) run_on_app_loop(_do_save_as())
elif action == unsave_lib: elif action == unsave_lib:
from ..core.cache import delete_from_library from ..core.cache import delete_from_library
# Pass db so templated filenames are matched and the meta
# row gets cleaned up. Refresh on success OR on a meta-only
# cleanup (orphan row, no on-disk file) — either way the
# saved-dot indicator state has changed.
delete_from_library(fav.post_id, db=self._db) delete_from_library(fav.post_id, db=self._db)
self.refresh() for i, f in enumerate(self._bookmarks):
if f.post_id == fav.post_id and i < len(self._grid._thumbs):
self._grid._thumbs[i].set_saved_locally(False)
break
self.bookmarks_changed.emit() self.bookmarks_changed.emit()
elif action == copy_file: elif action == copy_file:
path = fav.cached_path path = fav.cached_path
@ -477,20 +501,24 @@ class BookmarksView(QWidget):
menu = QMenu(self) menu = QMenu(self)
# Save All to Library submenu — folders are filesystem-truth. any_unsaved = any(not self._db.is_post_in_library(f.post_id) for f in favs)
# Conversion from a flat action to a submenu so the user can any_saved = any(self._db.is_post_in_library(f.post_id) for f in favs)
# pick a destination instead of having "save all" silently use
# each bookmark's fav.folder (which was the cross-bleed bug). save_lib_menu = None
save_lib_unsorted = None
save_lib_new = None
save_lib_folder_actions: dict[int, str] = {}
unsave_all = None
if any_unsaved:
save_lib_menu = menu.addMenu(f"Save All ({len(favs)}) to Library") save_lib_menu = menu.addMenu(f"Save All ({len(favs)}) to Library")
save_lib_unsorted = save_lib_menu.addAction("Unfiled") save_lib_unsorted = save_lib_menu.addAction("Unfiled")
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_folder_actions: dict[int, str] = {}
for folder in library_folders(): for folder in library_folders():
a = save_lib_menu.addAction(folder) a = save_lib_menu.addAction(folder)
save_lib_folder_actions[id(a)] = folder save_lib_folder_actions[id(a)] = folder
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...") save_lib_new = save_lib_menu.addAction("+ New Folder...")
if any_saved:
unsave_all = menu.addAction(f"Unsave All ({len(favs)}) from Library") unsave_all = menu.addAction(f"Unsave All ({len(favs)}) from Library")
menu.addSeparator() menu.addSeparator()
@ -516,7 +544,6 @@ class BookmarksView(QWidget):
self._copy_to_library(fav, folder_name) self._copy_to_library(fav, folder_name)
else: else:
self._copy_to_library_unsorted(fav) self._copy_to_library_unsorted(fav)
self.refresh()
if action == save_lib_unsorted: if action == save_lib_unsorted:
_save_all_into(None) _save_all_into(None)
@ -534,9 +561,13 @@ class BookmarksView(QWidget):
_save_all_into(save_lib_folder_actions[id(action)]) _save_all_into(save_lib_folder_actions[id(action)])
elif action == unsave_all: elif action == unsave_all:
from ..core.cache import delete_from_library from ..core.cache import delete_from_library
unsaved_ids = set()
for fav in favs: for fav in favs:
delete_from_library(fav.post_id, db=self._db) delete_from_library(fav.post_id, db=self._db)
self.refresh() unsaved_ids.add(fav.post_id)
for i, fav in enumerate(self._bookmarks):
if fav.post_id in unsaved_ids and i < len(self._grid._thumbs):
self._grid._thumbs[i].set_saved_locally(False)
self.bookmarks_changed.emit() self.bookmarks_changed.emit()
elif action == move_none: elif action == move_none:
for fav in favs: for fav in favs:

View File

@ -0,0 +1,248 @@
"""Single-post and multi-select right-click context menus."""
from __future__ import annotations
from typing import TYPE_CHECKING
from PySide6.QtWidgets import QApplication, QMenu
if TYPE_CHECKING:
from .main_window import BooruApp
class ContextMenuHandler:
"""Builds and dispatches context menus for the thumbnail grid."""
def __init__(self, app: BooruApp) -> None:
self._app = app
@staticmethod
def _is_child_of_menu(action, menu) -> bool:
parent = action.parent()
while parent:
if parent == menu:
return True
parent = getattr(parent, 'parent', lambda: None)()
return False
def show_single(self, index: int, pos) -> None:
if index < 0 or index >= len(self._app._posts):
return
post = self._app._posts[index]
menu = QMenu(self._app)
open_browser = menu.addAction("Open in Browser")
open_default = menu.addAction("Open in Default App")
menu.addSeparator()
save_as = menu.addAction("Save As...")
from ..core.config import library_folders
save_lib_menu = None
save_lib_unsorted = None
save_lib_new = None
save_lib_folders = {}
unsave_lib = None
if self._app._post_actions.is_post_saved(post.id):
unsave_lib = menu.addAction("Unsave from Library")
else:
save_lib_menu = menu.addMenu("Save to Library")
save_lib_unsorted = save_lib_menu.addAction("Unfiled")
save_lib_menu.addSeparator()
for folder in library_folders():
a = save_lib_menu.addAction(folder)
save_lib_folders[id(a)] = folder
save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...")
copy_clipboard = menu.addAction("Copy File to Clipboard")
copy_url = menu.addAction("Copy Image URL")
copy_tags = menu.addAction("Copy Tags")
menu.addSeparator()
fav_action = None
bm_folder_actions: dict[int, str] = {}
bm_unfiled = None
bm_new = None
if self._app._post_actions.is_current_bookmarked(index):
fav_action = menu.addAction("Remove Bookmark")
else:
fav_menu = menu.addMenu("Bookmark as")
bm_unfiled = fav_menu.addAction("Unfiled")
fav_menu.addSeparator()
for folder in self._app._db.get_folders():
a = fav_menu.addAction(folder)
bm_folder_actions[id(a)] = folder
fav_menu.addSeparator()
bm_new = fav_menu.addAction("+ New Folder...")
menu.addSeparator()
bl_menu = menu.addMenu("Blacklist Tag")
if post.tag_categories:
for category, tags in post.tag_categories.items():
cat_menu = bl_menu.addMenu(category)
for tag in tags[:30]:
cat_menu.addAction(tag)
else:
for tag in post.tag_list[:30]:
bl_menu.addAction(tag)
bl_post_action = menu.addAction("Blacklist Post")
action = menu.exec(pos)
if not action:
return
if action == open_browser:
self._app._open_in_browser(post)
elif action == open_default:
self._app._open_in_default(post)
elif action == save_as:
self._app._post_actions.save_as(post)
elif action == save_lib_unsorted:
self._app._post_actions.save_to_library(post, None)
elif action == save_lib_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self._app, "New Folder", "Folder name:")
if ok and name.strip():
try:
from ..core.config import saved_folder_dir
saved_folder_dir(name.strip())
except ValueError as e:
QMessageBox.warning(self._app, "Invalid Folder Name", str(e))
return
self._app._post_actions.save_to_library(post, name.strip())
elif id(action) in save_lib_folders:
self._app._post_actions.save_to_library(post, save_lib_folders[id(action)])
elif action == unsave_lib:
self._app._post_actions.unsave_from_preview()
elif action == copy_clipboard:
self._app._copy_file_to_clipboard()
elif action == copy_url:
QApplication.clipboard().setText(post.file_url)
self._app._status.showMessage("URL copied")
elif action == copy_tags:
QApplication.clipboard().setText(post.tags)
self._app._status.showMessage("Tags copied")
elif fav_action is not None and action == fav_action:
self._app._post_actions.toggle_bookmark(index)
elif bm_unfiled is not None and action == bm_unfiled:
self._app._post_actions.toggle_bookmark(index, None)
elif bm_new is not None and action == bm_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self._app, "New Bookmark Folder", "Folder name:")
if ok and name.strip():
try:
self._app._db.add_folder(name.strip())
except ValueError as e:
QMessageBox.warning(self._app, "Invalid Folder Name", str(e))
return
self._app._post_actions.toggle_bookmark(index, name.strip())
elif id(action) in bm_folder_actions:
self._app._post_actions.toggle_bookmark(index, bm_folder_actions[id(action)])
elif self._is_child_of_menu(action, bl_menu):
tag = action.text()
self._app._db.add_blacklisted_tag(tag)
self._app._db.set_setting("blacklist_enabled", "1")
if self._app._preview._current_path and tag in post.tag_list:
from ..core.cache import cached_path_for
cp = str(cached_path_for(post.file_url))
if cp == self._app._preview._current_path:
self._app._preview.clear()
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
self._app._popout_ctrl.window.stop_media()
self._app._status.showMessage(f"Blacklisted: {tag}")
self._app._search_ctrl.remove_blacklisted_from_grid(tag=tag)
elif action == bl_post_action:
self._app._db.add_blacklisted_post(post.file_url)
self._app._search_ctrl.remove_blacklisted_from_grid(post_url=post.file_url)
self._app._status.showMessage(f"Post #{post.id} blacklisted")
self._app._search_ctrl.do_search()
def show_multi(self, indices: list, pos) -> None:
posts = [self._app._posts[i] for i in indices if 0 <= i < len(self._app._posts)]
if not posts:
return
count = len(posts)
site_id = self._app._site_combo.currentData()
any_bookmarked = bool(site_id) and any(self._app._db.is_bookmarked(site_id, p.id) for p in posts)
any_unbookmarked = bool(site_id) and any(not self._app._db.is_bookmarked(site_id, p.id) for p in posts)
any_saved = any(self._app._post_actions.is_post_saved(p.id) for p in posts)
any_unsaved = any(not self._app._post_actions.is_post_saved(p.id) for p in posts)
menu = QMenu(self._app)
save_menu = None
save_unsorted = None
save_new = None
save_folder_actions: dict[int, str] = {}
if any_unsaved:
from ..core.config import library_folders
save_menu = menu.addMenu(f"Save All to Library ({count})")
save_unsorted = save_menu.addAction("Unfiled")
for folder in library_folders():
a = save_menu.addAction(folder)
save_folder_actions[id(a)] = folder
save_menu.addSeparator()
save_new = save_menu.addAction("+ New Folder...")
unsave_lib_all = None
if any_saved:
unsave_lib_all = menu.addAction(f"Unsave All from Library ({count})")
if (any_unsaved or any_saved) and (any_unbookmarked or any_bookmarked):
menu.addSeparator()
fav_all = None
if any_unbookmarked:
fav_all = menu.addAction(f"Bookmark All ({count})")
unfav_all = None
if any_bookmarked:
unfav_all = menu.addAction(f"Remove All Bookmarks ({count})")
if any_unsaved or any_saved or any_unbookmarked or any_bookmarked:
menu.addSeparator()
batch_dl = menu.addAction(f"Download All ({count})...")
copy_urls = menu.addAction("Copy All URLs")
action = menu.exec(pos)
if not action:
return
if fav_all is not None and action == fav_all:
self._app._post_actions.bulk_bookmark(indices, posts)
elif save_unsorted is not None and action == save_unsorted:
self._app._post_actions.bulk_save(indices, posts, None)
elif save_new is not None and action == save_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self._app, "New Folder", "Folder name:")
if ok and name.strip():
try:
from ..core.config import saved_folder_dir
saved_folder_dir(name.strip())
except ValueError as e:
QMessageBox.warning(self._app, "Invalid Folder Name", str(e))
return
self._app._post_actions.bulk_save(indices, posts, name.strip())
elif id(action) in save_folder_actions:
self._app._post_actions.bulk_save(indices, posts, save_folder_actions[id(action)])
elif unsave_lib_all is not None and action == unsave_lib_all:
self._app._post_actions.bulk_unsave(indices, posts)
elif action == batch_dl:
from .dialogs import select_directory
dest = select_directory(self._app, "Download to folder")
if dest:
self._app._post_actions.batch_download_posts(posts, dest)
elif unfav_all is not None and action == unfav_all:
if site_id:
for post in posts:
self._app._db.remove_bookmark(site_id, post.id)
for idx in indices:
if 0 <= idx < len(self._app._grid._thumbs):
self._app._grid._thumbs[idx].set_bookmarked(False)
self._app._grid._clear_multi()
self._app._status.showMessage(f"Removed {count} bookmarks")
if self._app._stack.currentIndex() == 1:
self._app._bookmarks_view.refresh()
elif action == copy_urls:
urls = "\n".join(p.file_url for p in posts)
QApplication.clipboard().setText(urls)
self._app._status.showMessage(f"Copied {count} URLs")

View File

@ -3,25 +3,35 @@
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import sys
from pathlib import Path
from PySide6.QtWidgets import QFileDialog, QWidget from PySide6.QtWidgets import QFileDialog, QWidget
from ..core.config import IS_WINDOWS from ..core.config import IS_WINDOWS
_gtk_cached: bool | None = None
def _use_gtk() -> bool: def _use_gtk() -> bool:
global _gtk_cached
if IS_WINDOWS: if IS_WINDOWS:
return False return False
if _gtk_cached is not None:
return _gtk_cached
try: try:
from ..core.db import Database from ..core.db import Database
db = Database() db = Database()
val = db.get_setting("file_dialog_platform") val = db.get_setting("file_dialog_platform")
db.close() db.close()
return val == "gtk" _gtk_cached = val == "gtk"
except Exception: except Exception:
return False _gtk_cached = False
return _gtk_cached
def reset_gtk_cache() -> None:
"""Called after settings change so the next dialog picks up the new value."""
global _gtk_cached
_gtk_cached = None
def save_file( def save_file(

View File

@ -2,20 +2,18 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path import logging
from PySide6.QtCore import Qt, Signal, QSize, QRect, QRectF, QMimeData, QUrl, QPoint, Property log = logging.getLogger("booru")
from PySide6.QtGui import QPixmap, QPainter, QPainterPath, QColor, QPen, QKeyEvent, QWheelEvent, QDrag, QMouseEvent
from PySide6.QtCore import Qt, Signal, QSize, QRect, QRectF, QMimeData, QUrl, QPoint, Property, QPropertyAnimation, QEasingCurve
from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QKeyEvent, QWheelEvent, QDrag, QMouseEvent
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QWidget,
QScrollArea, QScrollArea,
QMenu,
QApplication,
QRubberBand, QRubberBand,
) )
from ..core.api.base import Post
THUMB_SIZE = 180 THUMB_SIZE = 180
THUMB_SPACING = 2 THUMB_SPACING = 2
BORDER_WIDTH = 2 BORDER_WIDTH = 2
@ -65,10 +63,18 @@ class ThumbnailWidget(QWidget):
def _set_idle_color(self, c): self._idle_color = QColor(c) if isinstance(c, str) else c def _set_idle_color(self, c): self._idle_color = QColor(c) if isinstance(c, str) else c
idleColor = Property(QColor, _get_idle_color, _set_idle_color) idleColor = Property(QColor, _get_idle_color, _set_idle_color)
# Thumbnail fade-in opacity (0.0 → 1.0 on pixmap arrival)
def _get_thumb_opacity(self): return self._thumb_opacity
def _set_thumb_opacity(self, v):
self._thumb_opacity = v
self.update()
thumbOpacity = Property(float, _get_thumb_opacity, _set_thumb_opacity)
def __init__(self, index: int, parent: QWidget | None = None) -> None: def __init__(self, index: int, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self.index = index self.index = index
self._pixmap: QPixmap | None = None self._pixmap: QPixmap | None = None
self._source_path: str | None = None # on-disk path, for re-scaling on size change
self._selected = False self._selected = False
self._multi_selected = False self._multi_selected = False
self._bookmarked = False self._bookmarked = False
@ -77,6 +83,7 @@ class ThumbnailWidget(QWidget):
self._drag_start: QPoint | None = None self._drag_start: QPoint | None = None
self._cached_path: str | None = None self._cached_path: str | None = None
self._prefetch_progress: float = -1 # -1 = not prefetching, 0-1 = progress self._prefetch_progress: float = -1 # -1 = not prefetching, 0-1 = progress
self._thumb_opacity: float = 0.0
# Seed selection colors from the palette so non-themed environments # Seed selection colors from the palette so non-themed environments
# (no custom.qss) automatically use the system highlight color. # (no custom.qss) automatically use the system highlight color.
# The qproperty setters above override these later when the QSS is # The qproperty setters above override these later when the QSS is
@ -88,16 +95,31 @@ class ThumbnailWidget(QWidget):
self._hover_color = self._selection_color.lighter(150) self._hover_color = self._selection_color.lighter(150)
self._idle_color = pal.color(QPalette.ColorRole.Mid) self._idle_color = pal.color(QPalette.ColorRole.Mid)
self.setFixedSize(THUMB_SIZE, THUMB_SIZE) self.setFixedSize(THUMB_SIZE, THUMB_SIZE)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setMouseTracking(True) self.setMouseTracking(True)
def set_pixmap(self, pixmap: QPixmap) -> None: def set_pixmap(self, pixmap: QPixmap, path: str | None = None) -> None:
if path is not None:
self._source_path = path
self._pixmap = pixmap.scaled( self._pixmap = pixmap.scaled(
THUMB_SIZE - 4, THUMB_SIZE - 4, THUMB_SIZE - 4, THUMB_SIZE - 4,
Qt.AspectRatioMode.KeepAspectRatio, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation, Qt.TransformationMode.SmoothTransformation,
) )
self.update() self._thumb_opacity = 0.0
anim = QPropertyAnimation(self, b"thumbOpacity")
anim.setDuration(80)
anim.setStartValue(0.0)
anim.setEndValue(1.0)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
anim.finished.connect(lambda: self._on_fade_done(anim))
self._fade_anim = anim
anim.start()
def _on_fade_done(self, anim: QPropertyAnimation) -> None:
"""Clear the reference then schedule deletion."""
if self._fade_anim is anim:
self._fade_anim = None
anim.deleteLater()
def set_selected(self, selected: bool) -> None: def set_selected(self, selected: bool) -> None:
self._selected = selected self._selected = selected
@ -130,7 +152,6 @@ class ThumbnailWidget(QWidget):
# Defaults were seeded from the palette in __init__. # Defaults were seeded from the palette in __init__.
highlight = self._selection_color highlight = self._selection_color
base = pal.color(pal.ColorRole.Base) base = pal.color(pal.ColorRole.Base)
mid = self._idle_color
window = pal.color(pal.ColorRole.Window) window = pal.color(pal.ColorRole.Window)
# Fill entire cell with window color # Fill entire cell with window color
@ -182,7 +203,11 @@ class ThumbnailWidget(QWidget):
if self._pixmap: if self._pixmap:
x = (self.width() - self._pixmap.width()) // 2 x = (self.width() - self._pixmap.width()) // 2
y = (self.height() - self._pixmap.height()) // 2 y = (self.height() - self._pixmap.height()) // 2
if self._thumb_opacity < 1.0:
p.setOpacity(self._thumb_opacity)
p.drawPixmap(x, y, self._pixmap) p.drawPixmap(x, y, self._pixmap)
if self._thumb_opacity < 1.0:
p.setOpacity(1.0)
# Border drawn AFTER the pixmap. Plain rectangle (no rounding) so # Border drawn AFTER the pixmap. Plain rectangle (no rounding) so
# it lines up exactly with the pixmap's square edges — no corner # it lines up exactly with the pixmap's square edges — no corner
@ -252,24 +277,32 @@ class ThumbnailWidget(QWidget):
p.end() p.end()
def enterEvent(self, event) -> None:
self._hover = True
self.update()
def leaveEvent(self, event) -> None: def leaveEvent(self, event) -> None:
if self._hover:
self._hover = False self._hover = False
self.setCursor(Qt.CursorShape.ArrowCursor)
self.update() self.update()
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self._drag_start = event.position().toPoint()
self.clicked.emit(self.index, event)
elif event.button() == Qt.MouseButton.RightButton:
self.right_clicked.emit(self.index, event.globalPosition().toPoint())
def mouseMoveEvent(self, event) -> None: def mouseMoveEvent(self, event) -> None:
# If the grid has a pending or active rubber band, forward the move
grid = self._grid()
if grid and (grid._rb_origin or grid._rb_pending_origin):
vp_pos = self.mapTo(grid.viewport(), event.position().toPoint())
if grid._rb_origin:
grid._rb_drag(vp_pos)
return
if grid._maybe_start_rb(vp_pos):
grid._rb_drag(vp_pos)
return
return
# Update hover and cursor based on whether cursor is over the pixmap
over = self._hit_pixmap(event.position().toPoint()) if self._pixmap else False
if over != self._hover:
self._hover = over
self.setCursor(Qt.CursorShape.PointingHandCursor if over else Qt.CursorShape.ArrowCursor)
self.update()
if (self._drag_start and self._cached_path if (self._drag_start and self._cached_path
and (event.position().toPoint() - self._drag_start).manhattanLength() > 10): and (event.position().toPoint() - self._drag_start).manhattanLength() > 30):
drag = QDrag(self) drag = QDrag(self)
mime = QMimeData() mime = QMimeData()
mime.setUrls([QUrl.fromLocalFile(self._cached_path)]) mime.setUrls([QUrl.fromLocalFile(self._cached_path)])
@ -278,15 +311,65 @@ class ThumbnailWidget(QWidget):
drag.setPixmap(self._pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)) drag.setPixmap(self._pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio))
drag.exec(Qt.DropAction.CopyAction) drag.exec(Qt.DropAction.CopyAction)
self._drag_start = None self._drag_start = None
self.setCursor(Qt.CursorShape.ArrowCursor)
return return
super().mouseMoveEvent(event)
def _hit_pixmap(self, pos) -> bool:
"""True if pos is within the drawn pixmap area."""
if not self._pixmap:
return False
px = (self.width() - self._pixmap.width()) // 2
py = (self.height() - self._pixmap.height()) // 2
return QRect(px, py, self._pixmap.width(), self._pixmap.height()).contains(pos)
def _grid(self):
"""Walk up to the ThumbnailGrid ancestor."""
w = self.parentWidget()
while w:
if isinstance(w, ThumbnailGrid):
return w
w = w.parentWidget()
return None
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
pos = event.position().toPoint()
if not self._hit_pixmap(pos):
grid = self._grid()
if grid:
grid.on_padding_click(self, pos)
event.accept()
return
# Pixmap click — clear any stale rubber band state from a
# previous interrupted drag before starting a new interaction.
grid = self._grid()
if grid:
grid._clear_stale_rubber_band()
self._drag_start = pos
self.clicked.emit(self.index, event)
elif event.button() == Qt.MouseButton.RightButton:
self.right_clicked.emit(self.index, event.globalPosition().toPoint())
def mouseReleaseEvent(self, event) -> None: def mouseReleaseEvent(self, event) -> None:
self._drag_start = None self._drag_start = None
grid = self._grid()
if grid:
if grid._rb_origin:
grid._rb_end()
elif grid._rb_pending_origin is not None:
# Click without drag — treat as deselect
grid._rb_pending_origin = None
grid.clear_selection()
def mouseDoubleClickEvent(self, event) -> None: def mouseDoubleClickEvent(self, event) -> None:
self._drag_start = None self._drag_start = None
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
pos = event.position().toPoint()
if not self._hit_pixmap(pos):
grid = self._grid()
if grid:
grid.on_padding_click(self, pos)
return
self.double_clicked.emit(self.index) self.double_clicked.emit(self.index)
@ -304,6 +387,8 @@ class FlowLayout(QWidget):
def clear(self) -> None: def clear(self) -> None:
for w in self._items: for w in self._items:
if hasattr(w, '_fade_anim') and w._fade_anim is not None:
w._fade_anim.stop()
w.setParent(None) # type: ignore w.setParent(None) # type: ignore
w.deleteLater() w.deleteLater()
self._items.clear() self._items.clear()
@ -409,6 +494,7 @@ class ThumbnailGrid(QScrollArea):
self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom) self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom)
# Rubber band drag selection # Rubber band drag selection
self._rubber_band: QRubberBand | None = None self._rubber_band: QRubberBand | None = None
self._rb_pending_origin: QPoint | None = None # press position, not yet confirmed as drag
self._rb_origin: QPoint | None = None self._rb_origin: QPoint | None = None
@property @property
@ -436,6 +522,7 @@ class ThumbnailGrid(QScrollArea):
thumb.clicked.connect(self._on_thumb_click) thumb.clicked.connect(self._on_thumb_click)
thumb.double_clicked.connect(self._on_thumb_double_click) thumb.double_clicked.connect(self._on_thumb_double_click)
thumb.right_clicked.connect(self._on_thumb_right_click) thumb.right_clicked.connect(self._on_thumb_right_click)
self._flow.add_widget(thumb) self._flow.add_widget(thumb)
self._thumbs.append(thumb) self._thumbs.append(thumb)
@ -450,6 +537,7 @@ class ThumbnailGrid(QScrollArea):
thumb.clicked.connect(self._on_thumb_click) thumb.clicked.connect(self._on_thumb_click)
thumb.double_clicked.connect(self._on_thumb_double_click) thumb.double_clicked.connect(self._on_thumb_double_click)
thumb.right_clicked.connect(self._on_thumb_right_click) thumb.right_clicked.connect(self._on_thumb_right_click)
self._flow.add_widget(thumb) self._flow.add_widget(thumb)
self._thumbs.append(thumb) self._thumbs.append(thumb)
new_thumbs.append(thumb) new_thumbs.append(thumb)
@ -468,6 +556,21 @@ class ThumbnailGrid(QScrollArea):
self._thumbs[self._selected_index].set_selected(False) self._thumbs[self._selected_index].set_selected(False)
self._selected_index = -1 self._selected_index = -1
def _clear_stale_rubber_band(self) -> None:
"""Reset any leftover rubber band state before starting a new interaction.
Rubber band state can get stuck if a drag is interrupted without
a matching release event Wayland focus steal, drag outside the
window, tab switch mid-drag, etc. Every new mouse press calls this
so the next interaction starts from a clean slate instead of
reusing a stale origin (which would make the rubber band "not
work" until the app is restarted).
"""
if self._rubber_band is not None:
self._rubber_band.hide()
self._rb_origin = None
self._rb_pending_origin = None
def _select(self, index: int) -> None: def _select(self, index: int) -> None:
if index < 0 or index >= len(self._thumbs): if index < 0 or index >= len(self._thumbs):
return return
@ -530,42 +633,97 @@ class ThumbnailGrid(QScrollArea):
self.ensureWidgetVisible(self._thumbs[index]) self.ensureWidgetVisible(self._thumbs[index])
self.context_requested.emit(index, pos) self.context_requested.emit(index, pos)
def mousePressEvent(self, event: QMouseEvent) -> None: def _start_rubber_band(self, pos: QPoint) -> None:
if event.button() == Qt.MouseButton.LeftButton: """Start a rubber band selection and deselect."""
# Only start rubber band if click is on empty grid space (not a thumbnail) self._rb_origin = pos
child = self.childAt(event.position().toPoint())
if child is self.widget() or child is self.viewport():
self._rb_origin = event.position().toPoint()
if not self._rubber_band: if not self._rubber_band:
self._rubber_band = QRubberBand(QRubberBand.Shape.Rectangle, self.viewport()) self._rubber_band = QRubberBand(QRubberBand.Shape.Rectangle, self.viewport())
self._rubber_band.setGeometry(QRect(self._rb_origin, QSize())) self._rubber_band.setGeometry(QRect(self._rb_origin, QSize()))
self._rubber_band.show() self._rubber_band.show()
self._clear_multi() self.clear_selection()
def on_padding_click(self, thumb, local_pos) -> None:
"""Called directly by ThumbnailWidget when a click misses the pixmap."""
self._clear_stale_rubber_band()
vp_pos = thumb.mapTo(self.viewport(), local_pos)
self._rb_pending_origin = vp_pos
def mousePressEvent(self, event: QMouseEvent) -> None:
# Clicks on viewport/flow (gaps, space below thumbs) start rubber band
if event.button() == Qt.MouseButton.LeftButton:
self._clear_stale_rubber_band()
child = self.childAt(event.position().toPoint())
if child is self.widget() or child is self.viewport():
self._rb_pending_origin = event.position().toPoint()
return return
super().mousePressEvent(event) super().mousePressEvent(event)
def mouseMoveEvent(self, event: QMouseEvent) -> None: def _rb_drag(self, vp_pos: QPoint) -> None:
if self._rb_origin and self._rubber_band: """Update rubber band geometry and intersected thumb selection."""
rb_rect = QRect(self._rb_origin, event.position().toPoint()).normalized() if not (self._rb_origin and self._rubber_band):
return
rb_rect = QRect(self._rb_origin, vp_pos).normalized()
self._rubber_band.setGeometry(rb_rect) self._rubber_band.setGeometry(rb_rect)
# Select thumbnails that intersect the rubber band # rb_rect is in viewport coords; thumb.geometry() is in widget (content)
# coords. Convert rb_rect to widget coords for the intersection test —
# widget.mapFrom(viewport, (0,0)) gives the widget-coord of viewport's
# origin, which is exactly the translation needed when scrolled.
vp_offset = self.widget().mapFrom(self.viewport(), QPoint(0, 0)) vp_offset = self.widget().mapFrom(self.viewport(), QPoint(0, 0))
rb_widget = rb_rect.translated(vp_offset)
self._clear_multi() self._clear_multi()
for i, thumb in enumerate(self._thumbs): for i, thumb in enumerate(self._thumbs):
thumb_rect = thumb.geometry().translated(vp_offset) if rb_widget.intersects(thumb.geometry()):
if rb_rect.intersects(thumb_rect):
self._multi_selected.add(i) self._multi_selected.add(i)
thumb.set_multi_selected(True) thumb.set_multi_selected(True)
def _rb_end(self) -> None:
"""Hide the rubber band and clear origin."""
if self._rubber_band:
self._rubber_band.hide()
self._rb_origin = None
def _maybe_start_rb(self, vp_pos: QPoint) -> bool:
"""If a rubber band press is pending and we've moved past threshold, start it."""
if self._rb_pending_origin is None:
return False
if (vp_pos - self._rb_pending_origin).manhattanLength() < 30:
return False
self._start_rubber_band(self._rb_pending_origin)
self._rb_pending_origin = None
return True
def mouseMoveEvent(self, event: QMouseEvent) -> None:
pos = event.position().toPoint()
if self._rb_origin and self._rubber_band:
self._rb_drag(pos)
return
if self._maybe_start_rb(pos):
self._rb_drag(pos)
return return
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None: def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if self._rb_origin and self._rubber_band: if self._rb_origin and self._rubber_band:
self._rubber_band.hide() self._rb_end()
self._rb_origin = None
return return
if self._rb_pending_origin is not None:
# Click without drag — treat as deselect
self._rb_pending_origin = None
self.clear_selection()
return
self.unsetCursor()
super().mouseReleaseEvent(event) super().mouseReleaseEvent(event)
def leaveEvent(self, event) -> None:
# Clear stuck hover states — Wayland doesn't always fire
# leaveEvent on individual child widgets when the mouse
# exits the scroll area quickly.
for thumb in self._thumbs:
if thumb._hover:
thumb._hover = False
thumb.update()
super().leaveEvent(event)
def select_all(self) -> None: def select_all(self) -> None:
self._clear_multi() self._clear_multi()
for i in range(len(self._thumbs)): for i in range(len(self._thumbs)):
@ -610,6 +768,8 @@ class ThumbnailGrid(QScrollArea):
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter: elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
if 0 <= idx < len(self._thumbs): if 0 <= idx < len(self._thumbs):
self.post_activated.emit(idx) self.post_activated.emit(idx)
elif key == Qt.Key.Key_Escape:
self.clear_selection()
elif key == Qt.Key.Key_Home: elif key == Qt.Key.Key_Home:
self._select(0) self._select(0)
elif key == Qt.Key.Key_End: elif key == Qt.Key.Key_End:
@ -631,6 +791,58 @@ class ThumbnailGrid(QScrollArea):
self.reached_bottom.emit() self.reached_bottom.emit()
if value <= 0 and sb.maximum() > 0: if value <= 0 and sb.maximum() > 0:
self.reached_top.emit() self.reached_top.emit()
self._recycle_offscreen()
def _recycle_offscreen(self) -> None:
"""Release decoded pixmaps for thumbnails far from the viewport.
Thumbnails within the visible area plus a buffer zone keep their
pixmaps. Thumbnails outside that zone have their pixmap set to
None to free decoded-image memory. When they scroll back into
view, the pixmap is re-decoded from the on-disk thumbnail cache
via ``_source_path``.
This caps decoded-thumbnail memory to roughly (visible + buffer)
widgets instead of every widget ever created during infinite scroll.
"""
if not self._thumbs:
return
step = THUMB_SIZE + THUMB_SPACING
if step == 0:
return
cols = self._flow.columns
vp_top = self.verticalScrollBar().value()
vp_height = self.viewport().height()
# Row range that's visible (0-based row indices)
first_visible_row = max(0, (vp_top - THUMB_SPACING) // step)
last_visible_row = (vp_top + vp_height) // step
# Buffer: keep ±5 rows of decoded pixmaps beyond the viewport
buffer_rows = 5
keep_first = max(0, first_visible_row - buffer_rows)
keep_last = last_visible_row + buffer_rows
keep_start = keep_first * cols
keep_end = min(len(self._thumbs), (keep_last + 1) * cols)
for i, thumb in enumerate(self._thumbs):
if keep_start <= i < keep_end:
# Inside keep zone — restore if missing
if thumb._pixmap is None and thumb._source_path:
pix = QPixmap(thumb._source_path)
if not pix.isNull():
thumb._pixmap = pix.scaled(
THUMB_SIZE - 4, THUMB_SIZE - 4,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
thumb._thumb_opacity = 1.0
thumb.update()
else:
# Outside keep zone — release
if thumb._pixmap is not None:
thumb._pixmap = None
def _nav_horizontal(self, direction: int) -> None: def _nav_horizontal(self, direction: int) -> None:
"""Move selection one cell left (-1) or right (+1); emit edge signals at boundaries.""" """Move selection one cell left (-1) or right (+1); emit edge signals at boundaries."""
@ -656,3 +868,10 @@ class ThumbnailGrid(QScrollArea):
super().resizeEvent(event) super().resizeEvent(event)
if self._flow: if self._flow:
self._flow.resize(self.viewport().size().width(), self._flow.minimumHeight()) self._flow.resize(self.viewport().size().width(), self._flow.minimumHeight())
# Column count can change on resize (splitter drag, tile/float
# toggle). Thumbs that were outside the keep zone had their
# pixmap freed by _recycle_offscreen and will paint as empty
# cells if the row shift moves them into view without a scroll
# event to refresh them. Re-run the recycle pass against the
# new geometry so newly-visible thumbs get their pixmap back.
self._recycle_offscreen()

View File

@ -3,15 +3,17 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from html import escape
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Qt, Property, Signal from PySide6.QtCore import Qt, Property, Signal
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QLabel, QScrollArea, QPushButton, QWidget, QVBoxLayout, QLabel, QScrollArea, QPushButton, QSizePolicy,
) )
from ..core.api.base import Post from ..core.api.base import Post
from ._source_html import build_source_html
log = logging.getLogger("booru") log = logging.getLogger("booru")
@ -85,12 +87,16 @@ class InfoPanel(QWidget):
self._title = QLabel("No post selected") self._title = QLabel("No post selected")
self._title.setStyleSheet("font-weight: bold;") self._title.setStyleSheet("font-weight: bold;")
self._title.setMinimumWidth(0)
self._title.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
layout.addWidget(self._title) layout.addWidget(self._title)
self._details = QLabel() self._details = QLabel()
self._details.setWordWrap(True) self._details.setWordWrap(True)
self._details.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextBrowserInteraction) self._details.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextBrowserInteraction)
self._details.setMaximumHeight(120) self._details.setMaximumHeight(120)
self._details.setMinimumWidth(0)
self._details.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
layout.addWidget(self._details) layout.addWidget(self._details)
self._tags_label = QLabel("Tags:") self._tags_label = QLabel("Tags:")
@ -111,28 +117,12 @@ class InfoPanel(QWidget):
log.debug(f"InfoPanel: tag_categories={list(post.tag_categories.keys()) if post.tag_categories else 'empty'}") log.debug(f"InfoPanel: tag_categories={list(post.tag_categories.keys()) if post.tag_categories else 'empty'}")
self._title.setText(f"Post #{post.id}") self._title.setText(f"Post #{post.id}")
filetype = Path(post.file_url.split("?")[0]).suffix.lstrip(".").upper() if post.file_url else "unknown" filetype = Path(post.file_url.split("?")[0]).suffix.lstrip(".").upper() if post.file_url else "unknown"
source = post.source or "none" source_html = build_source_html(post.source)
# Truncate display text but keep full URL for the link
source_full = source
if len(source) > 60:
source_display = source[:57] + "..."
else:
source_display = source
if source_full.startswith(("http://", "https://")):
source_html = f'<a href="{source_full}" style="color: #4fc3f7;">{source_display}</a>'
else:
source_html = source_display
from html import escape
self._details.setText(
f"Score: {post.score}\n"
f"Rating: {post.rating or 'unknown'}\n"
f"Filetype: {filetype}"
)
self._details.setTextFormat(Qt.TextFormat.RichText) self._details.setTextFormat(Qt.TextFormat.RichText)
self._details.setText( self._details.setText(
f"Score: {post.score}<br>" f"Score: {post.score}<br>"
f"Rating: {escape(post.rating or 'unknown')}<br>" f"Rating: {escape(post.rating or 'unknown')}<br>"
f"Filetype: {filetype}<br>" f"Filetype: {escape(filetype)}<br>"
f"Source: {source_html}" f"Source: {source_html}"
) )
self._details.setOpenExternalLinks(True) self._details.setOpenExternalLinks(True)
@ -146,15 +136,17 @@ class InfoPanel(QWidget):
# Display tags grouped by category. Colors come from the # Display tags grouped by category. Colors come from the
# tag*Color Qt Properties so a custom.qss can override any of # tag*Color Qt Properties so a custom.qss can override any of
# them via `InfoPanel { qproperty-tagCharacterColor: ...; }`. # them via `InfoPanel { qproperty-tagCharacterColor: ...; }`.
rendered: set[str] = set()
for category, tags in post.tag_categories.items(): for category, tags in post.tag_categories.items():
color = self._category_color(category) color = self._category_color(category)
header = QLabel(f"{category}:") header = QLabel(f"{category}:")
header.setStyleSheet( header.setStyleSheet(
f"font-weight: bold; margin-top: 6px; margin-bottom: 2px;" "font-weight: bold; margin-top: 6px; margin-bottom: 2px;"
+ (f" color: {color};" if color else "") + (f" color: {color};" if color else "")
) )
self._tags_flow.addWidget(header) self._tags_flow.addWidget(header)
for tag in tags[:50]: for tag in tags:
rendered.add(tag)
btn = QPushButton(tag) btn = QPushButton(tag)
btn.setFlat(True) btn.setFlat(True)
btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setCursor(Qt.CursorShape.PointingHandCursor)
@ -165,12 +157,33 @@ class InfoPanel(QWidget):
btn.setStyleSheet(style) btn.setStyleSheet(style)
btn.clicked.connect(lambda checked, t=tag: self.tag_clicked.emit(t)) btn.clicked.connect(lambda checked, t=tag: self.tag_clicked.emit(t))
self._tags_flow.addWidget(btn) self._tags_flow.addWidget(btn)
# Safety net: any tag in post.tag_list that didn't land in
# a cached category (batch tag API returned partial results,
# HTML scrape fell short, cache stale, etc.) is still shown
# under an "Other" bucket so tags can't silently disappear
# from the info panel.
leftover = [t for t in post.tag_list if t and t not in rendered]
if leftover:
header = QLabel("Other:")
header.setStyleSheet(
"font-weight: bold; margin-top: 6px; margin-bottom: 2px;"
)
self._tags_flow.addWidget(header)
for tag in leftover:
btn = QPushButton(tag)
btn.setFlat(True)
btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.setStyleSheet(
"QPushButton { text-align: left; padding: 1px 4px; border: none; }"
)
btn.clicked.connect(lambda checked, t=tag: self.tag_clicked.emit(t))
self._tags_flow.addWidget(btn)
elif not self._categories_pending: elif not self._categories_pending:
# Flat tag fallback — only when no category fetch is # Flat tag fallback — only when no category fetch is
# in-flight. When a fetch IS pending, leaving the tags # in-flight. When a fetch IS pending, leaving the tags
# area empty avoids the flat→categorized re-layout hitch # area empty avoids the flat→categorized re-layout hitch
# (categories arrive ~200ms later and render in one pass). # (categories arrive ~200ms later and render in one pass).
for tag in post.tag_list[:100]: for tag in post.tag_list:
btn = QPushButton(tag) btn = QPushButton(tag)
btn.setFlat(True) btn.setFlat(True)
btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setCursor(Qt.CursorShape.PointingHandCursor)

View File

@ -158,7 +158,16 @@ class LibraryView(QWidget):
if query and self._db: if query and self._db:
matching_ids = self._db.search_library_meta(query) matching_ids = self._db.search_library_meta(query)
if matching_ids: if matching_ids:
self._files = [f for f in self._files if f.stem.isdigit() and int(f.stem) in matching_ids] def _file_matches(f: Path) -> bool:
# Templated filenames: look up post_id via library_meta.filename
pid = self._db.get_library_post_id_by_filename(f.name)
if pid is not None:
return pid in matching_ids
# Legacy digit-stem fallback
if f.stem.isdigit():
return int(f.stem) in matching_ids
return False
self._files = [f for f in self._files if _file_matches(f)]
else: else:
self._files = [] self._files = []
@ -180,11 +189,22 @@ class LibraryView(QWidget):
thumb._cached_path = str(filepath) thumb._cached_path = str(filepath)
thumb.setToolTip(filepath.name) thumb.setToolTip(filepath.name)
thumb.set_saved_locally(True) thumb.set_saved_locally(True)
cached_thumb = lib_thumb_dir / f"{filepath.stem}.jpg" # Thumbnails are stored by post_id (from _copy_library_thumb),
# not by filename stem. Resolve post_id so templated filenames
# like artist_12345.jpg find their thumbnail correctly.
thumb_name = filepath.stem # default: digit-stem fallback
if self._db:
pid = self._db.get_library_post_id_by_filename(filepath.name)
if pid is not None:
thumb_name = str(pid)
elif filepath.stem.isdigit():
thumb_name = filepath.stem
cached_thumb = lib_thumb_dir / f"{thumb_name}.jpg"
if cached_thumb.exists(): if cached_thumb.exists():
pix = QPixmap(str(cached_thumb)) thumb_path = str(cached_thumb)
pix = QPixmap(thumb_path)
if not pix.isNull(): if not pix.isNull():
thumb.set_pixmap(pix) thumb.set_pixmap(pix, thumb_path)
continue continue
self._generate_thumb_async(i, filepath, cached_thumb) self._generate_thumb_async(i, filepath, cached_thumb)
@ -255,14 +275,18 @@ class LibraryView(QWidget):
def _sort_files(self) -> None: def _sort_files(self) -> None:
mode = self._sort_combo.currentText() mode = self._sort_combo.currentText()
if mode == "Post ID": if mode == "Post ID":
# Numeric sort by post id (filename stem). Library files are # Numeric sort by post id. Resolves templated filenames
# named {post_id}.{ext} in normal usage; anything with a # (e.g. artist_12345.jpg) via library_meta DB lookup, falls
# non-digit stem (someone manually dropped a file in) sorts # back to digit-stem parsing for legacy files. Anything
# to the end alphabetically so the numeric ordering of real # without a resolvable post_id sorts to the end alphabetically.
# posts isn't disrupted by stray names.
def _key(p: Path) -> tuple: def _key(p: Path) -> tuple:
stem = p.stem if self._db:
return (0, int(stem)) if stem.isdigit() else (1, stem.lower()) pid = self._db.get_library_post_id_by_filename(p.name)
if pid is not None:
return (0, pid)
if p.stem.isdigit():
return (0, int(p.stem))
return (1, p.stem.lower())
self._files.sort(key=_key) self._files.sort(key=_key)
elif mode == "Size": elif mode == "Size":
self._files.sort(key=lambda p: p.stat().st_size, reverse=True) self._files.sort(key=lambda p: p.stat().st_size, reverse=True)
@ -302,21 +326,56 @@ class LibraryView(QWidget):
threading.Thread(target=_work, daemon=True).start() threading.Thread(target=_work, daemon=True).start()
def _capture_video_thumb(self, index: int, source: str, dest: str) -> None: def _capture_video_thumb(self, index: int, source: str, dest: str) -> None:
"""Grab first frame from video. Tries ffmpeg, falls back to placeholder.""" """Grab first frame from video using mpv, falls back to placeholder."""
def _work(): def _work():
extracted = False
try: try:
import subprocess import threading as _threading
result = subprocess.run( import mpv as mpvlib
["ffmpeg", "-y", "-i", source, "-vframes", "1",
"-vf", f"scale={LIBRARY_THUMB_SIZE}:{LIBRARY_THUMB_SIZE}:force_original_aspect_ratio=decrease", frame_ready = _threading.Event()
"-q:v", "5", dest], m = mpvlib.MPV(
capture_output=True, timeout=10, vo='null', ao='null', aid='no',
pause=True, keep_open='yes',
terminal=False, config=False,
# Seek to 10% before first frame decode so a video that
# opens on a black frame (fade-in, title card, codec
# warmup) doesn't produce a black thumbnail. mpv clamps
# `start` to valid range so very short clips still land
# on a real frame.
start='10%',
hr_seek='yes',
) )
if Path(dest).exists(): try:
@m.property_observer('video-params')
def _on_params(_name, value):
if isinstance(value, dict) and value.get('w'):
frame_ready.set()
m.loadfile(source)
if frame_ready.wait(timeout=10):
m.command('screenshot-to-file', dest, 'video')
finally:
m.terminate()
if Path(dest).exists() and Path(dest).stat().st_size > 0:
from PIL import Image
with Image.open(dest) as img:
img.thumbnail(
(LIBRARY_THUMB_SIZE, LIBRARY_THUMB_SIZE),
Image.LANCZOS,
)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(dest, "JPEG", quality=85)
extracted = True
except Exception as e:
log.debug("mpv thumb extraction failed for %s: %s", source, e)
if extracted and Path(dest).exists():
self._signals.thumb_ready.emit(index, dest) self._signals.thumb_ready.emit(index, dest)
return return
except (FileNotFoundError, Exception):
pass
# Fallback: generate a placeholder # Fallback: generate a placeholder
from PySide6.QtGui import QPainter, QColor, QFont from PySide6.QtGui import QPainter, QColor, QFont
from PySide6.QtGui import QPolygon from PySide6.QtGui import QPolygon
@ -344,7 +403,7 @@ class LibraryView(QWidget):
if 0 <= index < len(thumbs): if 0 <= index < len(thumbs):
pix = QPixmap(path) pix = QPixmap(path)
if not pix.isNull(): if not pix.isNull():
thumbs[index].set_pixmap(pix) thumbs[index].set_pixmap(pix, path)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Selection signals # Selection signals
@ -501,7 +560,8 @@ class LibraryView(QWidget):
if post_id is None and filepath.stem.isdigit(): if post_id is None and filepath.stem.isdigit():
post_id = int(filepath.stem) post_id = int(filepath.stem)
filepath.unlink(missing_ok=True) filepath.unlink(missing_ok=True)
lib_thumb = thumbnails_dir() / "library" / f"{filepath.stem}.jpg" thumb_key = str(post_id) if post_id is not None else filepath.stem
lib_thumb = thumbnails_dir() / "library" / f"{thumb_key}.jpg"
lib_thumb.unlink(missing_ok=True) lib_thumb.unlink(missing_ok=True)
if post_id is not None: if post_id is not None:
self._db.remove_library_meta(post_id) self._db.remove_library_meta(post_id)
@ -556,7 +616,8 @@ class LibraryView(QWidget):
if post_id is None and f.stem.isdigit(): if post_id is None and f.stem.isdigit():
post_id = int(f.stem) post_id = int(f.stem)
f.unlink(missing_ok=True) f.unlink(missing_ok=True)
lib_thumb = thumbnails_dir() / "library" / f"{f.stem}.jpg" thumb_key = str(post_id) if post_id is not None else f.stem
lib_thumb = thumbnails_dir() / "library" / f"{thumb_key}.jpg"
lib_thumb.unlink(missing_ok=True) lib_thumb.unlink(missing_ok=True)
if post_id is not None: if post_id is not None:
self._db.remove_library_meta(post_id) self._db.remove_library_meta(post_id)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,89 @@
"""Pure helpers that build the kwargs dict passed to ``mpv.MPV`` and
the post-construction options dict applied via the property API.
Kept free of any Qt or mpv imports so the options can be audited from
a CI test that only installs the stdlib.
"""
from __future__ import annotations
# FFmpeg ``protocol_whitelist`` value applied via mpv's
# ``demuxer-lavf-o`` option (audit finding #2). ``file`` must stay so
# cached local clips and ``.part`` files keep playing; ``http``/
# ``https``/``tls``/``tcp`` are needed for fresh network video.
# ``crypto`` is intentionally omitted — it's an FFmpeg pseudo-protocol
# for AES-decrypted streams that boorus do not legitimately serve.
LAVF_PROTOCOL_WHITELIST = "file,http,https,tls,tcp"
def lavf_options() -> dict[str, str]:
"""Return the FFmpeg lavf demuxer options to apply post-construction.
These cannot be set via ``mpv.MPV(**kwargs)`` because python-mpv's
init path uses ``mpv_set_option_string``, which routes through
mpv's keyvalue list parser. That parser splits on ``,`` to find
entries, so the comma-laden ``protocol_whitelist`` value gets
shredded into orphan tokens and mpv rejects the option with
-7 OPT_FORMAT. mpv's documented backslash escape (``\\,``) is
not unescaped on this code path either.
The post-construction property API DOES accept dict values for
keyvalue-list options via the node API, so we set them after
``mpv.MPV()`` returns. Caller pattern:
m = mpv.MPV(**build_mpv_kwargs(is_windows=...))
for k, v in lavf_options().items():
m["demuxer-lavf-o"] = {k: v}
"""
return {"protocol_whitelist": LAVF_PROTOCOL_WHITELIST}
def build_mpv_kwargs(is_windows: bool) -> dict[str, object]:
"""Return the kwargs dict for constructing ``mpv.MPV``.
The playback, audio, and network options are unchanged from
pre-audit v0.2.5. The security hardening added by SECURITY_AUDIT.md
finding #2 is:
- ``ytdl="no"``: refuse to delegate URL handling to yt-dlp. mpv's
default enables a yt-dlp hook script that matches ~1500 hosts
and shells out to ``yt-dlp`` on any URL it recognizes. A
compromised booru returning ``file_url: "https://youtube.com/..."``
would pull the user through whatever extractor CVE is current.
- ``load_scripts="no"``: do not auto-load Lua scripts from
``~/.config/mpv/scripts``. These scripts run in mpv's context
every time the widget is created.
- ``input_conf="/dev/null"`` (POSIX only): skip loading
``~/.config/mpv/input.conf``. The existing
``input_default_bindings=False`` + ``input_vo_keyboard=False``
are the primary lockdown; this is defense-in-depth. Windows
uses a different null-device path and the load behavior varies
by mpv build, so it is skipped there.
The ffmpeg protocol whitelist (also part of finding #2) is NOT
in this dict see ``lavf_options`` for the explanation.
"""
kwargs: dict[str, object] = {
"vo": "libmpv",
"hwdec": "auto",
"keep_open": "yes",
"ao": "pulse,wasapi,",
"audio_client_name": "booru-viewer",
"input_default_bindings": False,
"input_vo_keyboard": False,
"osc": False,
"vd_lavc_fast": "yes",
"vd_lavc_skiploopfilter": "nonkey",
"cache": "yes",
"cache_pause": "no",
"demuxer_max_bytes": "50MiB",
"demuxer_readahead_secs": "20",
"network_timeout": "10",
"ytdl": "no",
"load_scripts": "no",
}
if not is_windows:
kwargs["input_conf"] = "/dev/null"
return kwargs

View File

@ -22,6 +22,7 @@ class ImageViewer(QWidget):
self._offset = QPointF(0, 0) self._offset = QPointF(0, 0)
self._drag_start: QPointF | None = None self._drag_start: QPointF | None = None
self._drag_offset = QPointF(0, 0) self._drag_offset = QPointF(0, 0)
self._zoom_scroll_accum = 0
self.setMouseTracking(True) self.setMouseTracking(True)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self._info_text = "" self._info_text = ""
@ -106,9 +107,14 @@ class ImageViewer(QWidget):
# Pure horizontal tilt — let parent handle (navigation) # Pure horizontal tilt — let parent handle (navigation)
event.ignore() event.ignore()
return return
self._zoom_scroll_accum += delta
steps = self._zoom_scroll_accum // 120
if not steps:
return
self._zoom_scroll_accum -= steps * 120
mouse_pos = event.position() mouse_pos = event.position()
old_zoom = self._zoom old_zoom = self._zoom
factor = 1.15 if delta > 0 else 1 / 1.15 factor = 1.15 ** steps
self._zoom = max(0.1, min(self._zoom * factor, 20.0)) self._zoom = max(0.1, min(self._zoom * factor, 20.0))
ratio = self._zoom / old_zoom ratio = self._zoom / old_zoom
self._offset = mouse_pos - ratio * (mouse_pos - self._offset) self._offset = mouse_pos - ratio * (mouse_pos - self._offset)

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from PySide6.QtCore import Signal from PySide6.QtCore import Signal
from PySide6.QtOpenGLWidgets import QOpenGLWidget as _QOpenGLWidget from PySide6.QtOpenGLWidgets import QOpenGLWidget as _QOpenGLWidget
@ -10,6 +11,8 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout
import mpv as mpvlib import mpv as mpvlib
from ._mpv_options import build_mpv_kwargs, lavf_options
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -35,58 +38,22 @@ class _MpvGLWidget(QWidget):
self._frame_ready.connect(self._gl.update) self._frame_ready.connect(self._gl.update)
# Create mpv eagerly on the main thread. # Create mpv eagerly on the main thread.
# #
# `ao=pulse` is critical for Linux Discord screen-share audio # Options come from `build_mpv_kwargs` (see `_mpv_options.py`
# capture. Discord on Linux only enumerates audio clients via # for the full rationale). Summary: Discord screen-share audio
# the libpulse API; it does not see clients that talk to # fix via `ao=pulse`, fast-load vd-lavc options, network cache
# PipeWire natively (which is mpv's default `ao=pipewire`). # tuning for the uncached-video fast path, and the SECURITY
# Forcing the pulseaudio output here makes mpv go through # hardening from audit #2 (ytdl=no, load_scripts=no, POSIX
# PipeWire's pulseaudio compatibility layer, which Discord # input_conf null).
# picks up the same way it picks up Firefox. Without this,
# videos play locally but the audio is silently dropped from
# any Discord screen share. See:
# https://github.com/mpv-player/mpv/issues/11100
# https://github.com/edisionnano/Screenshare-with-audio-on-Discord-with-Linux
# On Windows mpv ignores `ao=pulse` and falls through to the
# next entry, so listing `wasapi` second keeps Windows playback
# working without a platform branch here.
#
# `audio_client_name` is the name mpv registers with the audio
# backend. Sets `application.name` and friends so capture tools
# group mpv's audio under the booru-viewer app identity instead
# of the default "mpv Media Player".
self._mpv = mpvlib.MPV( self._mpv = mpvlib.MPV(
vo="libmpv", **build_mpv_kwargs(is_windows=sys.platform == "win32"),
hwdec="auto",
keep_open="yes",
ao="pulse,wasapi,",
audio_client_name="booru-viewer",
input_default_bindings=False,
input_vo_keyboard=False,
osc=False,
# Fast-load options: shave ~50-100ms off first-frame decode
# for h264/hevc by skipping a few bitstream-correctness checks
# (`vd-lavc-fast`) and the in-loop filter on non-keyframes
# (`vd-lavc-skiploopfilter=nonkey`). The artifacts are only
# visible on the first few frames before the decoder steady-
# state catches up, and only on degraded sources. mpv
# documents these as safe for "fast load" use cases like
# ours where we want the first frame on screen ASAP and
# don't care about a tiny quality dip during ramp-up.
vd_lavc_fast="yes",
vd_lavc_skiploopfilter="nonkey",
# Network streaming tuning for the uncached-video fast path.
# cache=yes is mpv's default for network sources but explicit
# is clearer. cache_pause=no keeps playback running through
# brief buffer underruns instead of pausing — for short booru
# clips a momentary stutter beats a pause icon. demuxer caps
# keep RAM bounded. network_timeout=10 replaces mpv's ~60s
# default so stalled connections surface errors promptly.
cache="yes",
cache_pause="no",
demuxer_max_bytes="50MiB",
demuxer_readahead_secs="20",
network_timeout="10",
) )
# The ffmpeg lavf demuxer protocol whitelist (also audit #2)
# has to be applied via the property API, not as an init
# kwarg — python-mpv's init path goes through
# mpv_set_option_string which trips on the comma-laden value.
# The property API uses the node API and accepts dict values.
for key, value in lavf_options().items():
self._mpv["demuxer-lavf-o"] = {key: value}
# Wire up the GL surface's callbacks to us # Wire up the GL surface's callbacks to us
self._gl._owner = self self._gl._owner = self
@ -144,10 +111,35 @@ class _MpvGLWidget(QWidget):
self._gl.makeCurrent() self._gl.makeCurrent()
self._init_gl() self._init_gl()
def cleanup(self) -> None: def release_render_context(self) -> None:
"""Free the GL render context without terminating mpv.
Releases all GPU-side textures and FBOs that the render context
holds. The next ``ensure_gl_init()`` call (from ``play_file``)
recreates the context cheaply (~5ms). This is the difference
between "mpv is idle but holding VRAM" and "mpv is idle and
clean."
Safe to call when mpv has no active file (after
``mpv.command('stop')``). After this, ``_paint_gl`` is a no-op
(``_ctx is None`` guard) and mpv won't fire frame-ready
callbacks because there's no render context to trigger them.
"""
if self._ctx: if self._ctx:
# GL context must be current so mpv can release its textures
# and FBOs on the correct context. Without this, drivers that
# enforce per-context resource ownership (not NVIDIA, but
# Mesa/Intel) leak the GPU objects.
self._gl.makeCurrent()
try:
self._ctx.free() self._ctx.free()
finally:
self._gl.doneCurrent()
self._ctx = None self._ctx = None
self._gl_inited = False
def cleanup(self) -> None:
self.release_render_context()
if self._mpv: if self._mpv:
self._mpv.terminate() self._mpv.terminate()
self._mpv = None self._mpv = None

View File

@ -3,15 +3,93 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os import time
from pathlib import Path
from PySide6.QtCore import Qt, QTimer, Signal, Property from PySide6.QtCore import Qt, QTimer, Signal, Property, QPoint
from PySide6.QtGui import QColor from PySide6.QtGui import QColor, QIcon, QPixmap, QPainter, QPen, QPolygon, QPainterPath, QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QStyle, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QStyle,
) )
def _paint_icon(shape: str, color: QColor, size: int = 16) -> QIcon:
"""Paint a media control icon using the given color."""
pix = QPixmap(size, size)
pix.fill(Qt.GlobalColor.transparent)
p = QPainter(pix)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(color)
s = size
if shape == "play":
p.drawPolygon(QPolygon([QPoint(3, 2), QPoint(3, s - 2), QPoint(s - 2, s // 2)]))
elif shape == "pause":
w = max(2, s // 4)
p.drawRect(2, 2, w, s - 4)
p.drawRect(s - 2 - w, 2, w, s - 4)
elif shape == "volume":
# Speaker cone
p.drawPolygon(QPolygon([
QPoint(1, s // 2 - 2), QPoint(4, s // 2 - 2),
QPoint(8, 2), QPoint(8, s - 2),
QPoint(4, s // 2 + 2), QPoint(1, s // 2 + 2),
]))
# Sound waves
p.setPen(QPen(color, 1.5))
p.setBrush(Qt.BrushStyle.NoBrush)
path = QPainterPath()
path.arcMoveTo(8, 3, 6, s - 6, 45)
path.arcTo(8, 3, 6, s - 6, 45, -90)
p.drawPath(path)
elif shape == "muted":
p.drawPolygon(QPolygon([
QPoint(1, s // 2 - 2), QPoint(4, s // 2 - 2),
QPoint(8, 2), QPoint(8, s - 2),
QPoint(4, s // 2 + 2), QPoint(1, s // 2 + 2),
]))
p.setPen(QPen(color, 2))
p.drawLine(10, 4, s - 2, s - 4)
p.drawLine(10, s - 4, s - 2, 4)
elif shape == "loop":
p.setPen(QPen(color, 1.5))
p.setBrush(Qt.BrushStyle.NoBrush)
path = QPainterPath()
path.arcMoveTo(2, 2, s - 4, s - 4, 30)
path.arcTo(2, 2, s - 4, s - 4, 30, 300)
p.drawPath(path)
# Arrowhead
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(color)
end = path.currentPosition().toPoint()
p.drawPolygon(QPolygon([
end, QPoint(end.x() - 4, end.y() - 3), QPoint(end.x() + 1, end.y() - 4),
]))
elif shape == "once":
p.setPen(QPen(color, 1))
f = QFont()
f.setPixelSize(s - 2)
f.setBold(True)
p.setFont(f)
p.drawText(pix.rect(), Qt.AlignmentFlag.AlignCenter, "1\u00D7")
elif shape == "next":
p.drawPolygon(QPolygon([QPoint(2, 2), QPoint(2, s - 2), QPoint(s - 5, s // 2)]))
p.drawRect(s - 4, 2, 2, s - 4)
elif shape == "auto":
mid = s // 2
p.drawPolygon(QPolygon([QPoint(1, 3), QPoint(1, s - 3), QPoint(mid - 1, s // 2)]))
p.drawPolygon(QPolygon([QPoint(mid, 3), QPoint(mid, s - 3), QPoint(s - 2, s // 2)]))
p.end()
return QIcon(pix)
import mpv as mpvlib import mpv as mpvlib
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -81,6 +159,9 @@ class VideoPlayer(QWidget):
self._mpv['background'] = 'color' self._mpv['background'] = 'color'
self._mpv['background-color'] = self._letterbox_color.name() self._mpv['background-color'] = self._letterbox_color.name()
except Exception: except Exception:
# mpv not fully initialized or torn down; letterbox color
# is a cosmetic fallback so a property-write refusal just
# leaves the default black until next set.
pass pass
def __init__(self, parent: QWidget | None = None, embed_controls: bool = True) -> None: def __init__(self, parent: QWidget | None = None, embed_controls: bool = True) -> None:
@ -119,15 +200,22 @@ class VideoPlayer(QWidget):
controls = QHBoxLayout(self._controls_bar) controls = QHBoxLayout(self._controls_bar)
controls.setContentsMargins(4, 2, 4, 2) controls.setContentsMargins(4, 2, 4, 2)
# Compact-padding override matches the top preview toolbar so the _btn_sz = 24
# bottom controls bar reads as part of the same panel rather than _fg = self.palette().buttonText().color()
# as a stamped-in overlay. Bundled themes' default `padding: 5px 12px`
# is too wide for short labels in narrow button slots.
_ctrl_btn_style = "padding: 2px 6px;"
self._play_btn = QPushButton("Play") def _icon_btn(shape: str, name: str, tip: str) -> QPushButton:
self._play_btn.setMaximumWidth(65) btn = QPushButton()
self._play_btn.setStyleSheet(_ctrl_btn_style) btn.setObjectName(name)
btn.setIcon(_paint_icon(shape, _fg))
btn.setFixedSize(_btn_sz, _btn_sz)
btn.setToolTip(tip)
return btn
self._icon_fg = _fg
self._play_icon = _paint_icon("play", _fg)
self._pause_icon = _paint_icon("pause", _fg)
self._play_btn = _icon_btn("play", "_ctrl_play", "Play / Pause (Space)")
self._play_btn.clicked.connect(self._toggle_play) self._play_btn.clicked.connect(self._toggle_play)
controls.addWidget(self._play_btn) controls.addWidget(self._play_btn)
@ -152,28 +240,29 @@ class VideoPlayer(QWidget):
self._vol_slider.valueChanged.connect(self._set_volume) self._vol_slider.valueChanged.connect(self._set_volume)
controls.addWidget(self._vol_slider) controls.addWidget(self._vol_slider)
self._mute_btn = QPushButton("Mute") self._vol_icon = _paint_icon("volume", _fg)
self._mute_btn.setMaximumWidth(80) self._muted_icon = _paint_icon("muted", _fg)
self._mute_btn.setStyleSheet(_ctrl_btn_style)
self._mute_btn = _icon_btn("volume", "_ctrl_mute", "Mute / Unmute")
self._mute_btn.clicked.connect(self._toggle_mute) self._mute_btn.clicked.connect(self._toggle_mute)
controls.addWidget(self._mute_btn) controls.addWidget(self._mute_btn)
self._autoplay = True self._autoplay = True
self._autoplay_btn = QPushButton("Auto") self._auto_icon = _paint_icon("auto", _fg)
self._autoplay_btn.setMaximumWidth(70) self._autoplay_btn = _icon_btn("auto", "_ctrl_autoplay", "Auto-play videos when selected")
self._autoplay_btn.setStyleSheet(_ctrl_btn_style)
self._autoplay_btn.setCheckable(True) self._autoplay_btn.setCheckable(True)
self._autoplay_btn.setChecked(True) self._autoplay_btn.setChecked(True)
self._autoplay_btn.setToolTip("Auto-play videos when selected")
self._autoplay_btn.clicked.connect(self._toggle_autoplay) self._autoplay_btn.clicked.connect(self._toggle_autoplay)
self._autoplay_btn.hide() self._autoplay_btn.hide()
controls.addWidget(self._autoplay_btn) controls.addWidget(self._autoplay_btn)
self._loop_icons = {
0: _paint_icon("loop", _fg),
1: _paint_icon("once", _fg),
2: _paint_icon("next", _fg),
}
self._loop_state = 0 # 0=Loop, 1=Once, 2=Next self._loop_state = 0 # 0=Loop, 1=Once, 2=Next
self._loop_btn = QPushButton("Loop") self._loop_btn = _icon_btn("loop", "_ctrl_loop", "Loop / Once / Next")
self._loop_btn.setMaximumWidth(60)
self._loop_btn.setStyleSheet(_ctrl_btn_style)
self._loop_btn.setToolTip("Loop: repeat / Once: stop at end / Next: advance")
self._loop_btn.clicked.connect(self._cycle_loop) self._loop_btn.clicked.connect(self._cycle_loop)
controls.addWidget(self._loop_btn) controls.addWidget(self._loop_btn)
@ -188,6 +277,10 @@ class VideoPlayer(QWidget):
if embed_controls: if embed_controls:
layout.addWidget(self._controls_bar) layout.addWidget(self._controls_bar)
# Responsive hiding: watch controls bar resize and hide widgets
# that don't fit at narrow widths.
self._controls_bar.installEventFilter(self)
self._eof_pending = False self._eof_pending = False
# Stale-eof suppression window. mpv emits `eof-reached=True` # Stale-eof suppression window. mpv emits `eof-reached=True`
# whenever a file ends — including via `command('stop')` — # whenever a file ends — including via `command('stop')` —
@ -238,14 +331,6 @@ class VideoPlayer(QWidget):
# spawn unmuted by default. _ensure_mpv replays this on creation. # spawn unmuted by default. _ensure_mpv replays this on creation.
self._pending_mute: bool = False self._pending_mute: bool = False
# Stream-record state: mpv's stream-record option tees its
# network stream into a .part file that gets promoted to the
# real cache path on clean EOF. Eliminates the parallel httpx
# download that used to race with mpv for the same bytes.
self._stream_record_tmp: Path | None = None
self._stream_record_target: Path | None = None
self._seeked_during_record: bool = False
def _ensure_mpv(self) -> mpvlib.MPV: def _ensure_mpv(self) -> mpvlib.MPV:
"""Set up mpv callbacks on first use. MPV instance is pre-created.""" """Set up mpv callbacks on first use. MPV instance is pre-created."""
if self._mpv is not None: if self._mpv is not None:
@ -295,7 +380,7 @@ class VideoPlayer(QWidget):
self._pending_mute = val self._pending_mute = val
if self._mpv: if self._mpv:
self._mpv.mute = val self._mpv.mute = val
self._mute_btn.setText("Unmute" if val else "Mute") self._mute_btn.setIcon(self._muted_icon if val else self._vol_icon)
@property @property
def autoplay(self) -> bool: def autoplay(self) -> bool:
@ -305,7 +390,8 @@ class VideoPlayer(QWidget):
def autoplay(self, val: bool) -> None: def autoplay(self, val: bool) -> None:
self._autoplay = val self._autoplay = val
self._autoplay_btn.setChecked(val) self._autoplay_btn.setChecked(val)
self._autoplay_btn.setText("Autoplay" if val else "Manual") self._autoplay_btn.setIcon(self._auto_icon if val else self._play_icon)
self._autoplay_btn.setToolTip("Autoplay on" if val else "Autoplay off")
@property @property
def loop_state(self) -> int: def loop_state(self) -> int:
@ -314,8 +400,9 @@ class VideoPlayer(QWidget):
@loop_state.setter @loop_state.setter
def loop_state(self, val: int) -> None: def loop_state(self, val: int) -> None:
self._loop_state = val self._loop_state = val
labels = ["Loop", "Once", "Next"] tips = ["Loop: repeat", "Once: stop at end", "Next: advance"]
self._loop_btn.setText(labels[val]) self._loop_btn.setIcon(self._loop_icons[val])
self._loop_btn.setToolTip(tips[val])
self._autoplay_btn.setVisible(val == 2) self._autoplay_btn.setVisible(val == 2)
self._apply_loop_to_mpv() self._apply_loop_to_mpv()
@ -327,8 +414,6 @@ class VideoPlayer(QWidget):
def seek_to_ms(self, ms: int) -> None: def seek_to_ms(self, ms: int) -> None:
if self._mpv: if self._mpv:
self._mpv.seek(ms / 1000.0, 'absolute+exact') self._mpv.seek(ms / 1000.0, 'absolute+exact')
if self._stream_record_target is not None:
self._seeked_during_record = True
def play_file(self, path: str, info: str = "") -> None: def play_file(self, path: str, info: str = "") -> None:
"""Play a file from a local path OR a remote http(s) URL. """Play a file from a local path OR a remote http(s) URL.
@ -350,6 +435,19 @@ class VideoPlayer(QWidget):
""" """
m = self._ensure_mpv() m = self._ensure_mpv()
self._gl_widget.ensure_gl_init() self._gl_widget.ensure_gl_init()
# Re-arm hardware decoder before each load. stop() sets
# hwdec=no to release the NVDEC/VAAPI surface pool (the bulk
# of mpv's idle VRAM footprint on NVIDIA), so we flip it back
# to auto here so the next loadfile picks up hwdec again.
# mpv re-inits the decoder context on the next frame — swamped
# by the network fetch for uncached videos.
try:
m['hwdec'] = 'auto'
except Exception:
# If hwdec re-arm is refused, mpv falls back to software
# decode silently — playback still works, just at higher
# CPU cost on this file.
pass
self._current_file = path self._current_file = path
self._media_ready_fired = False self._media_ready_fired = False
self._pending_duration = None self._pending_duration = None
@ -359,67 +457,101 @@ class VideoPlayer(QWidget):
# treated as belonging to the previous file's stop and # treated as belonging to the previous file's stop and
# ignored — see the long comment at __init__'s # ignored — see the long comment at __init__'s
# `_eof_ignore_until` definition for the race trace. # `_eof_ignore_until` definition for the race trace.
import time as _time self._eof_ignore_until = time.monotonic() + self._eof_ignore_window_secs
self._eof_ignore_until = _time.monotonic() + self._eof_ignore_window_secs
self._last_video_size = None # reset dedupe so new file fires a fit self._last_video_size = None # reset dedupe so new file fires a fit
self._apply_loop_to_mpv() self._apply_loop_to_mpv()
# Clean up any leftover .part from a previous play_file that
# didn't finish (rapid clicks, popout closed mid-stream, etc).
self._discard_stream_record()
if path.startswith(("http://", "https://")): if path.startswith(("http://", "https://")):
from urllib.parse import urlparse from urllib.parse import urlparse
from ...core.cache import _referer_for, cached_path_for from ...core.cache import _referer_for
referer = _referer_for(urlparse(path)) referer = _referer_for(urlparse(path))
target = cached_path_for(path) m.loadfile(path, "replace", referrer=referer)
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(target.suffix + ".part")
m.loadfile(path, "replace",
referrer=referer,
stream_record=tmp.as_posix())
self._stream_record_tmp = tmp
self._stream_record_target = target
else: else:
m.loadfile(path) m.loadfile(path)
if self._autoplay: if self._autoplay:
m.pause = False m.pause = False
else: else:
m.pause = True m.pause = True
self._play_btn.setText("Pause" if not m.pause else "Play") self._play_btn.setIcon(self._pause_icon if not m.pause else self._play_icon)
self._poll_timer.start() self._poll_timer.start()
def stop(self) -> None: def stop(self) -> None:
self._discard_stream_record()
self._poll_timer.stop() self._poll_timer.stop()
if self._mpv: if self._mpv:
self._mpv.command('stop') self._mpv.command('stop')
# Drop the hardware decoder surface pool to release VRAM
# while idle. On NVIDIA the NVDEC pool is the bulk of mpv's
# idle footprint and keep_open=yes + the live GL render
# context would otherwise pin it for the widget lifetime.
# play_file re-arms hwdec='auto' before the next loadfile.
try:
self._mpv['hwdec'] = 'no'
except Exception:
# Best-effort VRAM release on stop; if mpv is mid-
# teardown and rejects the write, GL context destruction
# still drops the surface pool eventually.
pass
# Free the GL render context so its internal textures and FBOs
# release VRAM while no video is playing. The next play_file()
# call recreates the context via ensure_gl_init() (~5ms cost,
# swamped by the network fetch for uncached videos).
self._gl_widget.release_render_context()
self._time_label.setText("0:00") self._time_label.setText("0:00")
self._duration_label.setText("0:00") self._duration_label.setText("0:00")
self._seek_slider.setRange(0, 0) self._seek_slider.setRange(0, 0)
self._play_btn.setText("Play") self._play_btn.setIcon(self._play_icon)
def pause(self) -> None: def pause(self) -> None:
if self._mpv: if self._mpv:
self._mpv.pause = True self._mpv.pause = True
self._play_btn.setText("Play") self._play_btn.setIcon(self._play_icon)
def resume(self) -> None: def resume(self) -> None:
if self._mpv: if self._mpv:
self._mpv.pause = False self._mpv.pause = False
self._play_btn.setText("Pause") self._play_btn.setIcon(self._pause_icon)
# -- Internal controls -- # -- Internal controls --
def eventFilter(self, obj, event):
if obj is self._controls_bar and event.type() == event.Type.Resize:
self._apply_responsive_layout()
return super().eventFilter(obj, event)
def _apply_responsive_layout(self) -> None:
"""Hide/show control elements based on available width."""
w = self._controls_bar.width()
# Breakpoints — hide wider elements first
show_volume = w >= 320
show_duration = w >= 240
show_time = w >= 200
self._vol_slider.setVisible(show_volume)
self._duration_label.setVisible(show_duration)
self._time_label.setVisible(show_time)
def _toggle_play(self) -> None: def _toggle_play(self) -> None:
if not self._mpv: if not self._mpv:
return return
# If paused at end-of-file (Once mode after playback), seek back
# to the start so pressing play replays instead of doing nothing.
if self._mpv.pause:
try:
pos = self._mpv.time_pos
dur = self._mpv.duration
if pos is not None and dur is not None and dur > 0 and pos >= dur - 0.5:
self._mpv.command('seek', 0, 'absolute+exact')
except Exception:
# Replay-on-end is a UX nicety; if mpv refuses the
# seek (stream not ready, state mid-transition) just
# toggle pause without rewinding.
pass
self._mpv.pause = not self._mpv.pause self._mpv.pause = not self._mpv.pause
self._play_btn.setText("Play" if self._mpv.pause else "Pause") self._play_btn.setIcon(self._play_icon if self._mpv.pause else self._pause_icon)
def _toggle_autoplay(self, checked: bool = True) -> None: def _toggle_autoplay(self, checked: bool = True) -> None:
self._autoplay = self._autoplay_btn.isChecked() self._autoplay = self._autoplay_btn.isChecked()
self._autoplay_btn.setText("Autoplay" if self._autoplay else "Manual") self._autoplay_btn.setIcon(self._auto_icon if self._autoplay else self._play_icon)
self._autoplay_btn.setToolTip("Autoplay on" if self._autoplay else "Autoplay off")
def _cycle_loop(self) -> None: def _cycle_loop(self) -> None:
self.loop_state = (self._loop_state + 1) % 3 self.loop_state = (self._loop_state + 1) % 3
@ -448,8 +580,6 @@ class VideoPlayer(QWidget):
""" """
if self._mpv: if self._mpv:
self._mpv.seek(pos / 1000.0, 'absolute+exact') self._mpv.seek(pos / 1000.0, 'absolute+exact')
if self._stream_record_target is not None:
self._seeked_during_record = True
def _seek_relative(self, ms: int) -> None: def _seek_relative(self, ms: int) -> None:
if self._mpv: if self._mpv:
@ -463,7 +593,7 @@ class VideoPlayer(QWidget):
if self._mpv: if self._mpv:
self._mpv.mute = not self._mpv.mute self._mpv.mute = not self._mpv.mute
self._pending_mute = bool(self._mpv.mute) self._pending_mute = bool(self._mpv.mute)
self._mute_btn.setText("Unmute" if self._mpv.mute else "Mute") self._mute_btn.setIcon(self._muted_icon if self._mpv.mute else self._vol_icon)
# -- mpv callbacks (called from mpv thread) -- # -- mpv callbacks (called from mpv thread) --
@ -487,8 +617,7 @@ class VideoPlayer(QWidget):
reset and trigger a spurious play_next auto-advance. reset and trigger a spurious play_next auto-advance.
""" """
if value is True: if value is True:
import time as _time if time.monotonic() < self._eof_ignore_until:
if _time.monotonic() < self._eof_ignore_until:
# Stale eof from a previous file's stop. Drop it. # Stale eof from a previous file's stop. Drop it.
return return
self._eof_pending = True self._eof_pending = True
@ -528,9 +657,9 @@ class VideoPlayer(QWidget):
# Pause state # Pause state
paused = self._mpv.pause paused = self._mpv.pause
expected_text = "Play" if paused else "Pause" expected_icon = self._play_icon if paused else self._pause_icon
if self._play_btn.text() != expected_text: if self._play_btn.icon().cacheKey() != expected_icon.cacheKey():
self._play_btn.setText(expected_text) self._play_btn.setIcon(expected_icon)
# Video size (set by observer on mpv thread, emitted here on main thread) # Video size (set by observer on mpv thread, emitted here on main thread)
if self._pending_video_size is not None: if self._pending_video_size is not None:
@ -547,61 +676,12 @@ class VideoPlayer(QWidget):
if not self._eof_pending: if not self._eof_pending:
return return
self._eof_pending = False self._eof_pending = False
self._finalize_stream_record()
if self._loop_state == 1: # Once if self._loop_state == 1: # Once
self.pause() self.pause()
elif self._loop_state == 2: # Next elif self._loop_state == 2: # Next
self.pause() self.pause()
self.play_next.emit() self.play_next.emit()
# -- Stream-record helpers --
def _discard_stream_record(self) -> None:
"""Remove any pending stream-record temp file without promoting."""
tmp = self._stream_record_tmp
self._stream_record_tmp = None
self._stream_record_target = None
self._seeked_during_record = False
if tmp is not None:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
def _finalize_stream_record(self) -> None:
"""Promote the stream-record .part file to its final cache path.
Only promotes if: (a) there is a pending stream-record, (b) the
user did not seek during playback (seeking invalidates the file
because mpv may have skipped byte ranges), and (c) the .part
file exists and is non-empty.
"""
tmp = self._stream_record_tmp
target = self._stream_record_target
self._stream_record_tmp = None
self._stream_record_target = None
if tmp is None or target is None:
return
if self._seeked_during_record:
log.debug("Stream-record discarded (seek during playback): %s", tmp.name)
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
return
if not tmp.exists() or tmp.stat().st_size == 0:
log.debug("Stream-record .part missing or empty: %s", tmp.name)
return
try:
os.replace(tmp, target)
log.debug("Stream-record promoted: %s -> %s", tmp.name, target.name)
except OSError as e:
log.warning("Stream-record promote failed: %s", e)
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
@staticmethod @staticmethod
def _fmt(ms: int) -> str: def _fmt(ms: int) -> str:
s = ms // 1000 s = ms // 1000

View File

@ -0,0 +1,322 @@
"""Image/video loading, prefetch, download progress, and cache eviction."""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from ..core.cache import download_image, cache_size_bytes, evict_oldest, evict_oldest_thumbnails
if TYPE_CHECKING:
from .main_window import BooruApp
log = logging.getLogger("booru")
# -- Pure functions (tested in tests/gui/test_media_controller.py) --
def compute_prefetch_order(
index: int, total: int, columns: int, mode: str,
) -> list[int]:
"""Return an ordered list of indices to prefetch around *index*.
*mode* is ``"Nearby"`` (4 cardinals) or ``"Aggressive"`` (ring expansion
capped at ~3 rows radius).
"""
if total == 0:
return []
if mode == "Nearby":
order = []
for offset in [1, -1, columns, -columns]:
adj = index + offset
if 0 <= adj < total:
order.append(adj)
return order
# Aggressive: ring expansion
max_radius = 3
max_posts = columns * max_radius * 2 + columns
seen = {index}
order = []
for dist in range(1, max_radius + 1):
ring = set()
for dy in (-dist, 0, dist):
for dx in (-dist, 0, dist):
if dy == 0 and dx == 0:
continue
adj = index + dy * columns + dx
if 0 <= adj < total and adj not in seen:
ring.add(adj)
for adj in (index + dist, index - dist):
if 0 <= adj < total and adj not in seen:
ring.add(adj)
for adj in sorted(ring):
seen.add(adj)
order.append(adj)
if len(order) >= max_posts:
break
return order
# -- Controller --
class MediaController:
"""Owns image/video loading, prefetch, download progress, and cache eviction."""
def __init__(self, app: BooruApp) -> None:
self._app = app
self._prefetch_pause = asyncio.Event()
self._prefetch_pause.set() # not paused
self._last_evict_check = 0.0 # monotonic timestamp
self._prefetch_gen = 0 # incremented on each prefetch_adjacent call
# -- Post activation (media load) --
def on_post_activated(self, index: int) -> None:
if 0 <= index < len(self._app._posts):
post = self._app._posts[index]
log.info(f"Preview: #{post.id} -> {post.file_url}")
try:
if self._app._popout_ctrl.window:
self._app._popout_ctrl.window.force_mpv_pause()
pmpv = self._app._preview._video_player._mpv
if pmpv is not None:
pmpv.pause = True
except Exception:
pass
self._app._preview._current_post = post
self._app._preview._current_site_id = self._app._site_combo.currentData()
self._app._preview.set_post_tags(post.tag_categories, post.tag_list)
self._app._ensure_post_categories_async(post)
site_id = self._app._preview._current_site_id
self._app._preview.update_bookmark_state(
bool(site_id and self._app._db.is_bookmarked(site_id, post.id))
)
self._app._preview.update_save_state(self._app._post_actions.is_post_saved(post.id))
self._app._status.showMessage(f"Loading #{post.id}...")
preview_hidden = not (
self._app._preview.isVisible() and self._app._preview.width() > 0
)
if preview_hidden:
self._app._signals.prefetch_progress.emit(index, 0.0)
else:
self._app._dl_progress.show()
self._app._dl_progress.setRange(0, 0)
def _progress(downloaded, total):
self._app._signals.download_progress.emit(downloaded, total)
if preview_hidden and total > 0:
self._app._signals.prefetch_progress.emit(
index, downloaded / total
)
info = (f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}] {Path(post.file_url.split('?')[0]).suffix.lstrip('.').upper() if post.file_url else ''}"
+ (f" {post.created_at}" if post.created_at else ""))
from ..core.cache import is_cached
from .media.constants import VIDEO_EXTENSIONS
is_video = bool(
post.file_url
and Path(post.file_url.split('?')[0]).suffix.lower() in VIDEO_EXTENSIONS
)
streaming = is_video and post.file_url and not is_cached(post.file_url)
if streaming:
self._app._signals.video_stream.emit(
post.file_url, info, post.width, post.height
)
async def _load():
self._prefetch_pause.clear()
try:
path = await download_image(post.file_url, progress_callback=_progress)
self._app._signals.image_done.emit(str(path), info)
except Exception as e:
log.error(f"Image download failed: {e}")
self._app._signals.image_error.emit(str(e))
finally:
self._prefetch_pause.set()
if preview_hidden:
self._app._signals.prefetch_progress.emit(index, -1)
self._app._run_async(_load)
if self._app._db.get_setting("prefetch_mode") in ("Nearby", "Aggressive"):
self.prefetch_adjacent(index)
# -- Image/video result handlers --
def on_image_done(self, path: str, info: str) -> None:
self._app._dl_progress.hide()
# If the preview is already streaming this video from URL,
# just update path references so copy/paste works — don't
# restart playback.
current = self._app._preview._current_path
if current and current.startswith(("http://", "https://")):
from ..core.cache import cached_path_for
if Path(path) == cached_path_for(current):
self._app._preview._current_path = path
idx = self._app._grid.selected_index
if 0 <= idx < len(self._app._grid._thumbs):
self._app._grid._thumbs[idx]._cached_path = path
cn = self._app._search_ctrl._cached_names
if cn is not None:
cn.add(Path(path).name)
self._app._status.showMessage(info)
self.auto_evict_cache()
return
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
self._app._preview._info_label.setText(info)
self._app._preview._current_path = path
else:
self.set_preview_media(path, info)
self._app._status.showMessage(info)
idx = self._app._grid.selected_index
if 0 <= idx < len(self._app._grid._thumbs):
self._app._grid._thumbs[idx]._cached_path = path
# Keep the search controller's cached-names set current so
# subsequent _drain_append_queue calls see newly downloaded files
# without a full directory rescan.
cn = self._app._search_ctrl._cached_names
if cn is not None:
from pathlib import Path as _P
cn.add(_P(path).name)
self._app._popout_ctrl.update_media(path, info)
self.auto_evict_cache()
def on_video_stream(self, url: str, info: str, width: int, height: int) -> None:
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
self._app._preview._info_label.setText(info)
self._app._preview._current_path = url
self._app._popout_ctrl.window.set_media(url, info, width=width, height=height)
self._app._popout_ctrl.update_state()
else:
self._app._preview._video_player.stop()
self._app._preview.set_media(url, info)
# Pre-set the expected cache path on the thumbnail immediately.
# The parallel httpx download will also set it via on_image_done
# when it completes, but this makes it available for drag-to-copy
# from the moment streaming starts.
from ..core.cache import cached_path_for
idx = self._app._grid.selected_index
if 0 <= idx < len(self._app._grid._thumbs):
self._app._grid._thumbs[idx]._cached_path = str(cached_path_for(url))
self._app._status.showMessage(f"Streaming #{Path(url.split('?')[0]).name}...")
def on_download_progress(self, downloaded: int, total: int) -> None:
popout_open = bool(self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible())
if total > 0:
if not popout_open:
self._app._dl_progress.setRange(0, total)
self._app._dl_progress.setValue(downloaded)
self._app._dl_progress.show()
mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
self._app._status.showMessage(f"Downloading... {mb:.1f}/{total_mb:.1f} MB")
if downloaded >= total and not popout_open:
self._app._dl_progress.hide()
elif not popout_open:
self._app._dl_progress.setRange(0, 0)
self._app._dl_progress.show()
def set_preview_media(self, path: str, info: str) -> None:
"""Set media on preview or just info if popout is open."""
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
self._app._preview._info_label.setText(info)
self._app._preview._current_path = path
else:
self._app._preview.set_media(path, info)
# -- Prefetch --
def on_prefetch_progress(self, index: int, progress: float) -> None:
if 0 <= index < len(self._app._grid._thumbs):
self._app._grid._thumbs[index].set_prefetch_progress(progress)
def prefetch_adjacent(self, index: int) -> None:
"""Prefetch posts around the given index.
Bumps a generation counter so any previously running spiral
exits at its next iteration instead of continuing to download
stale adjacencies.
"""
total = len(self._app._posts)
if total == 0:
return
cols = self._app._grid._flow.columns
mode = self._app._db.get_setting("prefetch_mode")
order = compute_prefetch_order(index, total, cols, mode)
self._prefetch_gen += 1
gen = self._prefetch_gen
async def _prefetch_spiral():
for adj in order:
if self._prefetch_gen != gen:
return # superseded by a newer prefetch
await self._prefetch_pause.wait()
if self._prefetch_gen != gen:
return
if 0 <= adj < len(self._app._posts) and self._app._posts[adj].file_url:
self._app._signals.prefetch_progress.emit(adj, 0.0)
try:
def _progress(dl, total_bytes, idx=adj):
if total_bytes > 0:
self._app._signals.prefetch_progress.emit(idx, dl / total_bytes)
await download_image(self._app._posts[adj].file_url, progress_callback=_progress)
except Exception as e:
log.warning(f"Operation failed: {e}")
self._app._signals.prefetch_progress.emit(adj, -1)
await asyncio.sleep(0.2)
self._app._run_async(_prefetch_spiral)
# -- Cache eviction --
def auto_evict_cache(self) -> None:
import time
now = time.monotonic()
if now - self._last_evict_check < 30:
return
self._last_evict_check = now
if not self._app._db.get_setting_bool("auto_evict"):
return
max_mb = self._app._db.get_setting_int("max_cache_mb")
if max_mb <= 0:
return
max_bytes = max_mb * 1024 * 1024
current = cache_size_bytes(include_thumbnails=False)
if current > max_bytes:
protected = set()
for fav in self._app._db.get_bookmarks(limit=999999):
if fav.cached_path:
protected.add(fav.cached_path)
evicted = evict_oldest(max_bytes, protected, current_bytes=current)
if evicted:
log.info(f"Auto-evicted {evicted} cached files")
max_thumb_mb = self._app._db.get_setting_int("max_thumb_cache_mb") or 500
max_thumb_bytes = max_thumb_mb * 1024 * 1024
evicted_thumbs = evict_oldest_thumbnails(max_thumb_bytes)
if evicted_thumbs:
log.info(f"Auto-evicted {evicted_thumbs} thumbnails")
# -- Utility --
@staticmethod
def image_dimensions(path: str) -> tuple[int, int]:
"""Read image width/height from a local file without decoding pixels."""
from .media.constants import _is_video
if _is_video(path):
return 0, 0
try:
from PySide6.QtGui import QImageReader
reader = QImageReader(path)
size = reader.size()
if size.isValid():
return size.width(), size.height()
except Exception:
pass
return 0, 0

View File

@ -114,7 +114,7 @@ class FitWindowToContent:
"""Compute the new window rect for the given content aspect using """Compute the new window rect for the given content aspect using
`state.viewport` and dispatch it to Hyprland (or `setGeometry()` `state.viewport` and dispatch it to Hyprland (or `setGeometry()`
on non-Hyprland). The adapter delegates the rect math + dispatch on non-Hyprland). The adapter delegates the rect math + dispatch
to `popout/hyprland.py`'s helper, which lands in commit 13. to the helpers in `popout/hyprland.py`.
""" """
content_w: int content_w: int

View File

@ -11,11 +11,11 @@ behind the same `HYPRLAND_INSTANCE_SIGNATURE` env var check the
legacy code used. Off-Hyprland systems no-op or return None at every legacy code used. Off-Hyprland systems no-op or return None at every
entry point. entry point.
The legacy `FullscreenPreview._hyprctl_*` methods become 1-line The popout adapter calls these helpers directly; there are no
shims that call into this module see commit 13's changes to `FullscreenPreview._hyprctl_*` shims anymore. Every env-var gate
`popout/window.py`. The shims preserve byte-for-byte call-site for opt-out (`BOORU_VIEWER_NO_HYPR_RULES`, popout-specific aspect
compatibility for the existing window.py code; commit 14's adapter lock) is implemented inside these functions so every call site
rewrite drops them in favor of direct calls. gets the same behavior.
""" """
from __future__ import annotations from __future__ import annotations
@ -54,7 +54,7 @@ def get_window(window_title: str) -> dict | None:
return None return None
def resize(window_title: str, w: int, h: int) -> None: def resize(window_title: str, w: int, h: int, animate: bool = False) -> None:
"""Ask Hyprland to resize the popout and lock its aspect ratio. """Ask Hyprland to resize the popout and lock its aspect ratio.
No-op on non-Hyprland systems. Tiled windows skip the resize No-op on non-Hyprland systems. Tiled windows skip the resize
@ -86,12 +86,12 @@ def resize(window_title: str, w: int, h: int) -> None:
if not win.get("floating"): if not win.get("floating"):
# Tiled — don't resize (fights the layout). Optionally set # Tiled — don't resize (fights the layout). Optionally set
# aspect lock and no_anim depending on the env vars. # aspect lock and no_anim depending on the env vars.
if rules_on: if rules_on and not animate:
cmds.append(f"dispatch setprop address:{addr} no_anim 1") cmds.append(f"dispatch setprop address:{addr} no_anim 1")
if aspect_on: if aspect_on:
cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 1") cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 1")
else: else:
if rules_on: if rules_on and not animate:
cmds.append(f"dispatch setprop address:{addr} no_anim 1") cmds.append(f"dispatch setprop address:{addr} no_anim 1")
if aspect_on: if aspect_on:
cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 0") cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 0")
@ -111,6 +111,7 @@ def resize_and_move(
x: int, x: int,
y: int, y: int,
win: dict | None = None, win: dict | None = None,
animate: bool = False,
) -> None: ) -> None:
"""Atomically resize and move the popout via a single hyprctl batch. """Atomically resize and move the popout via a single hyprctl batch.
@ -140,7 +141,7 @@ def resize_and_move(
if not addr: if not addr:
return return
cmds: list[str] = [] cmds: list[str] = []
if rules_on: if rules_on and not animate:
cmds.append(f"dispatch setprop address:{addr} no_anim 1") cmds.append(f"dispatch setprop address:{addr} no_anim 1")
if aspect_on: if aspect_on:
cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 0") cmds.append(f"dispatch setprop address:{addr} keep_aspect_ratio 0")
@ -171,8 +172,74 @@ def _dispatch_batch(cmds: list[str]) -> None:
pass pass
def get_monitor_available_rect(monitor_id: int | None = None) -> tuple[int, int, int, int] | None:
"""Return (x, y, w, h) of a monitor's usable area, accounting for
exclusive zones (Waybar, etc.) via the ``reserved`` field.
Falls back to the first monitor if *monitor_id* is None or not found.
Returns None if not on Hyprland or the query fails.
"""
if not _on_hyprland():
return None
try:
result = subprocess.run(
["hyprctl", "monitors", "-j"],
capture_output=True, text=True, timeout=1,
)
monitors = json.loads(result.stdout)
if not monitors:
return None
mon = None
if monitor_id is not None:
mon = next((m for m in monitors if m.get("id") == monitor_id), None)
if mon is None:
mon = monitors[0]
mx = mon.get("x", 0)
my = mon.get("y", 0)
mw = mon.get("width", 0)
mh = mon.get("height", 0)
# reserved: [left, top, right, bottom]
res = mon.get("reserved", [0, 0, 0, 0])
left, top, right, bottom = res[0], res[1], res[2], res[3]
return (
mx + left,
my + top,
mw - left - right,
mh - top - bottom,
)
except Exception:
return None
def settiled(window_title: str) -> None:
"""Ask Hyprland to un-float the popout, restoring it to tiled layout.
Used on reopen when the popout was tiled at close the windowrule
opens it floating, so we dispatch `settiled` to push it back into
the layout.
Gated by BOORU_VIEWER_NO_HYPR_RULES so ricers with their own rules
keep control.
"""
if not _on_hyprland():
return
if not hypr_rules_enabled():
return
win = get_window(window_title)
if not win:
return
addr = win.get("address")
if not addr:
return
if not win.get("floating"):
return
_dispatch_batch([f"dispatch settiled address:{addr}"])
__all__ = [ __all__ = [
"get_window", "get_window",
"get_monitor_available_rect",
"resize", "resize",
"resize_and_move", "resize_and_move",
"settiled",
] ]

View File

@ -16,12 +16,6 @@ becomes the forcing function that keeps this module pure.
The architecture, state diagram, invarianttransition mapping, and The architecture, state diagram, invarianttransition mapping, and
event/effect lists are documented in `docs/POPOUT_ARCHITECTURE.md`. event/effect lists are documented in `docs/POPOUT_ARCHITECTURE.md`.
This module's job is to be the executable form of that document. This module's job is to be the executable form of that document.
This is the **commit 2 skeleton**: every state, every event type, every
effect type, and the `StateMachine` class with all fields initialized.
The `dispatch` method routes events to per-event handlers that all
currently return empty effect lists. Real transitions land in
commits 4-11 of `docs/POPOUT_REFACTOR_PLAN.md`.
""" """
from __future__ import annotations from __future__ import annotations
@ -423,10 +417,6 @@ class StateMachine:
The state machine never imports Qt or mpv. It never calls into the The state machine never imports Qt or mpv. It never calls into the
adapter. The communication is one-directional: events in, effects adapter. The communication is one-directional: events in, effects
out. out.
**This is the commit 2 skeleton**: all state fields are initialized,
`dispatch` is wired but every transition handler is a stub that
returns an empty effect list. Real transitions land in commits 4-11.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@ -511,14 +501,7 @@ class StateMachine:
# and reads back the returned effects + the post-dispatch state. # and reads back the returned effects + the post-dispatch state.
def dispatch(self, event: Event) -> list[Effect]: def dispatch(self, event: Event) -> list[Effect]:
"""Process one event and return the effect list. """Process one event and return the effect list."""
**Skeleton (commit 2):** every event handler currently returns
an empty effect list. Real transitions land in commits 4-11.
Tests written in commit 3 will document what each transition
is supposed to do; they fail at this point and progressively
pass as the transitions land.
"""
# Closing is terminal — drop everything once we're done. # Closing is terminal — drop everything once we're done.
if self.state == State.CLOSING: if self.state == State.CLOSING:
return [] return []
@ -577,13 +560,13 @@ class StateMachine:
case CloseRequested(): case CloseRequested():
return self._on_close_requested(event) return self._on_close_requested(event)
case _: case _:
# Unknown event type. Returning [] keeps the skeleton # Unknown event type — defensive fall-through. The
# safe; the illegal-transition handler in commit 11 # legality check above is the real gate; in release
# will replace this with the env-gated raise. # mode illegal events log and drop, strict mode raises.
return [] return []
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Per-event stub handlers (commit 2 — all return []) # Per-event handlers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _on_open(self, event: Open) -> list[Effect]: def _on_open(self, event: Open) -> list[Effect]:
@ -594,8 +577,7 @@ class StateMachine:
on the state machine instance for the first ContentArrived on the state machine instance for the first ContentArrived
handler to consume. After Open the machine is still in handler to consume. After Open the machine is still in
AwaitingContent the actual viewport seeding from saved_geo AwaitingContent the actual viewport seeding from saved_geo
happens inside the first ContentArrived (commit 8 wires the happens inside the first ContentArrived.
actual viewport math; this commit just stashes the inputs).
No effects: the popout window is already constructed and No effects: the popout window is already constructed and
showing. The first content load triggers the first fit. showing. The first content load triggers the first fit.
@ -610,12 +592,11 @@ class StateMachine:
Snapshot the content into `current_*` fields regardless of Snapshot the content into `current_*` fields regardless of
kind so the rest of the state machine can read them. Then kind so the rest of the state machine can read them. Then
transition to LoadingVideo (video) or DisplayingImage (image, transition to LoadingVideo (video) or DisplayingImage (image)
commit 10) and emit the appropriate load + fit effects. and emit the appropriate load + fit effects.
The first-content-load one-shot consumes `saved_geo` to seed The first-content-load one-shot consumes `saved_geo` to seed
the viewport before the first fit (commit 8 wires the actual the viewport before the first fit. Every ContentArrived flips
seeding). After this commit, every ContentArrived flips
`is_first_content_load` to False the saved_geo path runs at `is_first_content_load` to False the saved_geo path runs at
most once per popout open. most once per popout open.
""" """

View File

@ -8,19 +8,45 @@ from typing import NamedTuple
class Viewport(NamedTuple): class Viewport(NamedTuple):
"""Where and how large the user wants popout content to appear. """Where and how large the user wants popout content to appear.
Three numbers, no aspect. Aspect is a property of the currently- Three numbers + an anchor mode, no aspect. Aspect is a property of
displayed post and is recomputed from actual content on every the currently-displayed post and is recomputed from actual content
navigation. The viewport stays put across navigations; the window on every navigation. The viewport stays put across navigations; the
rect is a derived projection (Viewport, content_aspect) (x,y,w,h). window rect is a derived projection (Viewport, content_aspect)
(x,y,w,h).
`long_side` is the binding edge length: for landscape it becomes `long_side` is the binding edge length: for landscape it becomes
width, for portrait it becomes height. Symmetric across the two width, for portrait it becomes height. Symmetric across the two
orientations, which is the property that breaks the orientations, which is the property that breaks the
width-anchor ratchet that the previous `_fit_to_content` had. width-anchor ratchet that the previous `_fit_to_content` had.
`anchor` controls which point of the window stays fixed across
navigations as the window size changes with aspect ratio:
``"center"`` (default) pins the window center; ``"tl"``/``"tr"``/
``"bl"``/``"br"`` pin the corresponding corner. The window
grows/shrinks away from the anchored corner. The user can drag the
window anywhere the anchor only affects resize direction, not
screen position.
`center_x`/`center_y` hold the anchor point coordinates (center
of the window in center mode, the pinned corner in corner modes).
""" """
center_x: float center_x: float
center_y: float center_y: float
long_side: float long_side: float
anchor: str = "center"
def anchor_point(x: float, y: float, w: float, h: float, anchor: str) -> tuple[float, float]:
"""Extract the anchor point from a window rect based on anchor mode."""
if anchor == "tl":
return (x, y)
if anchor == "tr":
return (x + w, y)
if anchor == "bl":
return (x, y + h)
if anchor == "br":
return (x + w, y + h)
return (x + w / 2, y + h / 2)
# Maximum drift between our last-dispatched window rect and the current # Maximum drift between our last-dispatched window rect and the current

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Qt, QRect, QTimer, Signal from PySide6.QtCore import Qt, QEventLoop, QRect, QTimer, Signal
from PySide6.QtGui import QPixmap from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QHBoxLayout, QInputDialog, QLabel, QMainWindow, QMenu, QPushButton, QHBoxLayout, QInputDialog, QLabel, QMainWindow, QMenu, QPushButton,
@ -54,7 +54,7 @@ from .state import (
WindowMoved, WindowMoved,
WindowResized, WindowResized,
) )
from .viewport import Viewport, _DRIFT_TOLERANCE from .viewport import Viewport, _DRIFT_TOLERANCE, anchor_point
# Adapter logger — separate from the popout's main `booru` logger so # Adapter logger — separate from the popout's main `booru` logger so
@ -68,9 +68,8 @@ from .viewport import Viewport, _DRIFT_TOLERANCE
# the dispatch trace to the Ctrl+L log panel — useful but invisible # the dispatch trace to the Ctrl+L log panel — useful but invisible
# from the shell. We additionally attach a stderr StreamHandler to # from the shell. We additionally attach a stderr StreamHandler to
# the adapter logger so `python -m booru_viewer.main_gui 2>&1 | # the adapter logger so `python -m booru_viewer.main_gui 2>&1 |
# grep POPOUT_FSM` works during the commit-14a verification gate. # grep POPOUT_FSM` works from the terminal. The handler is tagged
# The handler is tagged with a sentinel attribute so re-imports # with a sentinel attribute so re-imports don't stack duplicates.
# don't stack duplicates.
import sys as _sys import sys as _sys
_fsm_log = logging.getLogger("booru.popout.adapter") _fsm_log = logging.getLogger("booru.popout.adapter")
_fsm_log.setLevel(logging.DEBUG) _fsm_log.setLevel(logging.DEBUG)
@ -113,50 +112,53 @@ class FullscreenPreview(QMainWindow):
# Unfiled (root of saved_dir). # Unfiled (root of saved_dir).
save_to_folder = Signal(str) save_to_folder = Signal(str)
unsave_requested = Signal() unsave_requested = Signal()
toggle_save_requested = Signal()
blacklist_tag_requested = Signal(str) # tag name blacklist_tag_requested = Signal(str) # tag name
blacklist_post_requested = Signal() blacklist_post_requested = Signal()
open_in_default = Signal()
open_in_browser = Signal()
privacy_requested = Signal() privacy_requested = Signal()
closed = Signal() closed = Signal()
def __init__(self, grid_cols: int = 3, show_actions: bool = True, monitor: str = "", parent=None) -> None: def __init__(self, grid_cols: int = 3, show_actions: bool = True, monitor: str = "", anchor: str = "center", parent=None) -> None:
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("booru-viewer — Popout") self.setWindowTitle("booru-viewer — Popout")
self._grid_cols = grid_cols self._grid_cols = grid_cols
self._anchor = anchor
# Central widget — media fills the entire window # Central widget — media fills the entire window
central = QWidget() central = QWidget()
central.setLayout(QVBoxLayout()) central.setLayout(QVBoxLayout())
central.layout().setContentsMargins(0, 0, 0, 0) central.layout().setContentsMargins(0, 0, 0, 0)
central.layout().setSpacing(0) central.layout().setSpacing(0)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_context_menu)
# Media stack (fills entire window) # Media stack (fills entire window)
self._stack = QStackedWidget() self._stack = QStackedWidget()
central.layout().addWidget(self._stack) central.layout().addWidget(self._stack)
self._vol_scroll_accum = 0
self._viewer = ImageViewer() self._viewer = ImageViewer()
self._viewer.close_requested.connect(self.close) self._viewer.close_requested.connect(self.close)
self._stack.addWidget(self._viewer) self._stack.addWidget(self._viewer)
self._video = VideoPlayer() self._video = VideoPlayer()
# Note: two legacy VideoPlayer signal connections removed in # Two legacy VideoPlayer forwarding connections were removed
# commits 14b and 16: # during the state machine extraction — don't reintroduce:
# #
# - `self._video.play_next.connect(self.play_next_requested)` # - `self._video.play_next.connect(self.play_next_requested)`:
# (removed in 14b): the EmitPlayNextRequested effect now # the EmitPlayNextRequested effect emits play_next_requested
# emits play_next_requested via the state machine dispatch # via the state machine dispatch path. Keeping the forward
# path. Keeping the forwarding would double-emit the signal # would double-emit on every video EOF in Loop=Next mode.
# and cause main_window to navigate twice on every video
# EOF in Loop=Next mode.
# #
# - `self._video.video_size.connect(self._on_video_size)` # - `self._video.video_size.connect(self._on_video_size)`:
# (removed in 16): the dispatch path's VideoSizeKnown # the dispatch path's VideoSizeKnown handler produces
# handler emits FitWindowToContent which the apply path # FitWindowToContent which the apply path delegates to
# delegates to _fit_to_content. The legacy direct call to # _fit_to_content. The direct forwarding was a parallel
# _on_video_size → _fit_to_content was a parallel duplicate # duplicate that same-rect-skip in _fit_to_content masked
# that the same-rect skip in _fit_to_content made harmless, # but that muddied the dispatch trace.
# but it muddied the trace. The dispatch lambda below is
# wired in the same __init__ block (post state machine
# construction) and is now the sole path.
self._stack.addWidget(self._video) self._stack.addWidget(self._video)
self.setCentralWidget(central) self.setCentralWidget(central)
@ -176,10 +178,6 @@ class FullscreenPreview(QMainWindow):
toolbar.setContentsMargins(8, 4, 8, 4) toolbar.setContentsMargins(8, 4, 8, 4)
# Same compact-padding override as the embedded preview toolbar — # Same compact-padding override as the embedded preview toolbar —
# bundled themes' default `padding: 5px 12px` is too wide for these
# short labels in narrow fixed slots.
_tb_btn_style = "padding: 2px 6px;"
# Bookmark folders for the popout's Bookmark-as submenu — wired # Bookmark folders for the popout's Bookmark-as submenu — wired
# by app.py via set_bookmark_folders_callback after construction. # by app.py via set_bookmark_folders_callback after construction.
self._bookmark_folders_callback = None self._bookmark_folders_callback = None
@ -190,30 +188,29 @@ class FullscreenPreview(QMainWindow):
# are independent name spaces and need separate callbacks. # are independent name spaces and need separate callbacks.
self._folders_callback = None self._folders_callback = None
self._bookmark_btn = QPushButton("Bookmark") _tb_sz = 24
self._bookmark_btn.setMaximumWidth(90)
self._bookmark_btn.setStyleSheet(_tb_btn_style) def _icon_btn(text: str, name: str, tip: str) -> QPushButton:
btn = QPushButton(text)
btn.setObjectName(name)
btn.setFixedSize(_tb_sz, _tb_sz)
btn.setToolTip(tip)
return btn
self._bookmark_btn = _icon_btn("\u2606", "_tb_bookmark", "Bookmark (B)")
self._bookmark_btn.clicked.connect(self._on_bookmark_clicked) self._bookmark_btn.clicked.connect(self._on_bookmark_clicked)
toolbar.addWidget(self._bookmark_btn) toolbar.addWidget(self._bookmark_btn)
self._save_btn = QPushButton("Save") self._save_btn = _icon_btn("\u2193", "_tb_save", "Save to library (S)")
self._save_btn.setMaximumWidth(70)
self._save_btn.setStyleSheet(_tb_btn_style)
self._save_btn.clicked.connect(self._on_save_clicked) self._save_btn.clicked.connect(self._on_save_clicked)
toolbar.addWidget(self._save_btn) toolbar.addWidget(self._save_btn)
self._is_saved = False self._is_saved = False
self._bl_tag_btn = QPushButton("BL Tag") self._bl_tag_btn = _icon_btn("\u2298", "_tb_bl_tag", "Blacklist a tag")
self._bl_tag_btn.setMaximumWidth(65)
self._bl_tag_btn.setStyleSheet(_tb_btn_style)
self._bl_tag_btn.setToolTip("Blacklist a tag")
self._bl_tag_btn.clicked.connect(self._show_bl_tag_menu) self._bl_tag_btn.clicked.connect(self._show_bl_tag_menu)
toolbar.addWidget(self._bl_tag_btn) toolbar.addWidget(self._bl_tag_btn)
self._bl_post_btn = QPushButton("BL Post") self._bl_post_btn = _icon_btn("\u2297", "_tb_bl_post", "Blacklist this post")
self._bl_post_btn.setMaximumWidth(70)
self._bl_post_btn.setStyleSheet(_tb_btn_style)
self._bl_post_btn.setToolTip("Blacklist this post")
self._bl_post_btn.clicked.connect(self.blacklist_post_requested) self._bl_post_btn.clicked.connect(self.blacklist_post_requested)
toolbar.addWidget(self._bl_post_btn) toolbar.addWidget(self._bl_post_btn)
@ -284,7 +281,9 @@ class FullscreenPreview(QMainWindow):
self._stack.setMouseTracking(True) self._stack.setMouseTracking(True)
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
QApplication.instance().installEventFilter(self) app = QApplication.instance()
if app is not None:
app.installEventFilter(self)
# Pick target monitor # Pick target monitor
target_screen = None target_screen = None
if monitor and monitor != "Same as app": if monitor and monitor != "Same as app":
@ -330,13 +329,31 @@ class FullscreenPreview(QMainWindow):
# Qt fallback path) skip viewport updates triggered by our own # Qt fallback path) skip viewport updates triggered by our own
# programmatic geometry changes. # programmatic geometry changes.
self._applying_dispatch: bool = False self._applying_dispatch: bool = False
# Stashed content dims from the tiled early-return in
# _fit_to_content. When the user un-tiles the window, resizeEvent
# fires — the debounce timer re-runs _fit_to_content with these
# dims so the floating window gets the correct aspect ratio.
self._tiled_pending_content: tuple[int, int] | None = None
self._untile_refit_timer = QTimer(self)
self._untile_refit_timer.setSingleShot(True)
self._untile_refit_timer.setInterval(50)
self._untile_refit_timer.timeout.connect(self._check_untile_refit)
# Last known windowed geometry — captured on entering fullscreen so # Last known windowed geometry — captured on entering fullscreen so
# F11 → windowed can land back on the same spot. Seeded from saved # F11 → windowed can land back on the same spot. Seeded from saved
# geometry when the popout opens windowed, so even an immediate # geometry when the popout opens windowed, so even an immediate
# F11 → fullscreen → F11 has a sensible target. # F11 → fullscreen → F11 has a sensible target.
self._windowed_geometry = None self._windowed_geometry = None
# Restore saved state or start fullscreen # Restore saved state or start fullscreen
if FullscreenPreview._saved_geometry and not FullscreenPreview._saved_fullscreen: if FullscreenPreview._saved_tiled and not FullscreenPreview._saved_fullscreen:
# Was tiled at last close — let Hyprland's layout place it,
# then dispatch `settiled` to override the windowrule's float.
# Saved geometry is meaningless for a tiled window, so skip
# setGeometry entirely.
self.show()
QTimer.singleShot(
50, lambda: hyprland.settiled(self.windowTitle())
)
elif FullscreenPreview._saved_geometry and not FullscreenPreview._saved_fullscreen:
self.setGeometry(FullscreenPreview._saved_geometry) self.setGeometry(FullscreenPreview._saved_geometry)
self._pending_position_restore = ( self._pending_position_restore = (
FullscreenPreview._saved_geometry.x(), FullscreenPreview._saved_geometry.x(),
@ -351,17 +368,15 @@ class FullscreenPreview(QMainWindow):
else: else:
self.showFullScreen() self.showFullScreen()
# ---- State machine adapter wiring (commit 14a) ---- # ---- State machine adapter wiring ----
# Construct the pure-Python state machine and dispatch the # Construct the pure-Python state machine and dispatch the
# initial Open event with the cross-popout-session class state # initial Open event with the cross-popout-session class state
# the legacy code stashed above. The state machine runs in # the legacy code stashed above. Every Qt event handler, mpv
# PARALLEL with the legacy imperative code: every Qt event # signal, and button click below dispatches a state machine
# handler / mpv signal / button click below dispatches a state # event via `_dispatch_and_apply`, which applies the returned
# machine event AND continues to run the existing imperative # effects to widgets. The state machine is the authority for
# action. The state machine's returned effects are LOGGED at # "what to do next"; the imperative helpers below are the
# DEBUG, not applied to widgets. The legacy path stays # implementation the apply path delegates into.
# authoritative through commit 14a; commit 14b switches the
# authority to the dispatch path.
# #
# The grid_cols field is used by the keyboard nav handlers # The grid_cols field is used by the keyboard nav handlers
# for the Up/Down ±cols stride. # for the Up/Down ±cols stride.
@ -380,20 +395,17 @@ class FullscreenPreview(QMainWindow):
monitor=monitor, monitor=monitor,
)) ))
# Wire VideoPlayer's playback_restart Signal (added in commit 1) # Wire VideoPlayer's playback_restart Signal to the adapter's
# to the adapter's dispatch routing. mpv emits playback-restart # dispatch routing. mpv emits playback-restart once after each
# once after each loadfile and once after each completed seek; # loadfile and once after each completed seek; the adapter
# the adapter distinguishes by checking the state machine's # distinguishes by checking the state machine's current state
# current state at dispatch time. # at dispatch time.
self._video.playback_restart.connect(self._on_video_playback_restart) self._video.playback_restart.connect(self._on_video_playback_restart)
# Wire VideoPlayer signals to dispatch+apply via the # Wire VideoPlayer signals to dispatch+apply via the
# _dispatch_and_apply helper. NOTE: every lambda below MUST # _dispatch_and_apply helper. Every lambda below MUST call
# call _dispatch_and_apply, not _fsm_dispatch directly. Calling # _dispatch_and_apply, not _fsm_dispatch directly — see the
# _fsm_dispatch alone produces effects that never reach # docstring on _dispatch_and_apply for the historical bug that
# widgets — the bug that landed in commit 14b and broke # explains the distinction.
# video auto-fit (FitWindowToContent never applied) and
# Loop=Next play_next (EmitPlayNextRequested never applied)
# until the lambdas were fixed in this commit.
self._video.play_next.connect( self._video.play_next.connect(
lambda: self._dispatch_and_apply(VideoEofReached()) lambda: self._dispatch_and_apply(VideoEofReached())
) )
@ -442,8 +454,8 @@ class FullscreenPreview(QMainWindow):
Adapter-internal helper. Centralizes the dispatch + log path Adapter-internal helper. Centralizes the dispatch + log path
so every wire-point is one line. Returns the effect list for so every wire-point is one line. Returns the effect list for
callers that want to inspect it (commit 14a doesn't use the callers that want to inspect it; prefer `_dispatch_and_apply`
return value; commit 14b will pattern-match and apply). at wire-points so the apply step can't be forgotten.
The hasattr guard handles edge cases where Qt events might The hasattr guard handles edge cases where Qt events might
fire during __init__ (e.g. resizeEvent on the first show()) fire during __init__ (e.g. resizeEvent on the first show())
@ -465,10 +477,10 @@ class FullscreenPreview(QMainWindow):
return effects return effects
def _on_video_playback_restart(self) -> None: def _on_video_playback_restart(self) -> None:
"""mpv `playback-restart` event arrived (via VideoPlayer's """mpv `playback-restart` event arrived via VideoPlayer's
playback_restart Signal added in commit 1). Distinguish playback_restart Signal. Distinguish VideoStarted (after load)
VideoStarted (after load) from SeekCompleted (after seek) by from SeekCompleted (after seek) by the state machine's current
the state machine's current state. state.
This is the ONE place the adapter peeks at state to choose an This is the ONE place the adapter peeks at state to choose an
event type it's a read, not a write, and it's the price of event type it's a read, not a write, and it's the price of
@ -485,42 +497,35 @@ class FullscreenPreview(QMainWindow):
# round trip. # round trip.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Commit 14b — effect application # Effect application
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# #
# The state machine's dispatch returns a list of Effect descriptors # The state machine's dispatch returns a list of Effect descriptors
# describing what the adapter should do. `_apply_effects` is the # describing what the adapter should do. `_apply_effects` is the
# single dispatch point: every wire-point that calls `_fsm_dispatch` # single dispatch point: `_dispatch_and_apply` dispatches then calls
# follows it with `_apply_effects(effects)`. The pattern-match by # this. The pattern-match by type is the architectural choke point
# type is the architectural choke point — if a new effect type is # — a new Effect type in state.py triggers the TypeError branch at
# added in state.py, the type-check below catches the missing # runtime instead of silently dropping the effect.
# handler at runtime instead of silently dropping.
# #
# Several apply handlers are deliberate no-ops in commit 14b: # A few apply handlers are intentional no-ops:
# #
# - ApplyMute / ApplyVolume / ApplyLoopMode: the legacy slot # - ApplyMute / ApplyVolume / ApplyLoopMode: the legacy slot
# connections on the popout's VideoPlayer are still active and # connections on the popout's VideoPlayer handle the user-facing
# handle the user-facing toggles directly. The state machine # toggles directly. The state machine tracks these values as the
# tracks these values for the upcoming SyncFromEmbedded path # source of truth for sync with the embedded preview; pushing
# (future commit) but doesn't push them to widgets — pushing # them back here would create a double-write hazard.
# would create a sync hazard with the embedded preview's mute
# state, which main_window pushes via direct attribute writes.
# #
# - SeekVideoTo: the legacy `_ClickSeekSlider.clicked_position → # - SeekVideoTo: `_ClickSeekSlider.clicked_position → _seek` on the
# VideoPlayer._seek` connection still handles both the mpv.seek # VideoPlayer handles both the mpv.seek call and the legacy
# call and the legacy 500ms `_seek_pending_until` pin window. # 500ms pin window. The state machine's SeekingVideo state
# The state machine's SeekingVideo state tracks the seek for # tracks the seek; the slider rendering and the seek call itself
# future authority, but the slider rendering and the seek call # live on VideoPlayer.
# itself stay legacy. Replacing this requires either modifying
# VideoPlayer's _poll loop (forbidden by the no-touch rule) or
# building a custom poll loop in the adapter.
# #
# The other effect types (LoadImage, LoadVideo, StopMedia, # Every other effect (LoadImage, LoadVideo, StopMedia,
# FitWindowToContent, EnterFullscreen, ExitFullscreen, # FitWindowToContent, EnterFullscreen, ExitFullscreen,
# EmitNavigate, EmitPlayNextRequested, EmitClosed, TogglePlay) # EmitNavigate, EmitPlayNextRequested, EmitClosed, TogglePlay)
# delegate to existing private helpers in this file. The state # delegates to a private helper in this file. The state machine
# machine becomes the official entry point for these operations; # is the entry point; the helpers are the implementation.
# the helpers stay in place as the implementation.
def _apply_effects(self, effects: list) -> None: def _apply_effects(self, effects: list) -> None:
"""Apply a list of Effect descriptors returned by dispatch. """Apply a list of Effect descriptors returned by dispatch.
@ -537,18 +542,19 @@ class FullscreenPreview(QMainWindow):
elif isinstance(e, StopMedia): elif isinstance(e, StopMedia):
self._apply_stop_media() self._apply_stop_media()
elif isinstance(e, ApplyMute): elif isinstance(e, ApplyMute):
# No-op in 14b — legacy slot handles widget update. # No-op — VideoPlayer's legacy slot owns widget update;
# State machine tracks state.mute for future authority. # the state machine keeps state.mute as the sync source
# for the embedded-preview path.
pass pass
elif isinstance(e, ApplyVolume): elif isinstance(e, ApplyVolume):
pass # same — no-op in 14b pass # same — widget update handled by VideoPlayer
elif isinstance(e, ApplyLoopMode): elif isinstance(e, ApplyLoopMode):
pass # same — no-op in 14b pass # same — widget update handled by VideoPlayer
elif isinstance(e, SeekVideoTo): elif isinstance(e, SeekVideoTo):
# No-op in 14b legacy `_seek` slot handles both # No-op — `_seek` slot on VideoPlayer handles both
# mpv.seek (now exact) and the pin window. Replacing # mpv.seek and the pin window. The state's SeekingVideo
# this requires touching VideoPlayer._poll which is # fields exist so the slider's read-path still returns
# out of scope. # the clicked position during the seek.
pass pass
elif isinstance(e, TogglePlay): elif isinstance(e, TogglePlay):
self._video._toggle_play() self._video._toggle_play()
@ -614,6 +620,7 @@ class FullscreenPreview(QMainWindow):
_saved_geometry = None # remembers window size/position across opens _saved_geometry = None # remembers window size/position across opens
_saved_fullscreen = False _saved_fullscreen = False
_saved_tiled = False # True if Hyprland had it tiled at last close
_current_tags: dict[str, list[str]] = {} _current_tags: dict[str, list[str]] = {}
_current_tag_list: list[str] = [] _current_tag_list: list[str] = []
@ -621,6 +628,25 @@ class FullscreenPreview(QMainWindow):
self._current_tags = tag_categories self._current_tags = tag_categories
self._current_tag_list = tag_list self._current_tag_list = tag_list
def _exec_menu_at_button(self, menu: QMenu, btn: QPushButton):
"""Open a menu anchored below a button, blocking until dismissed.
Uses popup() + QEventLoop instead of exec(pos) because on
Hyprland/Wayland the popout window gets moved via hyprctl after
Qt maps it, and Qt's window-position tracking stays stale. Using
exec(btn.mapToGlobal(...)) resolves to a global point on the
wrong monitor, causing the menu to flash there before the
compositor corrects it. popup() routes through the same path
but with triggered/aboutToHide signals we can block manually.
"""
result = [None]
menu.triggered.connect(lambda a: result.__setitem__(0, a))
loop = QEventLoop()
menu.aboutToHide.connect(loop.quit)
menu.popup(btn.mapToGlobal(btn.rect().bottomLeft()))
loop.exec()
return result[0]
def _show_bl_tag_menu(self) -> None: def _show_bl_tag_menu(self) -> None:
menu = QMenu(self) menu = QMenu(self)
if self._current_tags: if self._current_tags:
@ -631,26 +657,27 @@ class FullscreenPreview(QMainWindow):
else: else:
for tag in self._current_tag_list[:30]: for tag in self._current_tag_list[:30]:
menu.addAction(tag) menu.addAction(tag)
action = menu.exec(self._bl_tag_btn.mapToGlobal(self._bl_tag_btn.rect().bottomLeft())) action = self._exec_menu_at_button(menu, self._bl_tag_btn)
if action: if action:
self.blacklist_tag_requested.emit(action.text()) self.blacklist_tag_requested.emit(action.text())
def update_state(self, bookmarked: bool, saved: bool) -> None: def update_state(self, bookmarked: bool, saved: bool) -> None:
self._is_bookmarked = bookmarked self._is_bookmarked = bookmarked
self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark") self._bookmark_btn.setText("\u2605" if bookmarked else "\u2606") # ★ / ☆
self._bookmark_btn.setMaximumWidth(90 if bookmarked else 80) self._bookmark_btn.setToolTip("Unbookmark (B)" if bookmarked else "Bookmark (B)")
self._is_saved = saved self._is_saved = saved
self._save_btn.setText("Unsave" if saved else "Save") self._save_btn.setText("\u2715" if saved else "\u2193") # ✕ / ⤓
self._save_btn.setToolTip("Unsave from library" if saved else "Save to library (S)")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public method interface (commit 15) # Public method interface
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# #
# The methods below replace direct underscore access from # The methods below are the only entry points main_window.py uses
# main_window.py. They wrap the existing private fields so # to drive the popout. They wrap the private fields so main_window
# main_window doesn't have to know about VideoPlayer / ImageViewer # doesn't have to know about VideoPlayer / ImageViewer /
# / QStackedWidget internals. The legacy private fields stay in # QStackedWidget internals. The private fields stay in place; these
# place — these are clean public wrappers, not a re-architecture. # are clean public wrappers, not a re-architecture.
def is_video_active(self) -> bool: def is_video_active(self) -> bool:
"""True if the popout is currently showing a video (vs image). """True if the popout is currently showing a video (vs image).
@ -787,6 +814,9 @@ class FullscreenPreview(QMainWindow):
try: try:
self._video._mpv.pause = True self._video._mpv.pause = True
except Exception: except Exception:
# mpv was torn down or is mid-transition between
# files; pause is best-effort so a stale instance
# rejecting the property write isn't a real failure.
pass pass
def stop_media(self) -> None: def stop_media(self) -> None:
@ -835,7 +865,7 @@ class FullscreenPreview(QMainWindow):
folder_actions[id(a)] = folder folder_actions[id(a)] = folder
menu.addSeparator() menu.addSeparator()
new_action = menu.addAction("+ New Folder...") new_action = menu.addAction("+ New Folder...")
action = menu.exec(self._save_btn.mapToGlobal(self._save_btn.rect().bottomLeft())) action = self._exec_menu_at_button(menu, self._save_btn)
if not action: if not action:
return return
if action == unfiled: if action == unfiled:
@ -867,7 +897,7 @@ class FullscreenPreview(QMainWindow):
folder_actions[id(a)] = folder folder_actions[id(a)] = folder
menu.addSeparator() menu.addSeparator()
new_action = menu.addAction("+ New Folder...") new_action = menu.addAction("+ New Folder...")
action = menu.exec(self._bookmark_btn.mapToGlobal(self._bookmark_btn.rect().bottomLeft())) action = self._exec_menu_at_button(menu, self._bookmark_btn)
if not action: if not action:
return return
if action == unfiled: if action == unfiled:
@ -879,6 +909,113 @@ class FullscreenPreview(QMainWindow):
elif id(action) in folder_actions: elif id(action) in folder_actions:
self.bookmark_to_folder.emit(folder_actions[id(action)]) self.bookmark_to_folder.emit(folder_actions[id(action)])
def _on_context_menu(self, pos) -> None:
menu = QMenu(self)
# Bookmark: unbookmark if already bookmarked, folder submenu if not
fav_action = None
bm_folder_actions = {}
bm_new_action = None
bm_unfiled = None
if self._is_bookmarked:
fav_action = menu.addAction("Unbookmark")
else:
bm_menu = menu.addMenu("Bookmark as")
bm_unfiled = bm_menu.addAction("Unfiled")
bm_menu.addSeparator()
if self._bookmark_folders_callback:
for folder in self._bookmark_folders_callback():
a = bm_menu.addAction(folder)
bm_folder_actions[id(a)] = folder
bm_menu.addSeparator()
bm_new_action = bm_menu.addAction("+ New Folder...")
save_menu = None
save_unsorted = None
save_new = None
save_folder_actions = {}
unsave_action = None
if self._is_saved:
unsave_action = menu.addAction("Unsave from Library")
else:
save_menu = menu.addMenu("Save to Library")
save_unsorted = save_menu.addAction("Unfiled")
save_menu.addSeparator()
if self._folders_callback:
for folder in self._folders_callback():
a = save_menu.addAction(folder)
save_folder_actions[id(a)] = folder
save_menu.addSeparator()
save_new = save_menu.addAction("+ New Folder...")
menu.addSeparator()
copy_action = menu.addAction("Copy File to Clipboard")
copy_url_action = menu.addAction("Copy Image URL")
open_action = menu.addAction("Open in Default App")
browser_action = menu.addAction("Open in Browser")
reset_action = None
if self._stack.currentIndex() == 0:
reset_action = menu.addAction("Reset View")
menu.addSeparator()
close_action = menu.addAction("Close Popout")
action = menu.exec(self.mapToGlobal(pos))
if not action:
return
if action == fav_action:
self.bookmark_requested.emit()
elif action == bm_unfiled:
self.bookmark_to_folder.emit("")
elif action == bm_new_action:
name, ok = QInputDialog.getText(self, "New Bookmark Folder", "Folder name:")
if ok and name.strip():
self.bookmark_to_folder.emit(name.strip())
elif id(action) in bm_folder_actions:
self.bookmark_to_folder.emit(bm_folder_actions[id(action)])
elif action == save_unsorted:
self.save_to_folder.emit("")
elif action == save_new:
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip():
self.save_to_folder.emit(name.strip())
elif id(action) in save_folder_actions:
self.save_to_folder.emit(save_folder_actions[id(action)])
elif action == unsave_action:
self.unsave_requested.emit()
elif action == copy_action:
from pathlib import Path as _Path
from PySide6.QtCore import QMimeData, QUrl
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPixmap as _QP
cp = self._state_machine.current_path
if cp and cp.startswith(("http://", "https://")):
from ...core.cache import cached_path_for
cached = cached_path_for(cp)
cp = str(cached) if cached.exists() else None
if cp and _Path(cp).exists():
mime = QMimeData()
mime.setUrls([QUrl.fromLocalFile(str(_Path(cp).resolve()))])
pix = _QP(cp)
if not pix.isNull():
mime.setImageData(pix.toImage())
QApplication.clipboard().setMimeData(mime)
elif action == copy_url_action:
from PySide6.QtWidgets import QApplication
url = self._state_machine.current_path or ""
if url:
QApplication.clipboard().setText(url)
elif action == open_action:
self.open_in_default.emit()
elif action == browser_action:
self.open_in_browser.emit()
elif action == reset_action:
self._viewer._fit_to_view()
self._viewer.update()
elif action == close_action:
self.close()
def set_media(self, path: str, info: str = "", width: int = 0, height: int = 0) -> None: def set_media(self, path: str, info: str = "", width: int = 0, height: int = 0) -> None:
"""Display `path` in the popout, info string above it. """Display `path` in the popout, info string above it.
@ -917,7 +1054,9 @@ class FullscreenPreview(QMainWindow):
from ...core.cache import _referer_for from ...core.cache import _referer_for
referer = _referer_for(urlparse(path)) referer = _referer_for(urlparse(path))
except Exception: except Exception:
pass _fsm_log.debug(
"referer derivation failed for %s", path, exc_info=True,
)
# Dispatch + apply. The state machine produces: # Dispatch + apply. The state machine produces:
# - LoadVideo or LoadImage (loads the media) # - LoadVideo or LoadImage (loads the media)
@ -958,7 +1097,8 @@ class FullscreenPreview(QMainWindow):
@staticmethod @staticmethod
def _compute_window_rect( def _compute_window_rect(
viewport: Viewport, content_aspect: float, screen viewport: Viewport, content_aspect: float, screen,
avail_override: tuple[int, int, int, int] | None = None,
) -> tuple[int, int, int, int]: ) -> tuple[int, int, int, int]:
"""Project a viewport onto a window rect for the given content aspect. """Project a viewport onto a window rect for the given content aspect.
@ -968,6 +1108,16 @@ class FullscreenPreview(QMainWindow):
if either would exceed its 0.90-of-screen ceiling, preserving if either would exceed its 0.90-of-screen ceiling, preserving
aspect exactly. Pure function no side effects, no widget aspect exactly. Pure function no side effects, no widget
access, all inputs explicit so it's trivial to reason about. access, all inputs explicit so it's trivial to reason about.
``viewport.center_x``/``center_y`` hold the anchor point the
window center in ``"center"`` mode, or the pinned corner in
corner modes. The anchor stays fixed; the window grows/shrinks
away from it.
*avail_override* is an (x, y, w, h) tuple that replaces
``screen.availableGeometry()`` used on Hyprland where Qt
doesn't see Waybar's exclusive zone but ``hyprctl monitors -j``
reports it via the ``reserved`` array.
""" """
if content_aspect >= 1.0: # landscape or square if content_aspect >= 1.0: # landscape or square
w = viewport.long_side w = viewport.long_side
@ -976,19 +1126,37 @@ class FullscreenPreview(QMainWindow):
h = viewport.long_side h = viewport.long_side
w = viewport.long_side * content_aspect w = viewport.long_side * content_aspect
avail = screen.availableGeometry() if avail_override:
cap_w = avail.width() * 0.90 ax, ay, aw, ah = avail_override
cap_h = avail.height() * 0.90 else:
_a = screen.availableGeometry()
ax, ay, aw, ah = _a.x(), _a.y(), _a.width(), _a.height()
cap_w = aw * 0.90
cap_h = ah * 0.90
scale = min(1.0, cap_w / w, cap_h / h) scale = min(1.0, cap_w / w, cap_h / h)
w *= scale w *= scale
h *= scale h *= scale
anchor = viewport.anchor
if anchor == "tl":
x = viewport.center_x
y = viewport.center_y
elif anchor == "tr":
x = viewport.center_x - w
y = viewport.center_y
elif anchor == "bl":
x = viewport.center_x
y = viewport.center_y - h
elif anchor == "br":
x = viewport.center_x - w
y = viewport.center_y - h
else:
x = viewport.center_x - w / 2 x = viewport.center_x - w / 2
y = viewport.center_y - h / 2 y = viewport.center_y - h / 2
# Nudge onto screen if the projected rect would land off-edge. # Nudge onto screen if the window would land off-edge.
x = max(avail.x(), min(x, avail.right() - w)) x = max(ax, min(x, ax + aw - w))
y = max(avail.y(), min(y, avail.bottom() - h)) y = max(ay, min(y, ay + ah - h))
return (round(x), round(y), round(w), round(h)) return (round(x), round(y), round(w), round(h))
@ -1014,18 +1182,20 @@ class FullscreenPreview(QMainWindow):
if win and win.get("at") and win.get("size"): if win and win.get("at") and win.get("size"):
wx, wy = win["at"] wx, wy = win["at"]
ww, wh = win["size"] ww, wh = win["size"]
ax, ay = anchor_point(wx, wy, ww, wh, self._anchor)
return Viewport( return Viewport(
center_x=wx + ww / 2, center_x=ax, center_y=ay,
center_y=wy + wh / 2,
long_side=float(max(ww, wh)), long_side=float(max(ww, wh)),
anchor=self._anchor,
) )
if floating is None: if floating is None:
rect = self.geometry() rect = self.geometry()
if rect.width() > 0 and rect.height() > 0: if rect.width() > 0 and rect.height() > 0:
ax, ay = anchor_point(rect.x(), rect.y(), rect.width(), rect.height(), self._anchor)
return Viewport( return Viewport(
center_x=rect.x() + rect.width() / 2, center_x=ax, center_y=ay,
center_y=rect.y() + rect.height() / 2,
long_side=float(max(rect.width(), rect.height())), long_side=float(max(rect.width(), rect.height())),
anchor=self._anchor,
) )
return None return None
@ -1066,10 +1236,11 @@ class FullscreenPreview(QMainWindow):
if self._first_fit_pending and self._pending_size and self._pending_position_restore: if self._first_fit_pending and self._pending_size and self._pending_position_restore:
pw, ph = self._pending_size pw, ph = self._pending_size
px, py = self._pending_position_restore px, py = self._pending_position_restore
ax, ay = anchor_point(px, py, pw, ph, self._anchor)
self._viewport = Viewport( self._viewport = Viewport(
center_x=px + pw / 2, center_x=ax, center_y=ay,
center_y=py + ph / 2,
long_side=float(max(pw, ph)), long_side=float(max(pw, ph)),
anchor=self._anchor,
) )
return self._viewport return self._viewport
@ -1096,10 +1267,11 @@ class FullscreenPreview(QMainWindow):
) )
if drift > _DRIFT_TOLERANCE: if drift > _DRIFT_TOLERANCE:
# External move/resize detected. Adopt current as intent. # External move/resize detected. Adopt current as intent.
ax, ay = anchor_point(cur_x, cur_y, cur_w, cur_h, self._anchor)
self._viewport = Viewport( self._viewport = Viewport(
center_x=cur_x + cur_w / 2, center_x=ax, center_y=ay,
center_y=cur_y + cur_h / 2,
long_side=float(max(cur_w, cur_h)), long_side=float(max(cur_w, cur_h)),
anchor=self._anchor,
) )
return self._viewport return self._viewport
@ -1151,8 +1323,10 @@ class FullscreenPreview(QMainWindow):
else: else:
floating = None floating = None
if floating is False: if floating is False:
hyprland.resize(self.windowTitle(), 0, 0) # tiled: just set keep_aspect_ratio hyprland.resize(self.windowTitle(), 0, 0, animate=self._first_fit_pending) # tiled: just set keep_aspect_ratio
self._tiled_pending_content = (content_w, content_h)
return return
self._tiled_pending_content = None
aspect = content_w / content_h aspect = content_w / content_h
screen = self.screen() screen = self.screen()
if screen is None: if screen is None:
@ -1164,7 +1338,10 @@ class FullscreenPreview(QMainWindow):
# the one-shots would lose the saved position; leaving them # the one-shots would lose the saved position; leaving them
# set lets a subsequent fit retry. # set lets a subsequent fit retry.
return return
x, y, w, h = self._compute_window_rect(viewport, aspect, screen) avail_rect = None
if on_hypr and win:
avail_rect = hyprland.get_monitor_available_rect(win.get("monitor"))
x, y, w, h = self._compute_window_rect(viewport, aspect, screen, avail_override=avail_rect)
# Identical-rect skip. If the computed rect is exactly what # Identical-rect skip. If the computed rect is exactly what
# we last dispatched, the window is already in that state and # we last dispatched, the window is already in that state and
# there's nothing for hyprctl (or setGeometry) to do. Skipping # there's nothing for hyprctl (or setGeometry) to do. Skipping
@ -1194,7 +1371,10 @@ class FullscreenPreview(QMainWindow):
# Hyprland: hyprctl is the sole authority. Calling self.resize() # Hyprland: hyprctl is the sole authority. Calling self.resize()
# here would race with the batch below and produce visible flashing # here would race with the batch below and produce visible flashing
# when the window also has to move. # when the window also has to move.
hyprland.resize_and_move(self.windowTitle(), w, h, x, y, win=win) hyprland.resize_and_move(
self.windowTitle(), w, h, x, y, win=win,
animate=self._first_fit_pending,
)
else: else:
# Non-Hyprland fallback: Qt drives geometry directly. Use # Non-Hyprland fallback: Qt drives geometry directly. Use
# setGeometry with the computed top-left rather than resize() # setGeometry with the computed top-left rather than resize()
@ -1214,6 +1394,18 @@ class FullscreenPreview(QMainWindow):
self._pending_position_restore = None self._pending_position_restore = None
self._pending_size = None self._pending_size = None
def _check_untile_refit(self) -> None:
"""Debounced callback: re-run fit if we left tiled under new content."""
if self._tiled_pending_content is not None:
cw, ch = self._tiled_pending_content
self._fit_to_content(cw, ch)
# Reset image zoom/offset so the image fits the new window
# geometry cleanly — the viewer's state is stale from the
# tiled layout.
if self._stack.currentIndex() == 0:
self._viewer._fit_to_view()
self._viewer.update()
def _show_overlay(self) -> None: def _show_overlay(self) -> None:
"""Show toolbar and video controls, restart auto-hide timer.""" """Show toolbar and video controls, restart auto-hide timer."""
if not self._ui_visible: if not self._ui_visible:
@ -1271,6 +1463,12 @@ class FullscreenPreview(QMainWindow):
elif key in (Qt.Key.Key_Down, Qt.Key.Key_J): elif key in (Qt.Key.Key_Down, Qt.Key.Key_J):
self._dispatch_and_apply(NavigateRequested(direction=self._grid_cols)) self._dispatch_and_apply(NavigateRequested(direction=self._grid_cols))
return True return True
elif key in (Qt.Key.Key_B, Qt.Key.Key_F):
self.bookmark_requested.emit()
return True
elif key == Qt.Key.Key_S:
self.toggle_save_requested.emit()
return True
elif key == Qt.Key.Key_F11: elif key == Qt.Key.Key_F11:
self._dispatch_and_apply(FullscreenToggled()) self._dispatch_and_apply(FullscreenToggled())
return True return True
@ -1279,11 +1477,11 @@ class FullscreenPreview(QMainWindow):
return True return True
elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1: elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1:
# +/- keys are seek-relative, NOT slider-pin seeks. The # +/- keys are seek-relative, NOT slider-pin seeks. The
# state machine's SeekRequested is for slider-driven # state machine's SeekRequested models slider-driven
# seeks. The +/- keys go straight to mpv via the # seeks (target_ms known up front); relative seeks go
# legacy path; the dispatch path doesn't see them in # straight to mpv. If we ever want the dispatch path to
# 14a (commit 14b will route them through SeekRequested # own them, compute target_ms from current position and
# with a target_ms computed from current position). # route through SeekRequested.
self._video._seek_relative(1800) self._video._seek_relative(1800)
return True return True
elif key == Qt.Key.Key_Comma and self._stack.currentIndex() == 1: elif key == Qt.Key.Key_Comma and self._stack.currentIndex() == 1:
@ -1300,13 +1498,11 @@ class FullscreenPreview(QMainWindow):
return True return True
# Vertical wheel adjusts volume on the video stack only # Vertical wheel adjusts volume on the video stack only
if self._stack.currentIndex() == 1: if self._stack.currentIndex() == 1:
delta = event.angleDelta().y() self._vol_scroll_accum += event.angleDelta().y()
if delta: steps = self._vol_scroll_accum // 120
vol = max(0, min(100, self._video.volume + (5 if delta > 0 else -5))) if steps:
# Dispatch VolumeSet so state.volume tracks. The self._vol_scroll_accum -= steps * 120
# actual mpv.volume write still happens via the vol = max(0, min(100, self._video.volume + 5 * steps))
# legacy assignment below — ApplyVolume is a no-op
# in 14b (see _apply_effects docstring).
self._dispatch_and_apply(VolumeSet(value=vol)) self._dispatch_and_apply(VolumeSet(value=vol))
self._video.volume = vol self._video.volume = vol
self._show_overlay() self._show_overlay()
@ -1316,7 +1512,7 @@ class FullscreenPreview(QMainWindow):
cursor_pos = self.mapFromGlobal(event.globalPosition().toPoint() if hasattr(event, 'globalPosition') else event.globalPos()) cursor_pos = self.mapFromGlobal(event.globalPosition().toPoint() if hasattr(event, 'globalPosition') else event.globalPos())
y = cursor_pos.y() y = cursor_pos.y()
h = self.height() h = self.height()
zone = 40 # px from top/bottom edge to trigger zone = max(60, h // 10) # ~10% of window height, floor 60px
if y < zone: if y < zone:
self._toolbar.show() self._toolbar.show()
self._hide_timer.start() self._hide_timer.start()
@ -1370,19 +1566,21 @@ class FullscreenPreview(QMainWindow):
x, y = win["at"] x, y = win["at"]
w, h = win["size"] w, h = win["size"]
self._windowed_geometry = QRect(x, y, w, h) self._windowed_geometry = QRect(x, y, w, h)
ax, ay = anchor_point(x, y, w, h, self._anchor)
self._viewport = Viewport( self._viewport = Viewport(
center_x=x + w / 2, center_x=ax, center_y=ay,
center_y=y + h / 2,
long_side=float(max(w, h)), long_side=float(max(w, h)),
anchor=self._anchor,
) )
else: else:
self._windowed_geometry = self.frameGeometry() self._windowed_geometry = self.frameGeometry()
rect = self._windowed_geometry rect = self._windowed_geometry
if rect.width() > 0 and rect.height() > 0: if rect.width() > 0 and rect.height() > 0:
ax, ay = anchor_point(rect.x(), rect.y(), rect.width(), rect.height(), self._anchor)
self._viewport = Viewport( self._viewport = Viewport(
center_x=rect.x() + rect.width() / 2, center_x=ax, center_y=ay,
center_y=rect.y() + rect.height() / 2,
long_side=float(max(rect.width(), rect.height())), long_side=float(max(rect.width(), rect.height())),
anchor=self._anchor,
) )
self.showFullScreen() self.showFullScreen()
@ -1416,6 +1614,9 @@ class FullscreenPreview(QMainWindow):
if vp and vp.get('w') and vp.get('h'): if vp and vp.get('w') and vp.get('h'):
content_w, content_h = vp['w'], vp['h'] content_w, content_h = vp['w'], vp['h']
except Exception: except Exception:
# mpv is mid-shutdown or between files; leave
# content_w/h at 0 so the caller falls back to the
# saved viewport rather than a bogus fit rect.
pass pass
else: else:
pix = self._viewer._pixmap pix = self._viewer._pixmap
@ -1436,8 +1637,11 @@ class FullscreenPreview(QMainWindow):
def resizeEvent(self, event) -> None: def resizeEvent(self, event) -> None:
super().resizeEvent(event) super().resizeEvent(event)
# Position floating overlays # Position floating overlays
w = self.centralWidget().width() central = self.centralWidget()
h = self.centralWidget().height() if central is None:
return
w = central.width()
h = central.height()
tb_h = self._toolbar.sizeHint().height() tb_h = self._toolbar.sizeHint().height()
self._toolbar.setGeometry(0, 0, w, tb_h) self._toolbar.setGeometry(0, 0, w, tb_h)
ctrl_h = self._video._controls_bar.sizeHint().height() ctrl_h = self._video._controls_bar.sizeHint().height()
@ -1474,15 +1678,18 @@ class FullscreenPreview(QMainWindow):
# position source on Wayland). # position source on Wayland).
import os import os
if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
if self._tiled_pending_content is not None:
self._untile_refit_timer.start()
return return
if self._applying_dispatch or self.isFullScreen(): if self._applying_dispatch or self.isFullScreen():
return return
rect = self.geometry() rect = self.geometry()
if rect.width() > 0 and rect.height() > 0: if rect.width() > 0 and rect.height() > 0:
ax, ay = anchor_point(rect.x(), rect.y(), rect.width(), rect.height(), self._anchor)
self._viewport = Viewport( self._viewport = Viewport(
center_x=rect.x() + rect.width() / 2, center_x=ax, center_y=ay,
center_y=rect.y() + rect.height() / 2,
long_side=float(max(rect.width(), rect.height())), long_side=float(max(rect.width(), rect.height())),
anchor=self._anchor,
) )
# Parallel state machine dispatch for the same event. # Parallel state machine dispatch for the same event.
self._dispatch_and_apply(WindowResized(rect=( self._dispatch_and_apply(WindowResized(rect=(
@ -1509,11 +1716,12 @@ class FullscreenPreview(QMainWindow):
rect = self.geometry() rect = self.geometry()
if rect.width() > 0 and rect.height() > 0: if rect.width() > 0 and rect.height() > 0:
# Move-only update: keep the existing long_side, just # Move-only update: keep the existing long_side, just
# update the center to where the window now sits. # update the anchor point to where the window now sits.
ax, ay = anchor_point(rect.x(), rect.y(), rect.width(), rect.height(), self._anchor)
self._viewport = Viewport( self._viewport = Viewport(
center_x=rect.x() + rect.width() / 2, center_x=ax, center_y=ay,
center_y=rect.y() + rect.height() / 2,
long_side=self._viewport.long_side, long_side=self._viewport.long_side,
anchor=self._anchor,
) )
# Parallel state machine dispatch for the same event. # Parallel state machine dispatch for the same event.
self._dispatch_and_apply(WindowMoved(rect=( self._dispatch_and_apply(WindowMoved(rect=(
@ -1547,9 +1755,13 @@ class FullscreenPreview(QMainWindow):
# Geometry is adapter-side concern, not state machine concern, # Geometry is adapter-side concern, not state machine concern,
# so the state machine doesn't see it. # so the state machine doesn't see it.
FullscreenPreview._saved_fullscreen = self.isFullScreen() FullscreenPreview._saved_fullscreen = self.isFullScreen()
FullscreenPreview._saved_tiled = False
if not self.isFullScreen(): if not self.isFullScreen():
# On Hyprland, Qt doesn't know the real position — ask the WM # On Hyprland, Qt doesn't know the real position — ask the WM
win = hyprland.get_window(self.windowTitle()) win = hyprland.get_window(self.windowTitle())
if win and win.get("floating") is False:
# Tiled: reopen will re-tile instead of restoring geometry.
FullscreenPreview._saved_tiled = True
if win and win.get("at") and win.get("size"): if win and win.get("at") and win.get("size"):
from PySide6.QtCore import QRect from PySide6.QtCore import QRect
x, y = win["at"] x, y = win["at"]
@ -1557,7 +1769,9 @@ class FullscreenPreview(QMainWindow):
FullscreenPreview._saved_geometry = QRect(x, y, w, h) FullscreenPreview._saved_geometry = QRect(x, y, w, h)
else: else:
FullscreenPreview._saved_geometry = self.frameGeometry() FullscreenPreview._saved_geometry = self.frameGeometry()
QApplication.instance().removeEventFilter(self) app = QApplication.instance()
if app is not None:
app.removeEventFilter(self)
# Snapshot video position BEFORE StopMedia destroys it. # Snapshot video position BEFORE StopMedia destroys it.
# _on_fullscreen_closed reads this via get_video_state() to # _on_fullscreen_closed reads this via get_video_state() to
# seek the embedded preview to the same position. # seek the embedded preview to the same position.
@ -1571,4 +1785,16 @@ class FullscreenPreview(QMainWindow):
# EmitClosed emits self.closed which triggers main_window's # EmitClosed emits self.closed which triggers main_window's
# _on_fullscreen_closed handler. # _on_fullscreen_closed handler.
self._dispatch_and_apply(CloseRequested()) self._dispatch_and_apply(CloseRequested())
# Tear down the popout's mpv + GL render context explicitly.
# FullscreenPreview has no WA_DeleteOnClose and Qt's C++ dtor
# doesn't reliably call Python-side destroy() overrides once
# popout_controller drops its reference, so without this the
# popout's separate mpv instance + NVDEC surface pool leak
# until the next full Python GC cycle.
try:
self._video._gl_widget.cleanup()
except Exception:
# Close path — a cleanup failure can't be recovered from
# here. Swallowing beats letting Qt abort mid-teardown.
pass
super().closeEvent(event) super().closeEvent(event)

View File

@ -0,0 +1,212 @@
"""Popout (fullscreen preview) lifecycle, state sync, and geometry persistence."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .main_window import BooruApp
log = logging.getLogger("booru")
# -- Pure functions (tested in tests/gui/test_popout_controller.py) --
def build_video_sync_dict(
volume: int,
mute: bool,
autoplay: bool,
loop_state: int,
position_ms: int,
) -> dict:
"""Build the video-state transfer dict used on popout open/close."""
return {
"volume": volume,
"mute": mute,
"autoplay": autoplay,
"loop_state": loop_state,
"position_ms": position_ms,
}
# -- Controller --
class PopoutController:
"""Owns popout lifecycle, state sync, and geometry persistence."""
def __init__(self, app: BooruApp) -> None:
self._app = app
self._fullscreen_window = None
self._popout_active = False
self._info_was_visible = False
self._right_splitter_sizes: list[int] = []
@property
def window(self):
return self._fullscreen_window
@property
def is_active(self) -> bool:
return self._popout_active
# -- Open --
def open(self) -> None:
path = self._app._preview._current_path
if not path:
return
info = self._app._preview._info_label.text()
video_pos = 0
if self._app._preview._stack.currentIndex() == 1:
video_pos = self._app._preview._video_player.get_position_ms()
self._popout_active = True
self._info_was_visible = self._app._info_panel.isVisible()
self._right_splitter_sizes = self._app._right_splitter.sizes()
self._app._preview.clear()
self._app._preview.hide()
self._app._info_panel.show()
self._app._right_splitter.setSizes([0, 0, 1000])
self._app._preview._current_path = path
idx = self._app._grid.selected_index
if 0 <= idx < len(self._app._posts):
self._app._info_panel.set_post(self._app._posts[idx])
from .popout.window import FullscreenPreview
saved_geo = self._app._db.get_setting("slideshow_geometry")
saved_fs = self._app._db.get_setting_bool("slideshow_fullscreen")
saved_tiled = self._app._db.get_setting_bool("slideshow_tiled")
if saved_geo:
parts = saved_geo.split(",")
if len(parts) == 4:
from PySide6.QtCore import QRect
FullscreenPreview._saved_geometry = QRect(*[int(p) for p in parts])
FullscreenPreview._saved_fullscreen = saved_fs
FullscreenPreview._saved_tiled = saved_tiled
else:
FullscreenPreview._saved_geometry = None
FullscreenPreview._saved_fullscreen = True
FullscreenPreview._saved_tiled = False
else:
FullscreenPreview._saved_fullscreen = True
FullscreenPreview._saved_tiled = saved_tiled
cols = self._app._grid._flow.columns
show_actions = self._app._stack.currentIndex() != 2
monitor = self._app._db.get_setting("slideshow_monitor")
anchor = self._app._db.get_setting("popout_anchor") or "center"
self._fullscreen_window = FullscreenPreview(grid_cols=cols, show_actions=show_actions, monitor=monitor, anchor=anchor, parent=self._app)
self._fullscreen_window.navigate.connect(self.navigate)
self._fullscreen_window.play_next_requested.connect(self._app._on_video_end_next)
from ..core.config import library_folders
self._fullscreen_window.set_folders_callback(library_folders)
self._fullscreen_window.save_to_folder.connect(self._app._post_actions.save_from_preview)
self._fullscreen_window.unsave_requested.connect(self._app._post_actions.unsave_from_preview)
self._fullscreen_window.toggle_save_requested.connect(self._app._post_actions.toggle_save_from_preview)
if show_actions:
self._fullscreen_window.bookmark_requested.connect(self._app._post_actions.bookmark_from_preview)
self._fullscreen_window.set_bookmark_folders_callback(self._app._db.get_folders)
self._fullscreen_window.bookmark_to_folder.connect(self._app._post_actions.bookmark_to_folder_from_preview)
self._fullscreen_window.blacklist_tag_requested.connect(self._app._post_actions.blacklist_tag_from_popout)
self._fullscreen_window.blacklist_post_requested.connect(self._app._post_actions.blacklist_post_from_popout)
self._fullscreen_window.open_in_default.connect(self._app._open_preview_in_default)
self._fullscreen_window.open_in_browser.connect(self._app._open_preview_in_browser)
self._fullscreen_window.closed.connect(self.on_closed)
self._fullscreen_window.privacy_requested.connect(self._app._privacy.toggle)
post = self._app._preview._current_post
if post:
self._fullscreen_window.set_post_tags(post.tag_categories, post.tag_list)
pv = self._app._preview._video_player
self._fullscreen_window.sync_video_state(
volume=pv.volume,
mute=pv.is_muted,
autoplay=pv.autoplay,
loop_state=pv.loop_state,
)
if video_pos > 0:
self._fullscreen_window.connect_media_ready_once(
lambda: self._fullscreen_window.seek_video_to(video_pos)
)
pre_w = post.width if post else 0
pre_h = post.height if post else 0
self._fullscreen_window.set_media(path, info, width=pre_w, height=pre_h)
self.update_state()
# -- Close --
def on_closed(self) -> None:
if self._fullscreen_window:
from .popout.window import FullscreenPreview
fs = FullscreenPreview._saved_fullscreen
geo = FullscreenPreview._saved_geometry
tiled = FullscreenPreview._saved_tiled
self._app._db.set_setting("slideshow_fullscreen", "1" if fs else "0")
self._app._db.set_setting("slideshow_tiled", "1" if tiled else "0")
if geo:
self._app._db.set_setting("slideshow_geometry", f"{geo.x()},{geo.y()},{geo.width()},{geo.height()}")
self._app._preview.show()
if not self._info_was_visible:
self._app._info_panel.hide()
if self._right_splitter_sizes:
self._app._right_splitter.setSizes(self._right_splitter_sizes)
self._popout_active = False
video_pos = 0
if self._fullscreen_window:
vstate = self._fullscreen_window.get_video_state()
pv = self._app._preview._video_player
pv.volume = vstate["volume"]
pv.is_muted = vstate["mute"]
pv.autoplay = vstate["autoplay"]
pv.loop_state = vstate["loop_state"]
video_pos = vstate["position_ms"]
path = self._app._preview._current_path
info = self._app._preview._info_label.text()
self._fullscreen_window = None
if path:
if video_pos > 0:
def _seek_preview():
self._app._preview._video_player.seek_to_ms(video_pos)
try:
self._app._preview._video_player.media_ready.disconnect(_seek_preview)
except RuntimeError:
pass
self._app._preview._video_player.media_ready.connect(_seek_preview)
self._app._preview.set_media(path, info)
# -- Navigation --
def navigate(self, direction: int) -> None:
self._app._navigate_preview(direction)
# -- State sync --
def update_media(self, path: str, info: str) -> None:
"""Sync the popout with new media from browse/bookmark/library."""
if self._fullscreen_window and self._fullscreen_window.isVisible():
self._app._preview._video_player.stop()
cp = self._app._preview._current_post
w = cp.width if cp else 0
h = cp.height if cp else 0
self._fullscreen_window.set_media(path, info, width=w, height=h)
show_full = self._app._stack.currentIndex() != 2
self._fullscreen_window.set_toolbar_visibility(
bookmark=show_full,
save=True,
bl_tag=show_full,
bl_post=show_full,
)
self.update_state()
def update_state(self) -> None:
"""Update popout button states by mirroring the embedded preview."""
if not self._fullscreen_window:
return
self._fullscreen_window.update_state(
self._app._preview._is_bookmarked,
self._app._preview._is_saved,
)
post = self._app._preview._current_post
if post is not None:
self._fullscreen_window.set_post_tags(
post.tag_categories or {}, post.tag_list
)

View File

@ -0,0 +1,606 @@
"""Bookmark, save/library, batch download, and blacklist operations."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from ..core.cache import download_image
if TYPE_CHECKING:
from .main_window import BooruApp
log = logging.getLogger("booru")
# Pure functions
def is_batch_message(msg: str) -> bool:
"""Detect batch progress messages like 'Saved 3/10 to Unfiled'."""
return "/" in msg and any(c.isdigit() for c in msg.split("/")[0][-2:])
def is_in_library(path: Path, saved_root: Path) -> bool:
return path.is_relative_to(saved_root)
class PostActionsController:
def __init__(self, app: BooruApp) -> None:
self._app = app
self._batch_dest: Path | None = None
def on_bookmark_error(self, e: str) -> None:
self._app._status.showMessage(f"Error: {e}")
def is_post_saved(self, post_id: int) -> bool:
return self._app._db.is_post_in_library(post_id)
def _maybe_unbookmark(self, post) -> None:
"""Remove the bookmark for *post* if the unbookmark-on-save setting is on.
Handles DB removal, grid thumbnail dot, preview state, bookmarks
tab refresh, and popout sync in one place so every save path
(single, bulk, Save As, batch download) can call it.
"""
if not self._app._db.get_setting_bool("unbookmark_on_save"):
return
site_id = (
self._app._preview._current_site_id
or self._app._site_combo.currentData()
)
if not site_id or not self._app._db.is_bookmarked(site_id, post.id):
return
self._app._db.remove_bookmark(site_id, post.id)
# Update grid thumbnail bookmark dot
for i, p in enumerate(self._app._posts):
if p.id == post.id and i < len(self._app._grid._thumbs):
self._app._grid._thumbs[i].set_bookmarked(False)
break
# Update preview and popout
if (self._app._preview._current_post
and self._app._preview._current_post.id == post.id):
self._app._preview.update_bookmark_state(False)
self._app._popout_ctrl.update_state()
# Refresh bookmarks tab if visible
if self._app._stack.currentIndex() == 1:
self._app._bookmarks_view.refresh()
def get_preview_post(self):
idx = self._app._grid.selected_index
if 0 <= idx < len(self._app._posts):
return self._app._posts[idx], idx
if self._app._preview._current_post:
return self._app._preview._current_post, -1
return None, -1
def bookmark_from_preview(self) -> None:
post, idx = self.get_preview_post()
if not post:
return
site_id = self._app._preview._current_site_id or self._app._site_combo.currentData()
if not site_id:
return
if idx >= 0:
self.toggle_bookmark(idx)
else:
if self._app._db.is_bookmarked(site_id, post.id):
self._app._db.remove_bookmark(site_id, post.id)
else:
from ..core.cache import cached_path_for
cached = cached_path_for(post.file_url)
self._app._db.add_bookmark(
site_id=site_id, post_id=post.id,
file_url=post.file_url, preview_url=post.preview_url or "",
tags=post.tags, rating=post.rating, score=post.score,
source=post.source, cached_path=str(cached) if cached.exists() else None,
tag_categories=post.tag_categories,
)
bookmarked = bool(self._app._db.is_bookmarked(site_id, post.id))
self._app._preview.update_bookmark_state(bookmarked)
self._app._popout_ctrl.update_state()
if self._app._stack.currentIndex() == 1:
self._app._bookmarks_view.refresh()
def bookmark_to_folder_from_preview(self, folder: str) -> None:
"""Bookmark the current preview post into a specific bookmark folder.
Triggered by the toolbar Bookmark-as submenu, which only shows
when the post is not yet bookmarked -- so this method only handles
the create path, never the move/remove paths. Empty string means
Unfiled. Brand-new folder names get added to the DB folder list
first so the bookmarks tab combo immediately shows them.
"""
post, idx = self.get_preview_post()
if not post:
return
site_id = self._app._preview._current_site_id or self._app._site_combo.currentData()
if not site_id:
return
target = folder if folder else None
if target and target not in self._app._db.get_folders():
try:
self._app._db.add_folder(target)
except ValueError as e:
self._app._status.showMessage(f"Invalid folder name: {e}")
return
if idx >= 0:
# In the grid -- go through toggle_bookmark so the grid
# thumbnail's bookmark badge updates via on_bookmark_done.
self.toggle_bookmark(idx, target)
else:
# Preview-only post (e.g. opened from the bookmarks tab while
# browse is empty). Inline the add -- no grid index to update.
from ..core.cache import cached_path_for
cached = cached_path_for(post.file_url)
self._app._db.add_bookmark(
site_id=site_id, post_id=post.id,
file_url=post.file_url, preview_url=post.preview_url or "",
tags=post.tags, rating=post.rating, score=post.score,
source=post.source,
cached_path=str(cached) if cached.exists() else None,
folder=target,
tag_categories=post.tag_categories,
)
where = target or "Unfiled"
self._app._status.showMessage(f"Bookmarked #{post.id} to {where}")
self._app._preview.update_bookmark_state(True)
self._app._popout_ctrl.update_state()
# Refresh bookmarks tab if visible so the new entry appears.
if self._app._stack.currentIndex() == 1:
self._app._bookmarks_view.refresh()
def save_from_preview(self, folder: str) -> None:
post, idx = self.get_preview_post()
if post:
target = folder if folder else None
self.save_to_library(post, target)
def toggle_save_from_preview(self) -> None:
"""Toggle library save: unsave if already saved, save to Unfiled otherwise."""
post, _ = self.get_preview_post()
if not post:
return
if self.is_post_saved(post.id):
self.unsave_from_preview()
else:
self.save_from_preview("")
def unsave_from_preview(self) -> None:
post, idx = self.get_preview_post()
if not post:
return
# delete_from_library walks every library folder by post id and
# deletes every match in one call -- no folder hint needed. Pass
# db so templated filenames also get unlinked AND the meta row
# gets cleaned up.
from ..core.cache import delete_from_library
deleted = delete_from_library(post.id, db=self._app._db)
if deleted:
self._app._status.showMessage(f"Removed #{post.id} from library")
self._app._preview.update_save_state(False)
# Update browse grid thumbnail saved dot
for i, p in enumerate(self._app._posts):
if p.id == post.id and i < len(self._app._grid._thumbs):
self._app._grid._thumbs[i].set_saved_locally(False)
break
# Update bookmarks grid thumbnail
bm_grid = self._app._bookmarks_view._grid
for i, fav in enumerate(self._app._bookmarks_view._bookmarks):
if fav.post_id == post.id and i < len(bm_grid._thumbs):
bm_grid._thumbs[i].set_saved_locally(False)
break
# Refresh the active tab's grid so the unsaved post disappears
# from library or loses its saved dot on bookmarks.
if self._app._stack.currentIndex() == 2:
self._app._library_view.refresh()
elif self._app._stack.currentIndex() == 1:
self._app._bookmarks_view.refresh()
else:
self._app._status.showMessage(f"#{post.id} not in library")
self._app._popout_ctrl.update_state()
def blacklist_tag_from_popout(self, tag: str) -> None:
from PySide6.QtWidgets import QMessageBox
reply = QMessageBox.question(
self._app, "Blacklist Tag",
f"Blacklist tag \"{tag}\"?\nPosts with this tag will be hidden.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self._app._db.add_blacklisted_tag(tag)
self._app._db.set_setting("blacklist_enabled", "1")
self._app._status.showMessage(f"Blacklisted: {tag}")
self._app._search_ctrl.remove_blacklisted_from_grid(tag=tag)
def blacklist_post_from_popout(self) -> None:
post, idx = self.get_preview_post()
if post:
from PySide6.QtWidgets import QMessageBox
reply = QMessageBox.question(
self._app, "Blacklist Post",
f"Blacklist post #{post.id}?\nThis post will be hidden from results.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self._app._db.add_blacklisted_post(post.file_url)
self._app._status.showMessage(f"Post #{post.id} blacklisted")
self._app._search_ctrl.remove_blacklisted_from_grid(post_url=post.file_url)
def toggle_bookmark(self, index: int, folder: str | None = None) -> None:
"""Toggle the bookmark state of post at `index`.
When `folder` is given and the post is not yet bookmarked, the
new bookmark is filed under that bookmark folder. The folder
arg is ignored when removing -- bookmark folder membership is
moot if the bookmark itself is going away.
"""
post = self._app._posts[index]
site_id = self._app._site_combo.currentData()
if not site_id:
return
if self._app._db.is_bookmarked(site_id, post.id):
self._app._db.remove_bookmark(site_id, post.id)
self._app._search_ctrl.invalidate_lookup_caches()
self._app._status.showMessage(f"Unbookmarked #{post.id}")
thumbs = self._app._grid._thumbs
if 0 <= index < len(thumbs):
thumbs[index].set_bookmarked(False)
else:
self._app._status.showMessage(f"Bookmarking #{post.id}...")
async def _fav():
try:
path = await download_image(post.file_url)
self._app._db.add_bookmark(
site_id=site_id,
post_id=post.id,
file_url=post.file_url,
preview_url=post.preview_url,
tags=post.tags,
rating=post.rating,
score=post.score,
source=post.source,
cached_path=str(path),
folder=folder,
tag_categories=post.tag_categories,
)
where = folder or "Unfiled"
self._app._signals.bookmark_done.emit(index, f"Bookmarked #{post.id} to {where}")
except Exception as e:
self._app._signals.bookmark_error.emit(str(e))
self._app._run_async(_fav)
def bulk_bookmark(self, indices: list[int], posts: list) -> None:
site_id = self._app._site_combo.currentData()
if not site_id:
return
self._app._status.showMessage(f"Bookmarking {len(posts)}...")
async def _do():
for i, (idx, post) in enumerate(zip(indices, posts)):
if self._app._db.is_bookmarked(site_id, post.id):
continue
try:
path = await download_image(post.file_url)
self._app._db.add_bookmark(
site_id=site_id, post_id=post.id,
file_url=post.file_url, preview_url=post.preview_url,
tags=post.tags, rating=post.rating, score=post.score,
source=post.source, cached_path=str(path),
tag_categories=post.tag_categories,
)
self._app._signals.bookmark_done.emit(idx, f"Bookmarked {i+1}/{len(posts)}")
except Exception as e:
log.warning(f"Operation failed: {e}")
self._app._signals.batch_done.emit(f"Bookmarked {len(posts)} posts")
self._app._run_async(_do)
def bulk_save(self, indices: list[int], posts: list, folder: str | None) -> None:
"""Bulk-save the selected posts into the library, optionally inside a subfolder.
Each iteration routes through save_post_file with a shared
in_flight set so template-collision-prone batches (e.g.
%artist% on a page that has many posts by the same artist) get
sequential _1, _2, _3 suffixes instead of clobbering each other.
"""
from ..core.config import saved_dir, saved_folder_dir
from ..core.library_save import save_post_file
where = folder or "Unfiled"
self._app._status.showMessage(f"Saving {len(posts)} to {where}...")
try:
dest_dir = saved_folder_dir(folder) if folder else saved_dir()
except ValueError as e:
self._app._status.showMessage(f"Invalid folder name: {e}")
return
in_flight: set[str] = set()
async def _do():
fetcher = self._app._get_category_fetcher()
for i, (idx, post) in enumerate(zip(indices, posts)):
try:
src = Path(await download_image(post.file_url))
await save_post_file(src, post, dest_dir, self._app._db, in_flight, category_fetcher=fetcher)
self.copy_library_thumb(post)
self._app._signals.bookmark_done.emit(idx, f"Saved {i+1}/{len(posts)} to {where}")
self._maybe_unbookmark(post)
except Exception as e:
log.warning(f"Bulk save #{post.id} failed: {e}")
self._app._signals.batch_done.emit(f"Saved {len(posts)} to {where}")
self._app._run_async(_do)
def bulk_unsave(self, indices: list[int], posts: list) -> None:
"""Bulk-remove selected posts from the library.
Mirrors `bulk_save` shape but synchronously -- `delete_from_library`
is a filesystem op, no httpx round-trip needed. Touches only the
library (filesystem); bookmarks are a separate DB-backed concept
and stay untouched. The grid's saved-locally dot clears for every
selection slot regardless of whether the file was actually present
-- the user's intent is "make these not-saved", and a missing file
is already not-saved.
"""
from ..core.cache import delete_from_library
for post in posts:
delete_from_library(post.id, db=self._app._db)
for idx in indices:
if 0 <= idx < len(self._app._grid._thumbs):
self._app._grid._thumbs[idx].set_saved_locally(False)
self._app._grid._clear_multi()
self._app._status.showMessage(f"Removed {len(posts)} from library")
if self._app._stack.currentIndex() == 2:
self._app._library_view.refresh()
self._app._popout_ctrl.update_state()
def ensure_bookmarked(self, post) -> None:
"""Bookmark a post if not already bookmarked."""
site_id = self._app._site_combo.currentData()
if not site_id or self._app._db.is_bookmarked(site_id, post.id):
return
async def _fav():
try:
path = await download_image(post.file_url)
self._app._db.add_bookmark(
site_id=site_id,
post_id=post.id,
file_url=post.file_url,
preview_url=post.preview_url,
tags=post.tags,
rating=post.rating,
score=post.score,
source=post.source,
cached_path=str(path),
)
except Exception as e:
log.warning(f"Operation failed: {e}")
self._app._run_async(_fav)
def batch_download_posts(self, posts: list, dest: str) -> None:
"""Multi-select Download All entry point. Delegates to
batch_download_to so the in_flight set, library_meta write,
and saved-dots refresh share one implementation."""
self.batch_download_to(posts, Path(dest))
def batch_download_to(self, posts: list, dest_dir: Path) -> None:
"""Download `posts` into `dest_dir`, routing each save through
save_post_file with a shared in_flight set so collision-prone
templates produce sequential _1, _2 suffixes within the batch.
Stashes `dest_dir` on `self._batch_dest` so on_batch_progress
and on_batch_done can decide whether the destination is inside
the library and the saved-dots need refreshing. The library_meta
write happens automatically inside save_post_file when dest_dir
is inside saved_dir() -- fixes the v0.2.3 latent bug where batch
downloads into a library folder left files unregistered.
"""
from ..core.library_save import save_post_file
self._batch_dest = dest_dir
self._app._status.showMessage(f"Downloading {len(posts)} images...")
in_flight: set[str] = set()
async def _batch():
fetcher = self._app._get_category_fetcher()
for i, post in enumerate(posts):
try:
src = Path(await download_image(post.file_url))
await save_post_file(src, post, dest_dir, self._app._db, in_flight, category_fetcher=fetcher)
self._app._signals.batch_progress.emit(i + 1, len(posts), post.id)
self._maybe_unbookmark(post)
except Exception as e:
log.warning(f"Batch #{post.id} failed: {e}")
self._app._signals.batch_done.emit(f"Downloaded {len(posts)} images to {dest_dir}")
self._app._run_async(_batch)
def batch_download(self) -> None:
if not self._app._posts:
self._app._status.showMessage("No posts to download")
return
from .dialogs import select_directory
dest = select_directory(self._app, "Download to folder")
if not dest:
return
self.batch_download_to(list(self._app._posts), Path(dest))
def is_current_bookmarked(self, index: int) -> bool:
site_id = self._app._site_combo.currentData()
if not site_id or index < 0 or index >= len(self._app._posts):
return False
return self._app._db.is_bookmarked(site_id, self._app._posts[index].id)
def copy_library_thumb(self, post) -> None:
"""Copy a post's browse thumbnail into the library thumbnail
cache so the Library tab can paint it without re-downloading.
No-op if there's no preview_url or the source thumb isn't cached."""
if not post.preview_url:
return
from ..core.config import thumbnails_dir
from ..core.cache import cached_path_for
thumb_src = cached_path_for(post.preview_url, thumbnails_dir())
if not thumb_src.exists():
return
lib_thumb_dir = thumbnails_dir() / "library"
lib_thumb_dir.mkdir(parents=True, exist_ok=True)
lib_thumb = lib_thumb_dir / f"{post.id}.jpg"
if not lib_thumb.exists():
import shutil
shutil.copy2(thumb_src, lib_thumb)
def save_to_library(self, post, folder: str | None) -> None:
"""Save a post into the library, optionally inside a subfolder.
Routes through the unified save_post_file flow so the filename
template, sequential collision suffixes, same-post idempotency,
and library_meta write are all handled in one place. Re-saving
the same post into the same folder is a no-op (idempotent);
saving into a different folder produces a second copy without
touching the first.
"""
from ..core.config import saved_dir, saved_folder_dir
from ..core.library_save import save_post_file
self._app._status.showMessage(f"Saving #{post.id} to library...")
try:
dest_dir = saved_folder_dir(folder) if folder else saved_dir()
except ValueError as e:
self._app._status.showMessage(f"Invalid folder name: {e}")
return
async def _save():
try:
src = Path(await download_image(post.file_url))
await save_post_file(src, post, dest_dir, self._app._db, category_fetcher=self._app._get_category_fetcher())
self.copy_library_thumb(post)
where = folder or "Unfiled"
self._app._signals.bookmark_done.emit(
self._app._grid.selected_index,
f"Saved #{post.id} to {where}",
)
self._maybe_unbookmark(post)
except Exception as e:
self._app._signals.bookmark_error.emit(str(e))
self._app._run_async(_save)
def save_as(self, post) -> None:
"""Open a Save As dialog for a single post and write the file
through the unified save_post_file flow.
The default name in the dialog comes from rendering the user's
library_filename_template against the post; the user can edit
before confirming. If the chosen destination ends up inside
saved_dir(), save_post_file registers a library_meta row --
a behavior change from v0.2.3 (where Save As never wrote meta
regardless of destination)."""
from ..core.cache import cached_path_for
from ..core.config import render_filename_template
from ..core.library_save import save_post_file
from .dialogs import save_file
src = cached_path_for(post.file_url)
if not src.exists():
self._app._status.showMessage("Image not cached — double-click to download first")
return
ext = src.suffix
template = self._app._db.get_setting("library_filename_template")
default_name = render_filename_template(template, post, ext)
dest = save_file(self._app, "Save Image", default_name, f"Images (*{ext})")
if not dest:
return
dest_path = Path(dest)
async def _do_save():
try:
actual = await save_post_file(
src, post, dest_path.parent, self._app._db,
explicit_name=dest_path.name,
category_fetcher=self._app._get_category_fetcher(),
)
self._app._signals.bookmark_done.emit(
self._app._grid.selected_index,
f"Saved to {actual}",
)
self._maybe_unbookmark(post)
except Exception as e:
self._app._signals.bookmark_error.emit(f"Save failed: {e}")
self._app._run_async(_do_save)
def on_bookmark_done(self, index: int, msg: str) -> None:
self._app._status.showMessage(f"{len(self._app._posts)} results — {msg}")
self._app._search_ctrl.invalidate_lookup_caches()
# Detect batch operations (e.g. "Saved 3/10 to Unfiled") -- skip heavy updates
is_batch = is_batch_message(msg)
thumbs = self._app._grid._thumbs
if 0 <= index < len(thumbs):
if "Saved" in msg:
thumbs[index].set_saved_locally(True)
if "Bookmarked" in msg:
thumbs[index].set_bookmarked(True)
if not is_batch:
if "Bookmarked" in msg:
self._app._preview.update_bookmark_state(True)
if "Saved" in msg:
self._app._preview.update_save_state(True)
if self._app._stack.currentIndex() == 1:
bm_grid = self._app._bookmarks_view._grid
bm_idx = bm_grid.selected_index
if 0 <= bm_idx < len(bm_grid._thumbs):
bm_grid._thumbs[bm_idx].set_saved_locally(True)
if self._app._stack.currentIndex() == 2:
self._app._library_view.refresh()
self._app._popout_ctrl.update_state()
def on_batch_progress(self, current: int, total: int, post_id: int) -> None:
self._app._status.showMessage(f"Downloading {current}/{total}...")
# Light the browse saved-dot for the just-finished post if the
# batch destination is inside the library. Runs per-post on the
# main thread (this is a Qt slot), so the dot appears as the
# files land instead of all at once when the batch completes.
dest = self._batch_dest
if dest is None:
return
from ..core.config import saved_dir
if not is_in_library(dest, saved_dir()):
return
for i, p in enumerate(self._app._posts):
if p.id == post_id and i < len(self._app._grid._thumbs):
self._app._grid._thumbs[i].set_saved_locally(True)
break
def on_batch_done(self, msg: str) -> None:
self._app._status.showMessage(msg)
self._app._popout_ctrl.update_state()
if self._app._stack.currentIndex() == 1:
self._app._bookmarks_view.refresh()
if self._app._stack.currentIndex() == 2:
self._app._library_view.refresh()
# Saved-dot updates happen incrementally in on_batch_progress as
# each file lands; this slot just clears the destination stash.
self._batch_dest = None
def on_library_files_deleted(self, post_ids: list) -> None:
"""Library deleted files -- clear saved dots on browse grid."""
for i, p in enumerate(self._app._posts):
if p.id in post_ids and i < len(self._app._grid._thumbs):
self._app._grid._thumbs[i].set_saved_locally(False)
def refresh_browse_saved_dots(self) -> None:
"""Bookmarks changed -- rescan saved state for all visible browse grid posts."""
for i, p in enumerate(self._app._posts):
if i < len(self._app._grid._thumbs):
self._app._grid._thumbs[i].set_saved_locally(self.is_post_saved(p.id))
site_id = self._app._site_combo.currentData()
self._app._grid._thumbs[i].set_bookmarked(
bool(site_id and self._app._db.is_bookmarked(site_id, p.id))
)

View File

@ -51,6 +51,7 @@ class ImagePreview(QWidget):
self._is_bookmarked = False # tracks bookmark state for the button submenu self._is_bookmarked = False # tracks bookmark state for the button submenu
self._current_tags: dict[str, list[str]] = {} self._current_tags: dict[str, list[str]] = {}
self._current_tag_list: list[str] = [] self._current_tag_list: list[str] = []
self._vol_scroll_accum = 0
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@ -64,50 +65,34 @@ class ImagePreview(QWidget):
tb.setContentsMargins(4, 1, 4, 1) tb.setContentsMargins(4, 1, 4, 1)
tb.setSpacing(4) tb.setSpacing(4)
# Compact toolbar buttons. The bundled themes set _tb_sz = 24
# `QPushButton { padding: 5px 12px }` which eats 24px of horizontal
# space — too much for these short labels in fixed-width slots.
# Override with tighter padding inline so the labels (Unbookmark,
# Unsave, BL Tag, BL Post, Popout) fit cleanly under any theme.
# Same pattern as the search-bar score buttons in app.py and the
# settings dialog spinbox +/- buttons.
_tb_btn_style = "padding: 2px 6px;"
self._bookmark_btn = QPushButton("Bookmark") def _icon_btn(text: str, name: str, tip: str) -> QPushButton:
self._bookmark_btn.setFixedWidth(100) btn = QPushButton(text)
self._bookmark_btn.setStyleSheet(_tb_btn_style) btn.setObjectName(name)
btn.setFixedSize(_tb_sz, _tb_sz)
btn.setToolTip(tip)
return btn
self._bookmark_btn = _icon_btn("\u2606", "_tb_bookmark", "Bookmark (B)")
self._bookmark_btn.clicked.connect(self._on_bookmark_clicked) self._bookmark_btn.clicked.connect(self._on_bookmark_clicked)
tb.addWidget(self._bookmark_btn) tb.addWidget(self._bookmark_btn)
self._save_btn = QPushButton("Save") self._save_btn = _icon_btn("\u2193", "_tb_save", "Save to library (S)")
# 75 fits "Unsave" (6 chars) cleanly across every bundled theme.
# The previous 60 was tight enough that some themes clipped the
# last character on library files where the label flips to Unsave.
self._save_btn.setFixedWidth(75)
self._save_btn.setStyleSheet(_tb_btn_style)
self._save_btn.clicked.connect(self._on_save_clicked) self._save_btn.clicked.connect(self._on_save_clicked)
tb.addWidget(self._save_btn) tb.addWidget(self._save_btn)
self._bl_tag_btn = QPushButton("BL Tag") self._bl_tag_btn = _icon_btn("\u2298", "_tb_bl_tag", "Blacklist a tag")
self._bl_tag_btn.setFixedWidth(60)
self._bl_tag_btn.setStyleSheet(_tb_btn_style)
self._bl_tag_btn.setToolTip("Blacklist a tag")
self._bl_tag_btn.clicked.connect(self._show_bl_tag_menu) self._bl_tag_btn.clicked.connect(self._show_bl_tag_menu)
tb.addWidget(self._bl_tag_btn) tb.addWidget(self._bl_tag_btn)
self._bl_post_btn = QPushButton("BL Post") self._bl_post_btn = _icon_btn("\u2297", "_tb_bl_post", "Blacklist this post")
self._bl_post_btn.setFixedWidth(65)
self._bl_post_btn.setStyleSheet(_tb_btn_style)
self._bl_post_btn.setToolTip("Blacklist this post")
self._bl_post_btn.clicked.connect(self.blacklist_post_requested) self._bl_post_btn.clicked.connect(self.blacklist_post_requested)
tb.addWidget(self._bl_post_btn) tb.addWidget(self._bl_post_btn)
tb.addStretch() tb.addStretch()
self._popout_btn = QPushButton("Popout") self._popout_btn = _icon_btn("\u29c9", "_tb_popout", "Popout")
self._popout_btn.setFixedWidth(65)
self._popout_btn.setStyleSheet(_tb_btn_style)
self._popout_btn.setToolTip("Open in popout")
self._popout_btn.clicked.connect(self.fullscreen_requested) self._popout_btn.clicked.connect(self.fullscreen_requested)
tb.addWidget(self._popout_btn) tb.addWidget(self._popout_btn)
@ -212,7 +197,7 @@ class ImagePreview(QWidget):
self.bookmark_to_folder.emit(folder_actions[id(action)]) self.bookmark_to_folder.emit(folder_actions[id(action)])
def _on_save_clicked(self) -> None: def _on_save_clicked(self) -> None:
if self._save_btn.text() == "Unsave": if self._is_saved:
self.unsave_requested.emit() self.unsave_requested.emit()
return return
menu = QMenu(self) menu = QMenu(self)
@ -239,12 +224,13 @@ class ImagePreview(QWidget):
def update_bookmark_state(self, bookmarked: bool) -> None: def update_bookmark_state(self, bookmarked: bool) -> None:
self._is_bookmarked = bookmarked self._is_bookmarked = bookmarked
self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark") self._bookmark_btn.setText("\u2605" if bookmarked else "\u2606") # ★ / ☆
self._bookmark_btn.setFixedWidth(90 if bookmarked else 80) self._bookmark_btn.setToolTip("Unbookmark (B)" if bookmarked else "Bookmark (B)")
def update_save_state(self, saved: bool) -> None: def update_save_state(self, saved: bool) -> None:
self._is_saved = saved self._is_saved = saved
self._save_btn.setText("Unsave" if saved else "Save") self._save_btn.setText("\u2715" if saved else "\u2193") # ✕ / ⤓
self._save_btn.setToolTip("Unsave from library" if saved else "Save to library (S)")
@ -310,12 +296,36 @@ class ImagePreview(QWidget):
def _on_context_menu(self, pos) -> None: def _on_context_menu(self, pos) -> None:
menu = QMenu(self) menu = QMenu(self)
fav_action = menu.addAction("Bookmark")
# Bookmark: unbookmark if already bookmarked, folder submenu if not
fav_action = None
bm_folder_actions = {}
bm_new_action = None
bm_unfiled = None
if self._is_bookmarked:
fav_action = menu.addAction("Unbookmark")
else:
bm_menu = menu.addMenu("Bookmark as")
bm_unfiled = bm_menu.addAction("Unfiled")
bm_menu.addSeparator()
if self._bookmark_folders_callback:
for folder in self._bookmark_folders_callback():
a = bm_menu.addAction(folder)
bm_folder_actions[id(a)] = folder
bm_menu.addSeparator()
bm_new_action = bm_menu.addAction("+ New Folder...")
save_menu = None
save_unsorted = None
save_new = None
save_folder_actions = {}
unsave_action = None
if self._is_saved:
unsave_action = menu.addAction("Unsave from Library")
else:
save_menu = menu.addMenu("Save to Library") save_menu = menu.addMenu("Save to Library")
save_unsorted = save_menu.addAction("Unfiled") save_unsorted = save_menu.addAction("Unfiled")
save_menu.addSeparator() save_menu.addSeparator()
save_folder_actions = {}
if self._folders_callback: if self._folders_callback:
for folder in self._folders_callback(): for folder in self._folders_callback():
a = save_menu.addAction(folder) a = save_menu.addAction(folder)
@ -323,12 +333,9 @@ class ImagePreview(QWidget):
save_menu.addSeparator() save_menu.addSeparator()
save_new = save_menu.addAction("+ New Folder...") save_new = save_menu.addAction("+ New Folder...")
unsave_action = None
if self._is_saved:
unsave_action = menu.addAction("Unsave from Library")
menu.addSeparator() menu.addSeparator()
copy_image = menu.addAction("Copy File to Clipboard") copy_image = menu.addAction("Copy File to Clipboard")
copy_url = menu.addAction("Copy Image URL")
open_action = menu.addAction("Open in Default App") open_action = menu.addAction("Open in Default App")
browser_action = menu.addAction("Open in Browser") browser_action = menu.addAction("Open in Browser")
@ -347,6 +354,14 @@ class ImagePreview(QWidget):
return return
if action == fav_action: if action == fav_action:
self.bookmark_requested.emit() self.bookmark_requested.emit()
elif action == bm_unfiled:
self.bookmark_to_folder.emit("")
elif action == bm_new_action:
name, ok = QInputDialog.getText(self, "New Bookmark Folder", "Folder name:")
if ok and name.strip():
self.bookmark_to_folder.emit(name.strip())
elif id(action) in bm_folder_actions:
self.bookmark_to_folder.emit(bm_folder_actions[id(action)])
elif action == save_unsorted: elif action == save_unsorted:
self.save_to_folder.emit("") self.save_to_folder.emit("")
elif action == save_new: elif action == save_new:
@ -356,15 +371,22 @@ class ImagePreview(QWidget):
elif id(action) in save_folder_actions: elif id(action) in save_folder_actions:
self.save_to_folder.emit(save_folder_actions[id(action)]) self.save_to_folder.emit(save_folder_actions[id(action)])
elif action == copy_image: elif action == copy_image:
from pathlib import Path as _Path
from PySide6.QtCore import QMimeData, QUrl
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPixmap as _QP from PySide6.QtGui import QPixmap as _QP
pix = self._image_viewer._pixmap cp = self._current_path
if pix and not pix.isNull(): if cp and _Path(cp).exists():
QApplication.clipboard().setPixmap(pix) mime = QMimeData()
elif self._current_path: mime.setUrls([QUrl.fromLocalFile(str(_Path(cp).resolve()))])
pix = _QP(self._current_path) pix = _QP(cp)
if not pix.isNull(): if not pix.isNull():
QApplication.clipboard().setPixmap(pix) mime.setImageData(pix.toImage())
QApplication.clipboard().setMimeData(mime)
elif action == copy_url:
from PySide6.QtWidgets import QApplication
if self._current_post and self._current_post.file_url:
QApplication.clipboard().setText(self._current_post.file_url)
elif action == open_action: elif action == open_action:
self.open_in_default.emit() self.open_in_default.emit()
elif action == browser_action: elif action == browser_action:
@ -395,9 +417,11 @@ class ImagePreview(QWidget):
self.navigate.emit(1) self.navigate.emit(1)
return return
if self._stack.currentIndex() == 1: if self._stack.currentIndex() == 1:
delta = event.angleDelta().y() self._vol_scroll_accum += event.angleDelta().y()
if delta: steps = self._vol_scroll_accum // 120
vol = max(0, min(100, self._video_player.volume + (5 if delta > 0 else -5))) if steps:
self._vol_scroll_accum -= steps * 120
vol = max(0, min(100, self._video_player.volume + 5 * steps))
self._video_player.volume = vol self._video_player.volume = vol
else: else:
super().wheelEvent(event) super().wheelEvent(event)

View File

@ -0,0 +1,68 @@
"""Privacy-screen overlay for the main window."""
from __future__ import annotations
from typing import TYPE_CHECKING
from PySide6.QtWidgets import QWidget
if TYPE_CHECKING:
from .main_window import BooruApp
class PrivacyController:
"""Owns the privacy overlay toggle and popout coordination."""
def __init__(self, app: BooruApp) -> None:
self._app = app
self._on = False
self._overlay: QWidget | None = None
self._popout_was_visible = False
self._preview_was_playing = False
@property
def is_active(self) -> bool:
return self._on
def resize_overlay(self) -> None:
"""Re-fit the overlay to the main window's current rect."""
if self._overlay is not None and self._on:
self._overlay.setGeometry(self._app.rect())
def toggle(self) -> None:
if self._overlay is None:
self._overlay = QWidget(self._app)
self._overlay.setStyleSheet("background: black;")
self._overlay.hide()
self._on = not self._on
if self._on:
self._overlay.setGeometry(self._app.rect())
self._overlay.raise_()
self._overlay.show()
self._app.setWindowTitle("booru-viewer")
# Pause preview video, remembering whether it was playing
self._preview_was_playing = False
if self._app._preview._stack.currentIndex() == 1:
mpv = self._app._preview._video_player._mpv
self._preview_was_playing = mpv is not None and not mpv.pause
self._app._preview._video_player.pause()
# Delegate popout hide-and-pause to FullscreenPreview so it
# can capture its own geometry for restore.
self._popout_was_visible = bool(
self._app._popout_ctrl.window
and self._app._popout_ctrl.window.isVisible()
)
if self._popout_was_visible:
self._app._popout_ctrl.window.privacy_hide()
else:
self._overlay.hide()
# Resume embedded preview video only if it was playing before
if self._preview_was_playing and self._app._preview._stack.currentIndex() == 1:
self._app._preview._video_player.resume()
# Restore the popout via its own privacy_show method, which
# also re-dispatches the captured geometry to Hyprland (Qt
# show() alone doesn't preserve position on Wayland) and
# resumes its video.
if self._popout_was_visible and self._app._popout_ctrl.window:
self._app._popout_ctrl.window.privacy_show()

View File

@ -17,6 +17,29 @@ from PySide6.QtWidgets import (
from ..core.db import Database from ..core.db import Database
class _TagCompleter(QCompleter):
"""Completer that operates on the last space-separated tag only.
When the user types "blue_sky tre", the completer matches against
"tre" and the popup shows suggestions for that fragment. Accepting
a suggestion replaces only the last tag, preserving everything
before the final space.
"""
def splitPath(self, path: str) -> list[str]:
return [path.split()[-1]] if path.split() else [""]
def pathFromIndex(self, index) -> str:
completion = super().pathFromIndex(index)
text = self.widget().text()
parts = text.split()
if parts:
parts[-1] = completion
else:
parts = [completion]
return " ".join(parts) + " "
class SearchBar(QWidget): class SearchBar(QWidget):
"""Tag search bar with autocomplete, history dropdown, and saved searches.""" """Tag search bar with autocomplete, history dropdown, and saved searches."""
@ -63,9 +86,10 @@ class SearchBar(QWidget):
self._btn.clicked.connect(self._do_search) self._btn.clicked.connect(self._do_search)
layout.addWidget(self._btn) layout.addWidget(self._btn)
# Autocomplete # Autocomplete — _TagCompleter only completes the last tag,
# preserving previous tags in multi-tag queries.
self._completer_model = QStringListModel() self._completer_model = QStringListModel()
self._completer = QCompleter(self._completer_model) self._completer = _TagCompleter(self._completer_model)
self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self._input.setCompleter(self._completer) self._input.setCompleter(self._completer)
@ -78,6 +102,9 @@ class SearchBar(QWidget):
self._input.textChanged.connect(self._on_text_changed) self._input.textChanged.connect(self._on_text_changed)
def _on_text_changed(self, text: str) -> None: def _on_text_changed(self, text: str) -> None:
if text.endswith(" "):
self._completer_model.setStringList([])
return
self._ac_timer.start() self._ac_timer.start()
def _request_autocomplete(self) -> None: def _request_autocomplete(self) -> None:
@ -94,7 +121,7 @@ class SearchBar(QWidget):
def _do_search(self) -> None: def _do_search(self) -> None:
query = self._input.text().strip() query = self._input.text().strip()
if self._db and query: if self._db and query and self._db.get_setting_bool("search_history_enabled"):
self._db.add_search_history(query) self._db.add_search_history(query)
self.search_requested.emit(query) self.search_requested.emit(query)
@ -116,8 +143,8 @@ class SearchBar(QWidget):
saved_actions[id(a)] = (sid, query) saved_actions[id(a)] = (sid, query)
menu.addSeparator() menu.addSeparator()
# History # History (only shown when the setting is on)
history = self._db.get_search_history() history = self._db.get_search_history() if self._db.get_setting_bool("search_history_enabled") else []
if history: if history:
hist_header = menu.addAction("-- Recent --") hist_header = menu.addAction("-- Recent --")
hist_header.setEnabled(False) hist_header.setEnabled(False)

View File

@ -0,0 +1,601 @@
"""Search orchestration, infinite scroll, tag building, and blacklist filtering."""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING
from .search_state import SearchState
if TYPE_CHECKING:
from .main_window import BooruApp
log = logging.getLogger("booru")
# -- Pure functions (tested in tests/gui/test_search_controller.py) --
def build_search_tags(
tags: str,
rating: str,
api_type: str | None,
min_score: int,
media_filter: str,
) -> str:
"""Build the full search tag string from individual filter values."""
parts = []
if tags:
parts.append(tags)
if rating != "all" and api_type:
if api_type == "danbooru":
danbooru_map = {
"general": "g", "sensitive": "s",
"questionable": "q", "explicit": "e",
}
if rating in danbooru_map:
parts.append(f"rating:{danbooru_map[rating]}")
elif api_type == "gelbooru":
gelbooru_map = {
"general": "general", "sensitive": "sensitive",
"questionable": "questionable", "explicit": "explicit",
}
if rating in gelbooru_map:
parts.append(f"rating:{gelbooru_map[rating]}")
elif api_type == "e621":
e621_map = {
"general": "s", "sensitive": "s",
"questionable": "q", "explicit": "e",
}
if rating in e621_map:
parts.append(f"rating:{e621_map[rating]}")
else:
moebooru_map = {
"general": "safe", "sensitive": "safe",
"questionable": "questionable", "explicit": "explicit",
}
if rating in moebooru_map:
parts.append(f"rating:{moebooru_map[rating]}")
if min_score > 0:
parts.append(f"score:>={min_score}")
if media_filter == "Animated":
parts.append("animated")
elif media_filter == "Video":
parts.append("video")
elif media_filter == "GIF":
parts.append("animated_gif")
elif media_filter == "Audio":
parts.append("audio")
return " ".join(parts)
def filter_posts(
posts: list,
bl_tags: set,
bl_posts: set,
seen_ids: set,
) -> tuple[list, dict]:
"""Filter posts by blacklisted tags/URLs and dedup against *seen_ids*.
Mutates *seen_ids* in place (adds surviving post IDs).
Returns ``(filtered_posts, drop_counts)`` where *drop_counts* has keys
``bl_tags``, ``bl_posts``, ``dedup``.
"""
drops = {"bl_tags": 0, "bl_posts": 0, "dedup": 0}
n0 = len(posts)
if bl_tags:
posts = [p for p in posts if not bl_tags.intersection(p.tag_list)]
n1 = len(posts)
drops["bl_tags"] = n0 - n1
if bl_posts:
posts = [p for p in posts if p.file_url not in bl_posts]
n2 = len(posts)
drops["bl_posts"] = n1 - n2
posts = [p for p in posts if p.id not in seen_ids]
n3 = len(posts)
drops["dedup"] = n2 - n3
seen_ids.update(p.id for p in posts)
return posts, drops
def should_backfill(collected_count: int, limit: int, last_batch_size: int) -> bool:
"""Return True if another backfill page should be fetched."""
return collected_count < limit and last_batch_size >= limit
# -- Controller --
class SearchController:
"""Owns search orchestration, pagination, infinite scroll, and blacklist."""
def __init__(self, app: BooruApp) -> None:
self._app = app
self._current_page = 1
self._current_tags = ""
self._current_rating = "all"
self._min_score = 0
self._loading = False
self._search = SearchState()
self._last_scroll_page = 0
self._infinite_scroll = app._db.get_setting_bool("infinite_scroll")
# Cached lookup sets — rebuilt once per search, reused in
# _drain_append_queue to avoid repeated DB queries and directory
# listings on every infinite-scroll append.
self._cached_names: set[str] | None = None
self._bookmarked_ids: set[int] | None = None
self._saved_ids: set[int] | None = None
def reset(self) -> None:
"""Reset search state for a site change."""
self._search.shown_post_ids.clear()
self._search.page_cache.clear()
self._cached_names = None
self._bookmarked_ids = None
self._saved_ids = None
def invalidate_lookup_caches(self) -> None:
"""Clear cached bookmark/saved/cache-dir sets.
Call after a bookmark or save operation so the next
``_drain_append_queue`` picks up the change.
"""
self._bookmarked_ids = None
self._saved_ids = None
def clear_loading(self) -> None:
self._loading = False
# -- Search entry points --
def on_search(self, tags: str) -> None:
self._current_tags = tags
self._app._page_spin.setValue(1)
self._current_page = 1
self._search = SearchState()
self._cached_names = None
self._bookmarked_ids = None
self._saved_ids = None
self._min_score = self._app._score_spin.value()
self._app._preview.clear()
self._app._next_page_btn.setVisible(True)
self._app._prev_page_btn.setVisible(False)
self.do_search()
def on_search_error(self, e: str) -> None:
self._loading = False
self._app._status.showMessage(f"Error: {e}")
# -- Pagination --
def prev_page(self) -> None:
if self._current_page > 1:
self._current_page -= 1
if self._current_page in self._search.page_cache:
self._app._signals.search_done.emit(self._search.page_cache[self._current_page])
else:
self.do_search()
def next_page(self) -> None:
if self._loading:
return
self._current_page += 1
if self._current_page in self._search.page_cache:
self._app._signals.search_done.emit(self._search.page_cache[self._current_page])
return
self.do_search()
def on_nav_past_end(self) -> None:
if self._infinite_scroll:
return
self._search.nav_page_turn = "first"
self.next_page()
def on_nav_before_start(self) -> None:
if self._infinite_scroll:
return
if self._current_page > 1:
self._search.nav_page_turn = "last"
self.prev_page()
def scroll_next_page(self) -> None:
if self._loading:
return
self._current_page += 1
self.do_search()
def scroll_prev_page(self) -> None:
if self._loading or self._current_page <= 1:
return
self._current_page -= 1
self.do_search()
# -- Tag building --
def _build_search_tags(self) -> str:
api_type = self._app._current_site.api_type if self._app._current_site else None
return build_search_tags(
self._current_tags,
self._current_rating,
api_type,
self._min_score,
self._app._media_filter.currentText(),
)
# -- Core search --
def do_search(self) -> None:
if not self._app._current_site:
self._app._status.showMessage("No site selected")
return
self._loading = True
self._app._page_label.setText(f"Page {self._current_page}")
self._app._status.showMessage("Searching...")
search_tags = self._build_search_tags()
log.info(f"Search: tags='{search_tags}' rating={self._current_rating}")
page = self._current_page
limit = self._app._db.get_setting_int("page_size") or 40
bl_tags = set()
if self._app._db.get_setting_bool("blacklist_enabled"):
bl_tags = set(self._app._db.get_blacklisted_tags())
bl_posts = self._app._db.get_blacklisted_posts()
shown_ids = self._search.shown_post_ids.copy()
seen = shown_ids.copy()
total_drops = {"bl_tags": 0, "bl_posts": 0, "dedup": 0}
async def _search():
client = self._app._make_client()
try:
collected = []
raw_total = 0
current_page = page
batch = await client.search(tags=search_tags, page=current_page, limit=limit)
raw_total += len(batch)
filtered, batch_drops = filter_posts(batch, bl_tags, bl_posts, seen)
for k in total_drops:
total_drops[k] += batch_drops[k]
collected.extend(filtered)
if should_backfill(len(collected), limit, len(batch)):
for _ in range(9):
await asyncio.sleep(0.3)
current_page += 1
batch = await client.search(tags=search_tags, page=current_page, limit=limit)
raw_total += len(batch)
filtered, batch_drops = filter_posts(batch, bl_tags, bl_posts, seen)
for k in total_drops:
total_drops[k] += batch_drops[k]
collected.extend(filtered)
log.debug(f"Backfill: page={current_page} batch={len(batch)} filtered={len(filtered)} total={len(collected)}/{limit}")
if not should_backfill(len(collected), limit, len(batch)):
break
log.debug(
f"do_search: limit={limit} api_returned_total={raw_total} kept={len(collected[:limit])} "
f"drops_bl_tags={total_drops['bl_tags']} drops_bl_posts={total_drops['bl_posts']} drops_dedup={total_drops['dedup']} "
f"last_batch_size={len(batch)} api_short_signal={len(batch) < limit}"
)
self._app._signals.search_done.emit(collected[:limit])
except Exception as e:
self._app._signals.search_error.emit(str(e))
finally:
await client.close()
self._app._run_async(_search)
# -- Search results --
def on_search_done(self, posts: list) -> None:
self._app._page_label.setText(f"Page {self._current_page}")
self._app._posts = posts
ss = self._search
ss.shown_post_ids.update(p.id for p in posts)
ss.page_cache[self._current_page] = posts
if not self._infinite_scroll and len(ss.page_cache) > 10:
oldest = min(ss.page_cache.keys())
del ss.page_cache[oldest]
limit = self._app._db.get_setting_int("page_size") or 40
at_end = len(posts) < limit
log.debug(f"on_search_done: displayed_count={len(posts)} limit={limit} at_end={at_end}")
if at_end:
self._app._status.showMessage(f"{len(posts)} results (end)")
else:
self._app._status.showMessage(f"{len(posts)} results")
self._app._prev_page_btn.setVisible(self._current_page > 1)
self._app._next_page_btn.setVisible(not at_end)
thumbs = self._app._grid.set_posts(len(posts))
self._app._grid.scroll_to_top()
from PySide6.QtCore import QTimer
QTimer.singleShot(100, self.clear_loading)
from ..core.cache import cached_path_for, cache_dir
site_id = self._app._site_combo.currentData()
self._saved_ids = self._app._db.get_saved_post_ids()
_favs = self._app._db.get_bookmarks(site_id=site_id) if site_id else []
self._bookmarked_ids = {f.post_id for f in _favs}
_cd = cache_dir()
self._cached_names = set()
if _cd.exists():
self._cached_names = {f.name for f in _cd.iterdir() if f.is_file()}
for i, (post, thumb) in enumerate(zip(posts, thumbs)):
if post.id in self._bookmarked_ids:
thumb.set_bookmarked(True)
thumb.set_saved_locally(post.id in self._saved_ids)
cached = cached_path_for(post.file_url)
if cached.name in self._cached_names:
thumb._cached_path = str(cached)
if post.preview_url:
self.fetch_thumbnail(i, post.preview_url)
turn = self._search.nav_page_turn
if turn and posts:
self._search.nav_page_turn = None
if turn == "first":
idx = 0
else:
idx = len(posts) - 1
self._app._grid._select(idx)
self._app._media_ctrl.on_post_activated(idx)
self._app._grid.setFocus()
if self._app._db.get_setting("prefetch_mode") in ("Nearby", "Aggressive") and posts:
self._app._media_ctrl.prefetch_adjacent(0)
if self._infinite_scroll and posts:
QTimer.singleShot(200, self.check_viewport_fill)
# -- Infinite scroll --
def on_reached_bottom(self) -> None:
if not self._infinite_scroll or self._loading or self._search.infinite_exhausted:
return
self._loading = True
self._current_page += 1
search_tags = self._build_search_tags()
page = self._current_page
limit = self._app._db.get_setting_int("page_size") or 40
bl_tags = set()
if self._app._db.get_setting_bool("blacklist_enabled"):
bl_tags = set(self._app._db.get_blacklisted_tags())
bl_posts = self._app._db.get_blacklisted_posts()
shown_ids = self._search.shown_post_ids.copy()
seen = shown_ids.copy()
total_drops = {"bl_tags": 0, "bl_posts": 0, "dedup": 0}
async def _search():
client = self._app._make_client()
collected = []
raw_total = 0
last_page = page
api_exhausted = False
try:
current_page = page
batch = await client.search(tags=search_tags, page=current_page, limit=limit)
raw_total += len(batch)
last_page = current_page
filtered, batch_drops = filter_posts(batch, bl_tags, bl_posts, seen)
for k in total_drops:
total_drops[k] += batch_drops[k]
collected.extend(filtered)
if len(batch) < limit:
api_exhausted = True
elif len(collected) < limit:
for _ in range(9):
await asyncio.sleep(0.3)
current_page += 1
batch = await client.search(tags=search_tags, page=current_page, limit=limit)
raw_total += len(batch)
last_page = current_page
filtered, batch_drops = filter_posts(batch, bl_tags, bl_posts, seen)
for k in total_drops:
total_drops[k] += batch_drops[k]
collected.extend(filtered)
if len(batch) < limit:
api_exhausted = True
break
if len(collected) >= limit:
break
except Exception as e:
log.warning(f"Infinite scroll fetch failed: {e}")
finally:
self._search.infinite_last_page = last_page
self._search.infinite_api_exhausted = api_exhausted
log.debug(
f"on_reached_bottom: limit={limit} api_returned_total={raw_total} kept={len(collected[:limit])} "
f"drops_bl_tags={total_drops['bl_tags']} drops_bl_posts={total_drops['bl_posts']} drops_dedup={total_drops['dedup']} "
f"api_exhausted={api_exhausted} last_page={last_page}"
)
self._app._signals.search_append.emit(collected[:limit])
await client.close()
self._app._run_async(_search)
def on_scroll_range_changed(self, _min: int, max_val: int) -> None:
"""Scrollbar range changed (resize/splitter) -- check if viewport needs filling."""
if max_val == 0 and self._infinite_scroll and self._app._posts:
from PySide6.QtCore import QTimer
QTimer.singleShot(100, self.check_viewport_fill)
def check_viewport_fill(self) -> None:
"""If content doesn't fill the viewport, trigger infinite scroll."""
if not self._infinite_scroll or self._loading or self._search.infinite_exhausted:
return
self._app._grid.widget().updateGeometry()
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
sb = self._app._grid.verticalScrollBar()
if sb.maximum() == 0 and self._app._posts:
self.on_reached_bottom()
def on_search_append(self, posts: list) -> None:
"""Queue posts and add them one at a time as thumbnails arrive."""
ss = self._search
if not posts:
if ss.infinite_api_exhausted and ss.infinite_last_page > self._current_page:
self._current_page = ss.infinite_last_page
self._loading = False
if ss.infinite_api_exhausted:
ss.infinite_exhausted = True
self._app._status.showMessage(f"{len(self._app._posts)} results (end)")
else:
from PySide6.QtCore import QTimer
QTimer.singleShot(100, self.check_viewport_fill)
return
if ss.infinite_last_page > self._current_page:
self._current_page = ss.infinite_last_page
ss.shown_post_ids.update(p.id for p in posts)
ss.append_queue.extend(posts)
self._drain_append_queue()
def _drain_append_queue(self) -> None:
"""Add all queued posts to the grid at once, thumbnails load async."""
ss = self._search
if not ss.append_queue:
self._loading = False
return
from ..core.cache import cached_path_for
# Reuse the lookup sets built in on_search_done. They stay valid
# within an infinite-scroll session — bookmarks/saves don't change
# during passive scrolling, and the cache directory only grows.
if self._saved_ids is None:
self._saved_ids = self._app._db.get_saved_post_ids()
if self._bookmarked_ids is None:
site_id = self._app._site_combo.currentData()
_favs = self._app._db.get_bookmarks(site_id=site_id) if site_id else []
self._bookmarked_ids = {f.post_id for f in _favs}
if self._cached_names is None:
from ..core.cache import cache_dir
_cd = cache_dir()
self._cached_names = set()
if _cd.exists():
self._cached_names = {f.name for f in _cd.iterdir() if f.is_file()}
posts = ss.append_queue[:]
ss.append_queue.clear()
start_idx = len(self._app._posts)
self._app._posts.extend(posts)
thumbs = self._app._grid.append_posts(len(posts))
for i, (post, thumb) in enumerate(zip(posts, thumbs)):
idx = start_idx + i
if post.id in self._bookmarked_ids:
thumb.set_bookmarked(True)
thumb.set_saved_locally(post.id in self._saved_ids)
cached = cached_path_for(post.file_url)
if cached.name in self._cached_names:
thumb._cached_path = str(cached)
if post.preview_url:
self.fetch_thumbnail(idx, post.preview_url)
self._app._status.showMessage(f"{len(self._app._posts)} results")
self._loading = False
self._app._media_ctrl.auto_evict_cache()
sb = self._app._grid.verticalScrollBar()
from .grid import THUMB_SIZE, THUMB_SPACING
threshold = THUMB_SIZE + THUMB_SPACING * 2
if sb.maximum() == 0 or sb.value() >= sb.maximum() - threshold:
self.on_reached_bottom()
# -- Thumbnails --
def fetch_thumbnail(self, index: int, url: str) -> None:
from ..core.cache import download_thumbnail
async def _download():
try:
path = await download_thumbnail(url)
self._app._signals.thumb_done.emit(index, str(path))
except Exception as e:
log.warning(f"Thumb #{index} failed: {e}")
self._app._run_async(_download)
def on_thumb_done(self, index: int, path: str) -> None:
from PySide6.QtGui import QPixmap
thumbs = self._app._grid._thumbs
if 0 <= index < len(thumbs):
pix = QPixmap(path)
if not pix.isNull():
thumbs[index].set_pixmap(pix, path)
# -- Autocomplete --
def request_autocomplete(self, query: str) -> None:
if not self._app._current_site or len(query) < 2:
return
async def _ac():
client = self._app._make_client()
try:
results = await client.autocomplete(query)
self._app._signals.autocomplete_done.emit(results)
except Exception as e:
log.warning(f"Operation failed: {e}")
finally:
await client.close()
self._app._run_async(_ac)
def on_autocomplete_done(self, suggestions: list) -> None:
self._app._search_bar.set_suggestions(suggestions)
# -- Blacklist removal --
def remove_blacklisted_from_grid(self, tag: str = None, post_url: str = None) -> None:
"""Remove matching posts from the grid in-place without re-searching."""
to_remove = []
for i, post in enumerate(self._app._posts):
if tag and tag in post.tag_list:
to_remove.append(i)
elif post_url and post.file_url == post_url:
to_remove.append(i)
if not to_remove:
return
from ..core.cache import cached_path_for
for i in to_remove:
cp = str(cached_path_for(self._app._posts[i].file_url))
if cp == self._app._preview._current_path:
self._app._preview.clear()
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
self._app._popout_ctrl.window.stop_media()
break
for i in reversed(to_remove):
self._app._posts.pop(i)
thumbs = self._app._grid.set_posts(len(self._app._posts))
site_id = self._app._site_combo.currentData()
_saved_ids = self._app._db.get_saved_post_ids()
for i, (post, thumb) in enumerate(zip(self._app._posts, thumbs)):
if site_id and self._app._db.is_bookmarked(site_id, post.id):
thumb.set_bookmarked(True)
thumb.set_saved_locally(post.id in _saved_ids)
from ..core.cache import cached_path_for as cpf
cached = cpf(post.file_url)
if cached.exists():
thumb._cached_path = str(cached)
if post.preview_url:
self.fetch_thumbnail(i, post.preview_url)
self._app._status.showMessage(f"{len(self._app._posts)} results — {len(to_remove)} removed")

View File

@ -21,7 +21,6 @@ from PySide6.QtWidgets import (
QListWidget, QListWidget,
QMessageBox, QMessageBox,
QGroupBox, QGroupBox,
QProgressBar,
) )
from ..core.db import Database from ..core.db import Database
@ -65,6 +64,10 @@ class SettingsDialog(QDialog):
btns = QHBoxLayout() btns = QHBoxLayout()
btns.addStretch() btns.addStretch()
apply_btn = QPushButton("Apply")
apply_btn.clicked.connect(self._apply)
btns.addWidget(apply_btn)
save_btn = QPushButton("Save") save_btn = QPushButton("Save")
save_btn.clicked.connect(self._save_and_close) save_btn.clicked.connect(self._save_and_close)
btns.addWidget(save_btn) btns.addWidget(save_btn)
@ -187,6 +190,21 @@ class SettingsDialog(QDialog):
self._infinite_scroll.setChecked(self._db.get_setting_bool("infinite_scroll")) self._infinite_scroll.setChecked(self._db.get_setting_bool("infinite_scroll"))
form.addRow("", self._infinite_scroll) form.addRow("", self._infinite_scroll)
# Unbookmark on save
self._unbookmark_on_save = QCheckBox("Remove bookmark when saved to library")
self._unbookmark_on_save.setChecked(self._db.get_setting_bool("unbookmark_on_save"))
form.addRow("", self._unbookmark_on_save)
# Search history
self._search_history = QCheckBox("Record recent searches")
self._search_history.setChecked(self._db.get_setting_bool("search_history_enabled"))
form.addRow("", self._search_history)
# Flip layout
self._flip_layout = QCheckBox("Preview on left")
self._flip_layout.setChecked(self._db.get_setting_bool("flip_layout"))
form.addRow("", self._flip_layout)
# Slideshow monitor # Slideshow monitor
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
self._monitor_combo = QComboBox() self._monitor_combo = QComboBox()
@ -200,6 +218,16 @@ class SettingsDialog(QDialog):
self._monitor_combo.setCurrentIndex(idx) self._monitor_combo.setCurrentIndex(idx)
form.addRow("Popout monitor:", self._monitor_combo) form.addRow("Popout monitor:", self._monitor_combo)
# Popout anchor — resize pivot point
self._popout_anchor = QComboBox()
self._popout_anchor.addItems(["Center", "Top-left", "Top-right", "Bottom-left", "Bottom-right"])
_anchor_map = {"center": "Center", "tl": "Top-left", "tr": "Top-right", "bl": "Bottom-left", "br": "Bottom-right"}
current_anchor = self._db.get_setting("popout_anchor") or "center"
idx = self._popout_anchor.findText(_anchor_map.get(current_anchor, "Center"))
if idx >= 0:
self._popout_anchor.setCurrentIndex(idx)
form.addRow("Popout anchor:", self._popout_anchor)
# File dialog platform (Linux only) # File dialog platform (Linux only)
self._file_dialog_combo = None self._file_dialog_combo = None
if not IS_WINDOWS: if not IS_WINDOWS:
@ -285,6 +313,15 @@ class SettingsDialog(QDialog):
clear_cache_btn.clicked.connect(self._clear_image_cache) clear_cache_btn.clicked.connect(self._clear_image_cache)
btn_row1.addWidget(clear_cache_btn) btn_row1.addWidget(clear_cache_btn)
clear_tags_btn = QPushButton("Clear Tag Cache")
clear_tags_btn.setToolTip(
"Wipe the per-site tag-type cache (Gelbooru/Moebooru sites). "
"Use this if category colors stop appearing correctly — the "
"app will re-fetch tag types on the next post view."
)
clear_tags_btn.clicked.connect(self._clear_tag_cache)
btn_row1.addWidget(clear_tags_btn)
actions_layout.addLayout(btn_row1) actions_layout.addLayout(btn_row1)
btn_row2 = QHBoxLayout() btn_row2 = QHBoxLayout()
@ -444,7 +481,7 @@ class SettingsDialog(QDialog):
"%artist% %character% %copyright% %general% %meta% %species%\n" "%artist% %character% %copyright% %general% %meta% %species%\n"
"Applies to every save action: Save to Library, Save As, Batch Download, " "Applies to every save action: Save to Library, Save As, Batch Download, "
"multi-select bulk operations, and bookmark→library copies.\n" "multi-select bulk operations, and bookmark→library copies.\n"
"Note: Gelbooru and Moebooru only support %id% / %md5% / %score% / %rating% / %ext%." "All tokens work on all sites. Category tokens are fetched on demand."
) )
tmpl_help.setWordWrap(True) tmpl_help.setWordWrap(True)
tmpl_help.setStyleSheet("color: palette(mid); font-size: 10pt;") tmpl_help.setStyleSheet("color: palette(mid); font-size: 10pt;")
@ -515,7 +552,6 @@ class SettingsDialog(QDialog):
# -- Network tab -- # -- Network tab --
def _build_network_tab(self) -> QWidget: def _build_network_tab(self) -> QWidget:
from ..core.cache import get_connection_log
w = QWidget() w = QWidget()
layout = QVBoxLayout(w) layout = QVBoxLayout(w)
@ -672,6 +708,18 @@ class SettingsDialog(QDialog):
QMessageBox.information(self, "Done", f"Evicted {count} files.") QMessageBox.information(self, "Done", f"Evicted {count} files.")
self._refresh_stats() self._refresh_stats()
def _clear_tag_cache(self) -> None:
reply = QMessageBox.question(
self, "Confirm",
"Wipe the tag category cache for every site? This also clears "
"the per-site batch-API probe result, so the app will re-probe "
"Gelbooru/Moebooru backends on next use.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
count = self._db.clear_tag_cache()
QMessageBox.information(self, "Done", f"Deleted {count} tag-type rows.")
def _bl_export(self) -> None: def _bl_export(self) -> None:
from .dialogs import save_file from .dialogs import save_file
path = save_file(self, "Export Blacklist", "blacklist.txt", "Text (*.txt)") path = save_file(self, "Export Blacklist", "blacklist.txt", "Text (*.txt)")
@ -770,7 +818,8 @@ class SettingsDialog(QDialog):
# -- Save -- # -- Save --
def _save_and_close(self) -> None: def _apply(self) -> None:
"""Write all settings to DB and emit settings_changed."""
self._db.set_setting("page_size", str(self._page_size.value())) self._db.set_setting("page_size", str(self._page_size.value()))
self._db.set_setting("thumbnail_size", str(self._thumb_size.value())) self._db.set_setting("thumbnail_size", str(self._thumb_size.value()))
self._db.set_setting("default_rating", self._default_rating.currentText()) self._db.set_setting("default_rating", self._default_rating.currentText())
@ -779,7 +828,12 @@ class SettingsDialog(QDialog):
self._db.set_setting("preload_thumbnails", "1" if self._preload.isChecked() else "0") self._db.set_setting("preload_thumbnails", "1" if self._preload.isChecked() else "0")
self._db.set_setting("prefetch_mode", self._prefetch_combo.currentText()) self._db.set_setting("prefetch_mode", self._prefetch_combo.currentText())
self._db.set_setting("infinite_scroll", "1" if self._infinite_scroll.isChecked() else "0") self._db.set_setting("infinite_scroll", "1" if self._infinite_scroll.isChecked() else "0")
self._db.set_setting("unbookmark_on_save", "1" if self._unbookmark_on_save.isChecked() else "0")
self._db.set_setting("search_history_enabled", "1" if self._search_history.isChecked() else "0")
self._db.set_setting("flip_layout", "1" if self._flip_layout.isChecked() else "0")
self._db.set_setting("slideshow_monitor", self._monitor_combo.currentText()) self._db.set_setting("slideshow_monitor", self._monitor_combo.currentText())
_anchor_rmap = {"Center": "center", "Top-left": "tl", "Top-right": "tr", "Bottom-left": "bl", "Bottom-right": "br"}
self._db.set_setting("popout_anchor", _anchor_rmap.get(self._popout_anchor.currentText(), "center"))
self._db.set_setting("library_dir", self._library_dir.text().strip()) self._db.set_setting("library_dir", self._library_dir.text().strip())
self._db.set_setting("library_filename_template", self._library_filename_template.text().strip()) self._db.set_setting("library_filename_template", self._library_filename_template.text().strip())
self._db.set_setting("max_cache_mb", str(self._max_cache.value())) self._db.set_setting("max_cache_mb", str(self._max_cache.value()))
@ -796,5 +850,10 @@ class SettingsDialog(QDialog):
self._db.add_blacklisted_tag(tag) self._db.add_blacklisted_tag(tag)
if self._file_dialog_combo is not None: if self._file_dialog_combo is not None:
self._db.set_setting("file_dialog_platform", self._file_dialog_combo.currentText()) self._db.set_setting("file_dialog_platform", self._file_dialog_combo.currentText())
from .dialogs import reset_gtk_cache
reset_gtk_cache()
self.settings_changed.emit() self.settings_changed.emit()
def _save_and_close(self) -> None:
self._apply()
self.accept() self.accept()

View File

@ -191,7 +191,7 @@ class SiteDialog(QDialog):
def _try_parse_url(self, text: str) -> None: def _try_parse_url(self, text: str) -> None:
"""Strip query params from pasted URLs like https://gelbooru.com/index.php?page=post&s=list&tags=all.""" """Strip query params from pasted URLs like https://gelbooru.com/index.php?page=post&s=list&tags=all."""
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse
text = text.strip() text = text.strip()
if "?" not in text: if "?" not in text:
return return

View File

@ -0,0 +1,299 @@
"""Main-window geometry and splitter persistence."""
from __future__ import annotations
import json
import logging
import os
import subprocess
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .main_window import BooruApp
log = logging.getLogger("booru")
# -- Pure functions (tested in tests/gui/test_window_state.py) --
def parse_geometry(s: str) -> tuple[int, int, int, int] | None:
"""Parse ``"x,y,w,h"`` into a 4-tuple of ints, or *None* on bad input."""
if not s:
return None
parts = s.split(",")
if len(parts) != 4:
return None
try:
vals = tuple(int(p) for p in parts)
except ValueError:
return None
return vals # type: ignore[return-value]
def format_geometry(x: int, y: int, w: int, h: int) -> str:
"""Format geometry ints into the ``"x,y,w,h"`` DB string."""
return f"{x},{y},{w},{h}"
def parse_splitter_sizes(s: str, expected: int) -> list[int] | None:
"""Parse ``"a,b,..."`` into a list of *expected* non-negative ints.
Returns *None* when the string is empty, has the wrong count, contains
non-numeric values, any value is negative, or every value is zero (an
all-zero splitter is a transient state that should not be persisted).
"""
if not s:
return None
parts = s.split(",")
if len(parts) != expected:
return None
try:
sizes = [int(p) for p in parts]
except ValueError:
return None
if any(v < 0 for v in sizes):
return None
if all(v == 0 for v in sizes):
return None
return sizes
def build_hyprctl_restore_cmds(
addr: str,
x: int,
y: int,
w: int,
h: int,
want_floating: bool,
cur_floating: bool,
) -> list[str]:
"""Build the ``hyprctl --batch`` command list to restore window state.
When *want_floating* is True, ensures the window is floating then
resizes/moves. When False, primes Hyprland's per-window floating cache
by briefly toggling to floating (wrapped in ``no_anim``), then ends on
tiled so a later mid-session float-toggle picks up the saved dimensions.
"""
cmds: list[str] = []
if want_floating:
if not cur_floating:
cmds.append(f"dispatch togglefloating address:{addr}")
cmds.append(f"dispatch resizewindowpixel exact {w} {h},address:{addr}")
cmds.append(f"dispatch movewindowpixel exact {x} {y},address:{addr}")
else:
cmds.append(f"dispatch setprop address:{addr} no_anim 1")
if not cur_floating:
cmds.append(f"dispatch togglefloating address:{addr}")
cmds.append(f"dispatch resizewindowpixel exact {w} {h},address:{addr}")
cmds.append(f"dispatch movewindowpixel exact {x} {y},address:{addr}")
cmds.append(f"dispatch togglefloating address:{addr}")
cmds.append(f"dispatch setprop address:{addr} no_anim 0")
return cmds
# -- Controller --
class WindowStateController:
"""Owns main-window geometry persistence and Hyprland IPC."""
def __init__(self, app: BooruApp) -> None:
self._app = app
# -- Splitter persistence --
def save_main_splitter_sizes(self) -> None:
"""Persist the main grid/preview splitter sizes (debounced).
Refuses to save when either side is collapsed (size 0). The user can
end up with a collapsed right panel transiently -- e.g. while the
popout is open and the right panel is empty -- and persisting that
state traps them next launch with no visible preview area until they
manually drag the splitter back.
"""
sizes = self._app._splitter.sizes()
if len(sizes) >= 2 and all(s > 0 for s in sizes):
self._app._db.set_setting(
"main_splitter_sizes", ",".join(str(s) for s in sizes)
)
def save_right_splitter_sizes(self) -> None:
"""Persist the right splitter sizes (preview / dl_progress / info).
Skipped while the popout is open -- the popout temporarily collapses
the preview pane and gives the info panel the full right column,
and we don't want that transient layout persisted as the user's
preferred state.
"""
if self._app._popout_ctrl.is_active:
return
sizes = self._app._right_splitter.sizes()
if len(sizes) == 3 and sum(sizes) > 0:
self._app._db.set_setting(
"right_splitter_sizes", ",".join(str(s) for s in sizes)
)
# -- Hyprland IPC --
def hyprctl_main_window(self) -> dict | None:
"""Look up this main window in hyprctl clients. None off Hyprland.
Matches by Wayland app_id (Hyprland reports it as ``class``), which is
set in run() via setDesktopFileName. Title would also work but it
changes whenever the search bar updates the window title -- class is
constant for the lifetime of the window.
"""
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
return None
try:
result = subprocess.run(
["hyprctl", "clients", "-j"],
capture_output=True, text=True, timeout=1,
)
for c in json.loads(result.stdout):
cls = c.get("class") or c.get("initialClass")
if cls == "booru-viewer":
# Skip the popout -- it shares our class but has a
# distinct title we set explicitly.
if (c.get("title") or "").endswith("Popout"):
continue
return c
except Exception:
# hyprctl unavailable (non-Hyprland session), timed out,
# or produced invalid JSON. Caller treats None as
# "no Hyprland-visible main window" and falls back to
# Qt's own geometry tracking.
pass
return None
# -- Window state save / restore --
def save_main_window_state(self) -> None:
"""Persist the main window's last mode and (separately) the last
known floating geometry.
Two settings keys are used:
- main_window_was_floating ("1" / "0"): the *last* mode the window
was in (floating or tiled). Updated on every save.
- main_window_floating_geometry ("x,y,w,h"): the position+size the
window had the *last time it was actually floating*. Only updated
when the current state is floating, so a tile->close->reopen->float
sequence still has the user's old floating dimensions to use.
This split is important because Hyprland's resizeEvent for a tiled
window reports the tile slot size -- saving that into the floating
slot would clobber the user's chosen floating dimensions every time
they tiled the window.
"""
try:
win = self.hyprctl_main_window()
if win is None:
# Non-Hyprland fallback: just track Qt's frameGeometry as
# floating. There's no real tiled concept off-Hyprland.
g = self._app.frameGeometry()
self._app._db.set_setting(
"main_window_floating_geometry",
format_geometry(g.x(), g.y(), g.width(), g.height()),
)
self._app._db.set_setting("main_window_was_floating", "1")
return
floating = bool(win.get("floating"))
self._app._db.set_setting(
"main_window_was_floating", "1" if floating else "0"
)
if floating and win.get("at") and win.get("size"):
x, y = win["at"]
w, h = win["size"]
self._app._db.set_setting(
"main_window_floating_geometry", format_geometry(x, y, w, h)
)
# When tiled, intentionally do NOT touch floating_geometry --
# preserve the last good floating dimensions.
except Exception:
# Geometry persistence is best-effort; swallowing here
# beats crashing closeEvent over a hyprctl timeout or a
# setting-write race. Next save attempt will retry.
pass
def restore_main_window_state(self) -> None:
"""One-shot restore of saved floating geometry and last mode.
Called from __init__ via QTimer.singleShot(0, ...) so it fires on the
next event-loop iteration -- by which time the window has been shown
and (on Hyprland) registered with the compositor.
Entirely skipped when BOORU_VIEWER_NO_HYPR_RULES is set -- that flag
means the user wants their own windowrules to handle the main
window. Even seeding Qt's geometry could fight a ``windowrule = size``,
so we leave the initial Qt geometry alone too.
"""
from ..core.config import hypr_rules_enabled
if not hypr_rules_enabled():
return
# Migration: clear obsolete keys from earlier schemas so they can't
# interfere. main_window_maximized came from a buggy version that
# used Qt's isMaximized() which lies for Hyprland tiled windows.
# main_window_geometry was the combined-format key that's now split.
for stale in ("main_window_maximized", "main_window_geometry"):
if self._app._db.get_setting(stale):
self._app._db.set_setting(stale, "")
floating_geo = self._app._db.get_setting("main_window_floating_geometry")
was_floating = self._app._db.get_setting_bool("main_window_was_floating")
if not floating_geo:
return
geo = parse_geometry(floating_geo)
if geo is None:
return
x, y, w, h = geo
# Seed Qt with the floating geometry -- even if we're going to leave
# the window tiled now, this becomes the xdg-toplevel preferred size,
# which Hyprland uses when the user later toggles to floating. So
# mid-session float-toggle picks up the saved dimensions even when
# the window opened tiled.
self._app.setGeometry(x, y, w, h)
if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
return
# Slight delay so the window is registered before we try to find
# its address. The popout uses the same pattern.
from PySide6.QtCore import QTimer
QTimer.singleShot(
50, lambda: self.hyprctl_apply_main_state(x, y, w, h, was_floating)
)
def hyprctl_apply_main_state(
self, x: int, y: int, w: int, h: int, floating: bool
) -> None:
"""Apply saved floating mode + geometry to the main window via hyprctl.
If floating==True, ensures the window is floating and resizes/moves it
to the saved dimensions.
If floating==False, the window is left tiled but we still "prime"
Hyprland's per-window floating cache by briefly toggling to floating,
applying the saved geometry, and toggling back. This is wrapped in
a transient ``no_anim`` so the toggles are instant.
Skipped entirely when BOORU_VIEWER_NO_HYPR_RULES is set.
"""
from ..core.config import hypr_rules_enabled
if not hypr_rules_enabled():
return
win = self.hyprctl_main_window()
if not win:
return
addr = win.get("address")
if not addr:
return
cur_floating = bool(win.get("floating"))
cmds = build_hyprctl_restore_cmds(addr, x, y, w, h, floating, cur_floating)
if not cmds:
return
try:
subprocess.Popen(
["hyprctl", "--batch", " ; ".join(cmds)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
pass

View File

@ -2,7 +2,7 @@
[Setup] [Setup]
AppName=booru-viewer AppName=booru-viewer
AppVersion=pre-release 0.2.4 AppVersion=0.2.7
AppPublisher=pax AppPublisher=pax
AppPublisherURL=https://git.pax.moe/pax/booru-viewer AppPublisherURL=https://git.pax.moe/pax/booru-viewer
DefaultDirName={localappdata}\booru-viewer DefaultDirName={localappdata}\booru-viewer

View File

@ -4,14 +4,14 @@ build-backend = "hatchling.build"
[project] [project]
name = "booru-viewer" name = "booru-viewer"
version = "pre-release 0.2.4" version = "0.2.7"
description = "Local booru image browser with Qt6 GUI" description = "Local booru image browser with Qt6 GUI"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"httpx[http2]>=0.27", "httpx>=0.27,<1.0",
"Pillow>=10.0", "Pillow>=10.0,<12.0",
"PySide6>=6.6", "PySide6>=6.6,<7.0",
"python-mpv>=1.0", "python-mpv>=1.0,<2.0",
] ]
[project.scripts] [project.scripts]

0
tests/__init__.py Normal file
View File

71
tests/conftest.py Normal file
View File

@ -0,0 +1,71 @@
"""Shared fixtures for the booru-viewer test suite.
All fixtures here are pure-Python no Qt, no mpv, no network. Filesystem
writes go through `tmp_path` (or fixtures that wrap it). Module-level globals
that the production code mutates (the concurrency loop, the httpx singletons)
get reset around each test that touches them.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def tmp_db(tmp_path):
"""Fresh `Database` instance writing to a temp file. Auto-closes."""
from booru_viewer.core.db import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def tmp_library(tmp_path):
"""Point `saved_dir()` at `tmp_path/saved` for the duration of the test.
Uses `core.config.set_library_dir` (the official override hook) so the
redirect goes through the same code path the GUI uses for the
user-configurable library location. Tear-down restores the previous
value so tests can run in any order without bleed.
"""
from booru_viewer.core import config
saved = tmp_path / "saved"
saved.mkdir()
original = config._library_dir_override
config.set_library_dir(saved)
yield saved
config.set_library_dir(original)
@pytest.fixture
def reset_app_loop():
"""Reset `concurrency._app_loop` between tests.
The module global is set once at app startup in production; tests need
to start from a clean slate to assert the unset-state behavior.
"""
from booru_viewer.core import concurrency
original = concurrency._app_loop
concurrency._app_loop = None
yield
concurrency._app_loop = original
@pytest.fixture
def reset_shared_clients():
"""Reset both shared httpx singletons (cache module + BooruClient class).
Both are class/module-level globals; tests that exercise the lazy-init
+ lock pattern need them cleared so the test sees a fresh first-call
race instead of a leftover instance from a previous test.
"""
from booru_viewer.core.api.base import BooruClient
from booru_viewer.core import cache
original_booru = BooruClient._shared_client
original_cache = cache._shared_client
BooruClient._shared_client = None
cache._shared_client = None
yield
BooruClient._shared_client = original_booru
cache._shared_client = original_cache

0
tests/core/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,77 @@
"""Tests for `booru_viewer.core.api.base` — the lazy `_shared_client`
singleton on `BooruClient`.
Locks in the lock-and-recheck pattern at `base.py:90-108`. Without it,
two threads racing on first `.client` access would both see
`_shared_client is None`, both build an `httpx.AsyncClient`, and one of
them would leak (overwritten without aclose).
"""
from __future__ import annotations
import threading
from unittest.mock import patch, MagicMock
import pytest
from booru_viewer.core.api.base import BooruClient
class _StubClient(BooruClient):
"""Concrete subclass so we can instantiate `BooruClient` for the test
the base class has abstract `search` / `get_post` methods."""
api_type = "stub"
async def search(self, tags="", page=1, limit=40):
return []
async def get_post(self, post_id):
return None
def test_shared_client_singleton_under_concurrency(reset_shared_clients):
"""N threads racing on first `.client` access must result in exactly
one `httpx.AsyncClient` constructor call. The threading.Lock guards
the check-and-set so the second-and-later callers re-read the now-set
`_shared_client` after acquiring the lock instead of building their
own."""
constructor_calls = 0
constructor_lock = threading.Lock()
def _fake_async_client(*args, **kwargs):
nonlocal constructor_calls
with constructor_lock:
constructor_calls += 1
m = MagicMock()
m.is_closed = False
return m
# Barrier so all threads hit the property at the same moment
n_threads = 10
barrier = threading.Barrier(n_threads)
results = []
results_lock = threading.Lock()
client_instance = _StubClient("http://example.test")
def _worker():
barrier.wait()
c = client_instance.client
with results_lock:
results.append(c)
with patch("booru_viewer.core.api.base.httpx.AsyncClient",
side_effect=_fake_async_client):
threads = [threading.Thread(target=_worker) for _ in range(n_threads)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
assert constructor_calls == 1, (
f"Expected exactly one httpx.AsyncClient construction, "
f"got {constructor_calls}"
)
# All threads got back the same shared instance
assert len(results) == n_threads
assert all(r is results[0] for r in results)

View File

@ -0,0 +1,542 @@
"""Tests for CategoryFetcher: HTML parser, tag API parser, cache compose,
probe persistence, dispatch logic, and canonical ordering.
All pure Python no Qt, no network. Uses tmp_db fixture for cache tests
and synthetic HTML/JSON/XML for parser tests.
"""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from unittest.mock import AsyncMock, MagicMock
import pytest
from booru_viewer.core.api.category_fetcher import (
CategoryFetcher,
_canonical_order,
_parse_post_html,
_parse_tag_response,
_LABEL_MAP,
_GELBOORU_TYPE_MAP,
)
# ---------------------------------------------------------------------------
# Synthetic data helpers
# ---------------------------------------------------------------------------
@dataclass
class FakePost:
id: int = 1
tags: str = ""
tag_categories: dict = field(default_factory=dict)
@property
def tag_list(self) -> list[str]:
return self.tags.split() if self.tags else []
class FakeClient:
"""Minimal mock of BooruClient for CategoryFetcher construction."""
api_key = None
api_user = None
def __init__(self, post_view_url=None, tag_api_url=None, api_key=None, api_user=None):
self._pv_url = post_view_url
self._ta_url = tag_api_url
self.api_key = api_key
self.api_user = api_user
def _post_view_url(self, post):
return self._pv_url
def _tag_api_url(self):
return self._ta_url
async def _request(self, method, url, params=None):
raise NotImplementedError("mock _request not configured")
class FakeResponse:
"""Minimal httpx.Response stand-in for parser tests."""
def __init__(self, text: str, status_code: int = 200):
self.text = text
self.status_code = status_code
def json(self):
return json.loads(self.text)
def raise_for_status(self):
if self.status_code >= 400:
raise Exception(f"HTTP {self.status_code}")
# ---------------------------------------------------------------------------
# HTML parser tests (_parse_post_html)
# ---------------------------------------------------------------------------
class TestParsePostHtml:
"""Test the two-pass regex HTML parser against synthetic markup."""
def test_rule34_style_two_links(self):
"""Standard Gelbooru-fork layout: ? wiki link + tag search link."""
html = '''
<li class="tag-type-character">
<a href="index.php?page=wiki&s=list&search=hatsune_miku">?</a>
<a href="index.php?page=post&amp;s=list&amp;tags=hatsune_miku">hatsune miku</a>
<span class="tag-count">12345</span>
</li>
<li class="tag-type-artist">
<a href="index.php?page=wiki&s=list&search=someartist">?</a>
<a href="index.php?page=post&amp;s=list&amp;tags=someartist">someartist</a>
<span class="tag-count">100</span>
</li>
<li class="tag-type-general">
<a href="index.php?page=wiki&s=list&search=1girl">?</a>
<a href="index.php?page=post&amp;s=list&amp;tags=1girl">1girl</a>
<span class="tag-count">9999999</span>
</li>
'''
cats, labels = _parse_post_html(html)
assert "Character" in cats
assert "Artist" in cats
assert "General" in cats
assert cats["Character"] == ["hatsune_miku"]
assert cats["Artist"] == ["someartist"]
assert cats["General"] == ["1girl"]
assert labels["hatsune_miku"] == "Character"
assert labels["someartist"] == "Artist"
def test_moebooru_style(self):
"""yande.re / Konachan: /post?tags=NAME format."""
html = '''
<li class="tag-type-artist">
<a href="/artist/show?name=anmi">?</a>
<a href="/post?tags=anmi">anmi</a>
</li>
<li class="tag-type-copyright">
<a href="/wiki/show?title=vocaloid">?</a>
<a href="/post?tags=vocaloid">vocaloid</a>
</li>
'''
cats, labels = _parse_post_html(html)
assert cats["Artist"] == ["anmi"]
assert cats["Copyright"] == ["vocaloid"]
def test_combined_class_konachan(self):
"""Konachan uses class="tag-link tag-type-character"."""
html = '''
<span class="tag-link tag-type-character">
<a href="/wiki/show?title=miku">?</a>
<a href="/post?tags=hatsune_miku">hatsune miku</a>
</span>
'''
cats, _ = _parse_post_html(html)
assert cats["Character"] == ["hatsune_miku"]
def test_gelbooru_proper_returns_empty(self):
"""Gelbooru proper only has ? links with no tags= param."""
html = '''
<li class="tag-type-artist">
<a href="index.php?page=wiki&amp;s=list&amp;search=ooiaooi">?</a>
</li>
<li class="tag-type-character">
<a href="index.php?page=wiki&amp;s=list&amp;search=hatsune_miku">?</a>
</li>
'''
cats, labels = _parse_post_html(html)
assert cats == {}
assert labels == {}
def test_metadata_maps_to_meta(self):
"""class="tag-type-metadata" should map to label "Meta"."""
html = '''
<li class="tag-type-metadata">
<a href="?">?</a>
<a href="index.php?tags=highres">highres</a>
</li>
'''
cats, labels = _parse_post_html(html)
assert "Meta" in cats
assert cats["Meta"] == ["highres"]
def test_url_encoded_tag_names(self):
"""Tags with special chars get URL-encoded in the href."""
html = '''
<li class="tag-type-character">
<a href="?">?</a>
<a href="index.php?tags=miku_%28shinkalion%29">miku (shinkalion)</a>
</li>
'''
cats, labels = _parse_post_html(html)
assert cats["Character"] == ["miku_(shinkalion)"]
def test_empty_html(self):
cats, labels = _parse_post_html("")
assert cats == {}
assert labels == {}
def test_no_tag_type_elements(self):
html = '<div class="content"><p>Hello world</p></div>'
cats, labels = _parse_post_html(html)
assert cats == {}
def test_unknown_type_class_ignored(self):
"""Tag types not in _LABEL_MAP are silently skipped."""
html = '''
<li class="tag-type-faults">
<a href="?">?</a>
<a href="index.php?tags=broken">broken</a>
</li>
'''
cats, _ = _parse_post_html(html)
assert cats == {}
def test_multiple_tags_same_category(self):
html = '''
<li class="tag-type-character">
<a href="?">?</a>
<a href="index.php?tags=miku">miku</a>
</li>
<li class="tag-type-character">
<a href="?">?</a>
<a href="index.php?tags=rin">rin</a>
</li>
'''
cats, _ = _parse_post_html(html)
assert cats["Character"] == ["miku", "rin"]
# ---------------------------------------------------------------------------
# Tag API response parser tests (_parse_tag_response)
# ---------------------------------------------------------------------------
class TestParseTagResponse:
def test_json_response(self):
resp = FakeResponse(json.dumps({
"@attributes": {"limit": 100, "offset": 0, "count": 2},
"tag": [
{"id": 1, "name": "hatsune_miku", "count": 12345, "type": 4, "ambiguous": 0},
{"id": 2, "name": "1girl", "count": 9999, "type": 0, "ambiguous": 0},
]
}))
result = _parse_tag_response(resp)
assert ("hatsune_miku", 4) in result
assert ("1girl", 0) in result
def test_xml_response(self):
resp = FakeResponse(
'<?xml version="1.0" encoding="UTF-8"?>'
'<tags type="array">'
'<tag type="4" count="12345" name="hatsune_miku" ambiguous="false" id="1"/>'
'<tag type="0" count="9999" name="1girl" ambiguous="false" id="2"/>'
'</tags>'
)
result = _parse_tag_response(resp)
assert ("hatsune_miku", 4) in result
assert ("1girl", 0) in result
def test_empty_response(self):
resp = FakeResponse("")
assert _parse_tag_response(resp) == []
def test_json_flat_list(self):
"""Some endpoints return a flat list instead of wrapping in {"tag": [...]}."""
resp = FakeResponse(json.dumps([
{"name": "solo", "type": 0, "count": 5000},
]))
result = _parse_tag_response(resp)
assert ("solo", 0) in result
def test_malformed_xml(self):
resp = FakeResponse("<broken><xml")
result = _parse_tag_response(resp)
assert result == []
def test_malformed_json(self):
resp = FakeResponse("{not valid json!!!")
result = _parse_tag_response(resp)
assert result == []
# ---------------------------------------------------------------------------
# Canonical ordering
# ---------------------------------------------------------------------------
class TestCanonicalOrder:
def test_standard_order(self):
cats = {
"General": ["1girl"],
"Artist": ["anmi"],
"Meta": ["highres"],
"Character": ["miku"],
"Copyright": ["vocaloid"],
}
ordered = _canonical_order(cats)
keys = list(ordered.keys())
assert keys == ["Artist", "Character", "Copyright", "General", "Meta"]
def test_species_position(self):
cats = {
"General": ["1girl"],
"Species": ["cat_girl"],
"Artist": ["anmi"],
}
ordered = _canonical_order(cats)
keys = list(ordered.keys())
assert keys == ["Artist", "Species", "General"]
def test_unknown_category_appended(self):
cats = {
"Artist": ["anmi"],
"Circle": ["some_circle"],
}
ordered = _canonical_order(cats)
keys = list(ordered.keys())
assert "Artist" in keys
assert "Circle" in keys
assert keys.index("Artist") < keys.index("Circle")
def test_empty_dict(self):
assert _canonical_order({}) == {}
# ---------------------------------------------------------------------------
# Cache compose (try_compose_from_cache)
# ---------------------------------------------------------------------------
class TestCacheCompose:
def test_full_coverage_returns_true(self, tmp_db):
client = FakeClient()
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
tmp_db.set_tag_labels(1, {
"1girl": "General",
"hatsune_miku": "Character",
"vocaloid": "Copyright",
})
post = FakePost(tags="1girl hatsune_miku vocaloid")
result = fetcher.try_compose_from_cache(post)
assert result is True
assert "Character" in post.tag_categories
assert "Copyright" in post.tag_categories
assert "General" in post.tag_categories
def test_partial_coverage_returns_false_but_populates(self, tmp_db):
client = FakeClient()
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
tmp_db.set_tag_labels(1, {"hatsune_miku": "Character"})
post = FakePost(tags="1girl hatsune_miku vocaloid")
result = fetcher.try_compose_from_cache(post)
assert result is False
# Still populated with what IS cached
assert "Character" in post.tag_categories
assert post.tag_categories["Character"] == ["hatsune_miku"]
def test_zero_coverage_returns_false(self, tmp_db):
client = FakeClient()
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
post = FakePost(tags="1girl hatsune_miku vocaloid")
result = fetcher.try_compose_from_cache(post)
assert result is False
assert post.tag_categories == {}
def test_empty_tags_returns_true(self, tmp_db):
client = FakeClient()
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
post = FakePost(tags="")
assert fetcher.try_compose_from_cache(post) is True
def test_canonical_order_applied(self, tmp_db):
client = FakeClient()
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
tmp_db.set_tag_labels(1, {
"1girl": "General",
"anmi": "Artist",
"miku": "Character",
})
post = FakePost(tags="1girl anmi miku")
fetcher.try_compose_from_cache(post)
keys = list(post.tag_categories.keys())
assert keys == ["Artist", "Character", "General"]
def test_per_site_isolation(self, tmp_db):
client = FakeClient()
fetcher_1 = CategoryFetcher(client, tmp_db, site_id=1)
fetcher_2 = CategoryFetcher(client, tmp_db, site_id=2)
tmp_db.set_tag_labels(1, {"miku": "Character"})
# Site 2 has nothing cached
post = FakePost(tags="miku")
assert fetcher_1.try_compose_from_cache(post) is True
post2 = FakePost(tags="miku")
assert fetcher_2.try_compose_from_cache(post2) is False
# ---------------------------------------------------------------------------
# Probe persistence
# ---------------------------------------------------------------------------
class TestProbePersistence:
def test_initial_state_none(self, tmp_db):
fetcher = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
assert fetcher._batch_api_works is None
def test_save_true_persists(self, tmp_db):
fetcher = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
fetcher._save_probe_result(True)
fetcher2 = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
assert fetcher2._batch_api_works is True
def test_save_false_persists(self, tmp_db):
fetcher = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
fetcher._save_probe_result(False)
fetcher2 = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
assert fetcher2._batch_api_works is False
def test_per_site_isolation(self, tmp_db):
f1 = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
f1._save_probe_result(True)
f2 = CategoryFetcher(FakeClient(), tmp_db, site_id=2)
f2._save_probe_result(False)
assert CategoryFetcher(FakeClient(), tmp_db, site_id=1)._batch_api_works is True
assert CategoryFetcher(FakeClient(), tmp_db, site_id=2)._batch_api_works is False
def test_clear_tag_cache_wipes_probe(self, tmp_db):
fetcher = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
fetcher._save_probe_result(True)
tmp_db.clear_tag_cache(site_id=1)
fetcher2 = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
assert fetcher2._batch_api_works is None
# ---------------------------------------------------------------------------
# Batch API availability check
# ---------------------------------------------------------------------------
class TestBatchApiAvailable:
def test_available_with_url_and_auth(self, tmp_db):
client = FakeClient(tag_api_url="http://example.com", api_key="k", api_user="u")
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
assert fetcher._batch_api_available() is True
def test_not_available_without_url(self, tmp_db):
client = FakeClient(api_key="k", api_user="u")
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
assert fetcher._batch_api_available() is False
def test_not_available_without_auth(self, tmp_db):
client = FakeClient(tag_api_url="http://example.com")
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
assert fetcher._batch_api_available() is False
# ---------------------------------------------------------------------------
# Label map and type map coverage
# ---------------------------------------------------------------------------
class TestMaps:
def test_label_map_covers_common_types(self):
for name in ["general", "artist", "character", "copyright", "metadata", "meta", "species"]:
assert name in _LABEL_MAP
def test_gelbooru_type_map_covers_standard_codes(self):
assert _GELBOORU_TYPE_MAP[0] == "General"
assert _GELBOORU_TYPE_MAP[1] == "Artist"
assert _GELBOORU_TYPE_MAP[3] == "Copyright"
assert _GELBOORU_TYPE_MAP[4] == "Character"
assert _GELBOORU_TYPE_MAP[5] == "Meta"
assert 2 not in _GELBOORU_TYPE_MAP # Deprecated intentionally omitted
# ---------------------------------------------------------------------------
# _do_ensure dispatch — regression cover for transient-error poisoning
# ---------------------------------------------------------------------------
class TestDoEnsureProbeRouting:
"""When _batch_api_works is None, _do_ensure must route through
_probe_batch_api so transient errors stay transient. The prior
implementation called fetch_via_tag_api directly and inferred
False from empty tag_categories but fetch_via_tag_api swallows
per-chunk exceptions, so a network drop silently poisoned the
probe flag to False for the whole site."""
def test_transient_error_leaves_flag_none(self, tmp_db):
"""All chunks fail → _batch_api_works must stay None,
not flip to False."""
client = FakeClient(
tag_api_url="http://example.com/tags",
api_key="k",
api_user="u",
)
async def raising_request(method, url, params=None):
raise RuntimeError("network down")
client._request = raising_request
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
assert fetcher._batch_api_works is None
post = FakePost(tags="miku 1girl")
asyncio.new_event_loop().run_until_complete(fetcher._do_ensure(post))
assert fetcher._batch_api_works is None, (
"Transient error must not poison the probe flag"
)
# Persistence side: nothing was saved
reloaded = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
assert reloaded._batch_api_works is None
def test_clean_200_zero_matches_flips_to_false(self, tmp_db):
"""Clean HTTP 200 + no names matching the request → flips
the flag to False (structurally broken endpoint)."""
client = FakeClient(
tag_api_url="http://example.com/tags",
api_key="k",
api_user="u",
)
async def empty_ok_request(method, url, params=None):
# 200 with a valid but empty tag list
return FakeResponse(
json.dumps({"@attributes": {"count": 0}, "tag": []}),
status_code=200,
)
client._request = empty_ok_request
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
post = FakePost(tags="definitely_not_a_real_tag")
asyncio.new_event_loop().run_until_complete(fetcher._do_ensure(post))
assert fetcher._batch_api_works is False, (
"Clean 200 with zero matches must flip flag to False"
)
reloaded = CategoryFetcher(FakeClient(), tmp_db, site_id=1)
assert reloaded._batch_api_works is False
def test_non_200_leaves_flag_none(self, tmp_db):
"""500-family responses are transient, must not poison."""
client = FakeClient(
tag_api_url="http://example.com/tags",
api_key="k",
api_user="u",
)
async def five_hundred(method, url, params=None):
return FakeResponse("", status_code=503)
client._request = five_hundred
fetcher = CategoryFetcher(client, tmp_db, site_id=1)
post = FakePost(tags="miku")
asyncio.new_event_loop().run_until_complete(fetcher._do_ensure(post))
assert fetcher._batch_api_works is None

View File

@ -0,0 +1,217 @@
"""Tests for the shared network-safety helpers (SSRF guard + secret redaction)."""
from __future__ import annotations
import asyncio
import socket
from unittest.mock import patch
import httpx
import pytest
from booru_viewer.core.api._safety import (
SECRET_KEYS,
check_public_host,
redact_params,
redact_url,
validate_public_request,
)
# ======================================================================
# SSRF guard — finding #1
# ======================================================================
def test_public_v4_literal_passes():
check_public_host("8.8.8.8")
check_public_host("1.1.1.1")
def test_loopback_v4_rejected():
with pytest.raises(httpx.RequestError):
check_public_host("127.0.0.1")
with pytest.raises(httpx.RequestError):
check_public_host("127.0.0.53")
def test_cloud_metadata_ip_rejected():
"""169.254.169.254 — AWS/GCE/Azure metadata service."""
with pytest.raises(httpx.RequestError):
check_public_host("169.254.169.254")
def test_rfc1918_rejected():
with pytest.raises(httpx.RequestError):
check_public_host("10.0.0.1")
with pytest.raises(httpx.RequestError):
check_public_host("172.16.5.4")
with pytest.raises(httpx.RequestError):
check_public_host("192.168.1.1")
def test_cgnat_rejected():
with pytest.raises(httpx.RequestError):
check_public_host("100.64.0.1")
def test_multicast_v4_rejected():
with pytest.raises(httpx.RequestError):
check_public_host("224.0.0.1")
def test_ipv6_loopback_rejected():
with pytest.raises(httpx.RequestError):
check_public_host("::1")
def test_ipv6_unique_local_rejected():
with pytest.raises(httpx.RequestError):
check_public_host("fc00::1")
with pytest.raises(httpx.RequestError):
check_public_host("fd12:3456:789a::1")
def test_ipv6_link_local_rejected():
with pytest.raises(httpx.RequestError):
check_public_host("fe80::1")
def test_ipv6_multicast_rejected():
with pytest.raises(httpx.RequestError):
check_public_host("ff02::1")
def test_public_v6_passes():
# Google DNS
check_public_host("2001:4860:4860::8888")
def test_hostname_dns_failure_raises():
def _gaierror(*a, **kw):
raise socket.gaierror(-2, "Name or service not known")
with patch("socket.getaddrinfo", _gaierror):
with pytest.raises(httpx.RequestError):
check_public_host("nonexistent.test.invalid")
def test_hostname_resolving_to_loopback_rejected():
def _fake(*a, **kw):
return [(socket.AF_INET, 0, 0, "", ("127.0.0.1", 0))]
with patch("socket.getaddrinfo", _fake):
with pytest.raises(httpx.RequestError, match="blocked request target"):
check_public_host("mean.example")
def test_hostname_resolving_to_metadata_rejected():
def _fake(*a, **kw):
return [(socket.AF_INET, 0, 0, "", ("169.254.169.254", 0))]
with patch("socket.getaddrinfo", _fake):
with pytest.raises(httpx.RequestError):
check_public_host("stolen.example")
def test_hostname_resolving_to_public_passes():
def _fake(*a, **kw):
return [(socket.AF_INET, 0, 0, "", ("8.8.8.8", 0))]
with patch("socket.getaddrinfo", _fake):
check_public_host("dns.google")
def test_hostname_with_mixed_results_rejected_on_any_private():
"""If any resolved address is private, reject — conservative."""
def _fake(*a, **kw):
return [
(socket.AF_INET, 0, 0, "", ("8.8.8.8", 0)),
(socket.AF_INET, 0, 0, "", ("127.0.0.1", 0)),
]
with patch("socket.getaddrinfo", _fake):
with pytest.raises(httpx.RequestError):
check_public_host("dualhomed.example")
def test_empty_host_passes():
"""Edge case: httpx can call us with a relative URL mid-redirect."""
check_public_host("")
def test_validate_public_request_hook_rejects_metadata():
"""The async hook is invoked via asyncio.run() instead of
pytest-asyncio so the test runs on CI (which only installs
httpx + Pillow + pytest)."""
request = httpx.Request("GET", "http://169.254.169.254/latest/meta-data/")
with pytest.raises(httpx.RequestError):
asyncio.run(validate_public_request(request))
def test_validate_public_request_hook_allows_public():
def _fake(*a, **kw):
return [(socket.AF_INET, 0, 0, "", ("8.8.8.8", 0))]
with patch("socket.getaddrinfo", _fake):
request = httpx.Request("GET", "https://example.test/")
asyncio.run(validate_public_request(request)) # must not raise
# ======================================================================
# Credential redaction — finding #3
# ======================================================================
def test_secret_keys_covers_all_booru_client_params():
"""Every secret query param used by any booru client must be in SECRET_KEYS."""
# Danbooru: login + api_key
# e621: login + api_key
# Gelbooru: api_key + user_id
# Moebooru: login + password_hash
for key in ("login", "api_key", "user_id", "password_hash"):
assert key in SECRET_KEYS
def test_redact_url_replaces_secrets():
redacted = redact_url("https://x.test/posts.json?login=alice&api_key=supersecret&tags=cats")
assert "alice" not in redacted
assert "supersecret" not in redacted
assert "tags=cats" in redacted
assert "login=%2A%2A%2A" in redacted
assert "api_key=%2A%2A%2A" in redacted
def test_redact_url_leaves_non_secret_params_alone():
redacted = redact_url("https://x.test/posts.json?tags=cats&limit=50")
assert redacted == "https://x.test/posts.json?tags=cats&limit=50"
def test_redact_url_no_query_passthrough():
assert redact_url("https://x.test/") == "https://x.test/"
assert redact_url("https://x.test/posts.json") == "https://x.test/posts.json"
def test_redact_url_password_hash_and_user_id():
redacted = redact_url(
"https://x.test/post.json?login=a&password_hash=b&user_id=42&tags=cats"
)
assert "password_hash=%2A%2A%2A" in redacted
assert "user_id=%2A%2A%2A" in redacted
assert "tags=cats" in redacted
def test_redact_url_preserves_fragment_and_path():
redacted = redact_url("https://x.test/a/b/c?api_key=secret#frag")
assert redacted.startswith("https://x.test/a/b/c?")
assert redacted.endswith("#frag")
def test_redact_params_replaces_secrets():
out = redact_params({"api_key": "s", "tags": "cats", "login": "alice"})
assert out["api_key"] == "***"
assert out["login"] == "***"
assert out["tags"] == "cats"
def test_redact_params_empty():
assert redact_params({}) == {}
def test_redact_params_no_secrets():
out = redact_params({"tags": "cats", "limit": 50})
assert out == {"tags": "cats", "limit": 50}

388
tests/core/test_cache.py Normal file
View File

@ -0,0 +1,388 @@
"""Tests for `booru_viewer.core.cache` — Referer hostname matching, ugoira
zip-bomb defenses, download size caps, and validity-check fallback.
Locks in:
- `_referer_for` proper hostname suffix matching (`54ccc40` security fix)
guarding against `imgblahgelbooru.attacker.com` mapping to gelbooru.com
- `_convert_ugoira_to_gif` cap enforcement (frame count + uncompressed size)
before any decompression defense against ugoira zip bombs
- `_do_download` MAX_DOWNLOAD_BYTES enforcement, both the Content-Length
pre-check and the running-total chunk-loop guard
- `_is_valid_media` returning True on OSError so a transient EBUSY/lock
doesn't kick off a delete + re-download loop
"""
from __future__ import annotations
import asyncio
import io
import zipfile
from pathlib import Path
from unittest.mock import patch
from urllib.parse import urlparse
import pytest
from booru_viewer.core import cache
from booru_viewer.core.cache import (
MAX_DOWNLOAD_BYTES,
_convert_ugoira_to_gif,
_do_download,
_is_valid_media,
_referer_for,
)
# -- _referer_for hostname suffix matching --
def test_referer_for_exact_and_suffix_match():
"""Real booru hostnames map to the canonical Referer for their CDN.
Exact match and subdomain-suffix match both rewrite the Referer host
to the canonical apex (gelbooru `gelbooru.com`, donmai
`danbooru.donmai.us`). The actual request netloc is dropped the
point is to look like a navigation from the canonical site.
"""
# gelbooru exact host
assert _referer_for(urlparse("https://gelbooru.com/index.php")) \
== "https://gelbooru.com/"
# gelbooru subdomain rewrites to the canonical apex
assert _referer_for(urlparse("https://img3.gelbooru.com/images/abc.jpg")) \
== "https://gelbooru.com/"
# donmai exact host
assert _referer_for(urlparse("https://donmai.us/posts/123")) \
== "https://danbooru.donmai.us/"
# donmai subdomain rewrites to the canonical danbooru host
assert _referer_for(urlparse("https://safebooru.donmai.us/posts/123")) \
== "https://danbooru.donmai.us/"
def test_referer_for_rejects_substring_attacker():
"""An attacker host that contains `gelbooru.com` or `donmai.us` as a
SUBSTRING (not a hostname suffix) must NOT pick up the booru Referer.
Without proper suffix matching, `imgblahgelbooru.attacker.com` would
leak the gelbooru Referer to the attacker that's the `54ccc40`
security fix.
"""
# Attacker host that ends with attacker-controlled TLD
parsed = urlparse("https://imgblahgelbooru.attacker.com/x.jpg")
referer = _referer_for(parsed)
assert "gelbooru.com" not in referer
assert "imgblahgelbooru.attacker.com" in referer
parsed = urlparse("https://donmai.us.attacker.com/x.jpg")
referer = _referer_for(parsed)
assert "danbooru.donmai.us" not in referer
assert "donmai.us.attacker.com" in referer
# Completely unrelated host preserved as-is
parsed = urlparse("https://example.test/x.jpg")
assert _referer_for(parsed) == "https://example.test/"
# -- Ugoira zip-bomb defenses --
def _build_ugoira_zip(path: Path, n_frames: int, frame_bytes: bytes = b"x") -> Path:
"""Build a synthetic ugoira-shaped zip with `n_frames` numbered .jpg
entries. Content is whatever the caller passes; defaults to 1 byte.
The cap-enforcement tests don't need decodable JPEGs — the cap fires
before any decode happens. The filenames just need .jpg suffixes so
`_convert_ugoira_to_gif` recognizes them as frames.
"""
with zipfile.ZipFile(path, "w") as zf:
for i in range(n_frames):
zf.writestr(f"{i:04d}.jpg", frame_bytes)
return path
def test_ugoira_frame_count_cap_rejects_bomb(tmp_path, monkeypatch):
"""A zip with more than `UGOIRA_MAX_FRAMES` frames must be refused
BEFORE any decompression. We monkeypatch the cap down so the test
builds a tiny zip instead of a 5001-entry one the cap check is
cap > N, not cap == 5000."""
monkeypatch.setattr(cache, "UGOIRA_MAX_FRAMES", 2)
zip_path = _build_ugoira_zip(tmp_path / "bomb.zip", n_frames=3)
gif_path = zip_path.with_suffix(".gif")
result = _convert_ugoira_to_gif(zip_path)
# Function returned the original zip (refusal path)
assert result == zip_path
# No .gif was written
assert not gif_path.exists()
def test_ugoira_uncompressed_size_cap_rejects_bomb(tmp_path, monkeypatch):
"""A zip whose `ZipInfo.file_size` headers sum past
`UGOIRA_MAX_UNCOMPRESSED_BYTES` must be refused before decompression.
Same monkeypatch trick to keep the test data small."""
monkeypatch.setattr(cache, "UGOIRA_MAX_UNCOMPRESSED_BYTES", 50)
# Three 100-byte frames → 300 total > 50 cap
zip_path = _build_ugoira_zip(
tmp_path / "bomb.zip", n_frames=3, frame_bytes=b"x" * 100
)
gif_path = zip_path.with_suffix(".gif")
result = _convert_ugoira_to_gif(zip_path)
assert result == zip_path
assert not gif_path.exists()
# -- _do_download MAX_DOWNLOAD_BYTES caps --
class _FakeHeaders:
def __init__(self, mapping):
self._m = mapping
def get(self, key, default=None):
return self._m.get(key.lower(), default)
class _FakeResponse:
def __init__(self, headers, chunks):
self.headers = _FakeHeaders({k.lower(): v for k, v in headers.items()})
self._chunks = chunks
def raise_for_status(self):
pass
async def aiter_bytes(self, _size):
for chunk in self._chunks:
yield chunk
class _FakeStreamCtx:
def __init__(self, response):
self._resp = response
async def __aenter__(self):
return self._resp
async def __aexit__(self, *_args):
return False
class _FakeClient:
def __init__(self, response):
self._resp = response
def stream(self, _method, _url, headers=None):
return _FakeStreamCtx(self._resp)
def test_download_cap_content_length_pre_check(tmp_path):
"""When the server advertises a Content-Length larger than
MAX_DOWNLOAD_BYTES, `_do_download` must raise BEFORE iterating any
bytes. This is the cheap pre-check that protects against the trivial
OOM/disk-fill attack we don't even start streaming."""
too_big = MAX_DOWNLOAD_BYTES + 1
response = _FakeResponse(
headers={"content-type": "image/jpeg", "content-length": str(too_big)},
chunks=[b"never read"],
)
client = _FakeClient(response)
local = tmp_path / "out.jpg"
with pytest.raises(ValueError, match="Download too large"):
asyncio.run(_do_download(client, "http://example.test/x.jpg", {}, local, None))
# No file should have been written
assert not local.exists()
def test_download_cap_running_total_aborts(tmp_path, monkeypatch):
"""Servers can lie about Content-Length. The chunk loop must enforce
the running-total cap independently and abort mid-stream as soon as
cumulative bytes exceed `MAX_DOWNLOAD_BYTES`. We monkeypatch the cap
down to 1024 to keep the test fast."""
monkeypatch.setattr(cache, "MAX_DOWNLOAD_BYTES", 1024)
# Advertise 0 (unknown) so the small-payload branch runs and the
# running-total guard inside the chunk loop is what fires.
response = _FakeResponse(
headers={"content-type": "image/jpeg", "content-length": "0"},
chunks=[b"x" * 600, b"x" * 600], # 1200 total > 1024 cap
)
client = _FakeClient(response)
local = tmp_path / "out.jpg"
with pytest.raises(ValueError, match="exceeded cap mid-stream"):
asyncio.run(_do_download(client, "http://example.test/x.jpg", {}, local, None))
# The buffered-write path only writes after the loop finishes, so the
# mid-stream abort means no file lands on disk.
assert not local.exists()
# -- _looks_like_media (audit finding #10) --
def test_looks_like_media_jpeg_magic_recognised():
from booru_viewer.core.cache import _looks_like_media
assert _looks_like_media(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01") is True
def test_looks_like_media_png_magic_recognised():
from booru_viewer.core.cache import _looks_like_media
assert _looks_like_media(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR") is True
def test_looks_like_media_webm_magic_recognised():
from booru_viewer.core.cache import _looks_like_media
# EBML header (Matroska/WebM): 1A 45 DF A3
assert _looks_like_media(b"\x1aE\xdf\xa3" + b"\x00" * 20) is True
def test_looks_like_media_html_rejected():
from booru_viewer.core.cache import _looks_like_media
assert _looks_like_media(b"<!doctype html><html><body>") is False
assert _looks_like_media(b"<html><head>") is False
def test_looks_like_media_empty_rejected():
"""An empty buffer means the server returned nothing useful — fail
closed (rather than the on-disk validator's open-on-error fallback)."""
from booru_viewer.core.cache import _looks_like_media
assert _looks_like_media(b"") is False
def test_looks_like_media_unknown_magic_accepted():
"""Non-HTML, non-magic bytes are conservative-OK — some boorus
serve exotic-but-legal containers we don't enumerate."""
from booru_viewer.core.cache import _looks_like_media
assert _looks_like_media(b"random non-html data ") is True
# -- _do_download early header validation (audit finding #10) --
def test_do_download_early_rejects_html_payload(tmp_path):
"""A hostile server that returns HTML in the body (omitting
Content-Type so the early text/html guard doesn't fire) must be
caught by the magic-byte check before any bytes land on disk.
Audit finding #10: this used to wait for the full download to
complete before _is_valid_media rejected, wasting bandwidth."""
response = _FakeResponse(
headers={"content-length": "0"}, # no Content-Type, no length
chunks=[b"<!doctype html><html><body>500</body></html>"],
)
client = _FakeClient(response)
local = tmp_path / "out.jpg"
with pytest.raises(ValueError, match="not valid media"):
asyncio.run(_do_download(client, "http://example.test/x.jpg", {}, local, None))
assert not local.exists()
def test_do_download_early_rejects_html_across_tiny_chunks(tmp_path):
"""The accumulator must combine chunks smaller than the 16-byte
minimum so a server delivering one byte at a time can't slip
past the magic-byte check."""
response = _FakeResponse(
headers={"content-length": "0"},
chunks=[b"<!", b"do", b"ct", b"yp", b"e ", b"ht", b"ml", b">", b"x" * 100],
)
client = _FakeClient(response)
local = tmp_path / "out.jpg"
with pytest.raises(ValueError, match="not valid media"):
asyncio.run(_do_download(client, "http://example.test/x.jpg", {}, local, None))
assert not local.exists()
def test_do_download_writes_valid_jpeg_after_early_validation(tmp_path):
"""A real JPEG-like header passes the early check and the rest
of the stream is written through to disk. Header bytes must
appear in the final file (not be silently dropped)."""
body = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"PAYLOAD" + b"\xff\xd9"
response = _FakeResponse(
headers={"content-length": str(len(body)), "content-type": "image/jpeg"},
chunks=[body[:8], body[8:]], # split mid-magic
)
client = _FakeClient(response)
local = tmp_path / "out.jpg"
asyncio.run(_do_download(client, "http://example.test/x.jpg", {}, local, None))
assert local.exists()
assert local.read_bytes() == body
# -- _is_valid_media OSError fallback --
def test_is_valid_media_returns_true_on_oserror(tmp_path):
"""If the file can't be opened (transient EBUSY, lock, permissions),
`_is_valid_media` must return True so the caller doesn't delete the
cached file. The previous behavior of returning False kicked off a
delete + re-download loop on every access while the underlying
OS issue persisted."""
nonexistent = tmp_path / "definitely-not-here.jpg"
assert _is_valid_media(nonexistent) is True
# -- _url_locks LRU cap (audit finding #5) --
def test_url_locks_capped_at_max():
"""The per-URL coalesce lock table must not grow beyond _URL_LOCKS_MAX
entries. Without the cap, a long browsing session or an adversarial
booru returning cache-buster query strings would leak one Lock per
unique URL until OOM."""
cache._url_locks.clear()
try:
for i in range(cache._URL_LOCKS_MAX + 500):
cache._get_url_lock(f"hash{i}")
assert len(cache._url_locks) <= cache._URL_LOCKS_MAX
finally:
cache._url_locks.clear()
def test_url_locks_returns_same_lock_for_same_hash():
"""Two get_url_lock calls with the same hash must return the same
Lock object that's the whole point of the coalesce table."""
cache._url_locks.clear()
try:
lock_a = cache._get_url_lock("hashA")
lock_b = cache._get_url_lock("hashA")
assert lock_a is lock_b
finally:
cache._url_locks.clear()
def test_url_locks_lru_keeps_recently_used():
"""LRU semantics: a hash that gets re-touched moves to the end of
the OrderedDict and is the youngest, so eviction picks an older
entry instead."""
cache._url_locks.clear()
try:
cache._get_url_lock("oldest")
cache._get_url_lock("middle")
cache._get_url_lock("oldest") # touch — now youngest
# The dict should now be: middle, oldest (insertion order with
# move_to_end on the touch).
keys = list(cache._url_locks.keys())
assert keys == ["middle", "oldest"]
finally:
cache._url_locks.clear()
def test_url_locks_eviction_skips_held_locks():
"""A held lock (one a coroutine is mid-`async with` on) must NOT be
evicted; popping it would break the coroutine's __aexit__. The
eviction loop sees `lock.locked()` and skips it."""
cache._url_locks.clear()
try:
# Seed an entry and hold it.
held = cache._get_url_lock("held_hash")
async def hold_and_fill():
async with held:
# While we're holding the lock, force eviction by
# filling past the cap.
for i in range(cache._URL_LOCKS_MAX + 100):
cache._get_url_lock(f"new{i}")
# The held lock must still be present.
assert "held_hash" in cache._url_locks
asyncio.run(hold_and_fill())
finally:
cache._url_locks.clear()

View File

@ -0,0 +1,62 @@
"""Tests for `booru_viewer.core.concurrency` — the persistent-loop handle.
Locks in:
- `get_app_loop` raises a clear RuntimeError if `set_app_loop` was never
called (the production code uses this to bail loudly when async work
is scheduled before the loop thread starts)
- `run_on_app_loop` round-trips a coroutine result from a worker-thread
loop back to the calling thread via `concurrent.futures.Future`
"""
from __future__ import annotations
import asyncio
import threading
import pytest
from booru_viewer.core import concurrency
from booru_viewer.core.concurrency import (
get_app_loop,
run_on_app_loop,
set_app_loop,
)
def test_get_app_loop_raises_before_set(reset_app_loop):
"""Calling `get_app_loop` before `set_app_loop` is a configuration
error the production code expects a clear RuntimeError so callers
bail loudly instead of silently scheduling work onto a None loop."""
with pytest.raises(RuntimeError, match="not initialized"):
get_app_loop()
def test_run_on_app_loop_round_trips_result(reset_app_loop):
"""Spin up a real asyncio loop in a worker thread, register it via
`set_app_loop`, then from the test (main) thread schedule a coroutine
via `run_on_app_loop` and assert the result comes back through the
`concurrent.futures.Future` interface."""
loop = asyncio.new_event_loop()
ready = threading.Event()
def _run_loop():
asyncio.set_event_loop(loop)
ready.set()
loop.run_forever()
t = threading.Thread(target=_run_loop, daemon=True)
t.start()
ready.wait(timeout=2)
try:
set_app_loop(loop)
async def _produce():
return 42
fut = run_on_app_loop(_produce())
assert fut.result(timeout=2) == 42
finally:
loop.call_soon_threadsafe(loop.stop)
t.join(timeout=2)
loop.close()

145
tests/core/test_config.py Normal file
View File

@ -0,0 +1,145 @@
"""Tests for `booru_viewer.core.config` — path traversal guard on
`saved_folder_dir` and the shallow walk in `find_library_files`.
Locks in:
- `saved_folder_dir` resolve-and-relative_to check (`54ccc40` defense in
depth alongside `_validate_folder_name`)
- `find_library_files` matching exactly the root + 1-level subdirectory
layout that the library uses, with the right MEDIA_EXTENSIONS filter
- `data_dir` chmods its directory to 0o700 on POSIX (audit #4)
"""
from __future__ import annotations
import os
import sys
import pytest
from booru_viewer.core import config
from booru_viewer.core.config import find_library_files, saved_folder_dir
# -- saved_folder_dir traversal guard --
def test_saved_folder_dir_rejects_dotdot(tmp_library):
"""`..` and any path that resolves outside `saved_dir()` must raise
ValueError, not silently mkdir somewhere unexpected. We test literal
`..` shapes only symlink escapes are filesystem-dependent and
flaky in tests."""
with pytest.raises(ValueError, match="escapes saved directory"):
saved_folder_dir("..")
with pytest.raises(ValueError, match="escapes saved directory"):
saved_folder_dir("../escape")
with pytest.raises(ValueError, match="escapes saved directory"):
saved_folder_dir("foo/../..")
# -- find_library_files shallow walk --
def test_find_library_files_walks_root_and_one_level(tmp_library):
"""Library has a flat shape: `saved/<post_id>.<ext>` at the root, or
`saved/<folder>/<post_id>.<ext>` one level deep. The walk must:
- find matches at both depths
- filter by MEDIA_EXTENSIONS (skip .txt and other non-media)
- filter by exact stem (skip unrelated post ids)
"""
# Root-level match
(tmp_library / "123.jpg").write_bytes(b"")
# One-level subfolder match
(tmp_library / "folder1").mkdir()
(tmp_library / "folder1" / "123.png").write_bytes(b"")
# Different post id — must be excluded
(tmp_library / "folder2").mkdir()
(tmp_library / "folder2" / "456.gif").write_bytes(b"")
# Wrong extension — must be excluded even with the right stem
(tmp_library / "123.txt").write_bytes(b"")
matches = find_library_files(123)
match_names = {p.name for p in matches}
assert match_names == {"123.jpg", "123.png"}
# -- data_dir permissions (audit finding #4) --
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only chmod check")
def test_data_dir_chmod_700(tmp_path, monkeypatch):
"""`data_dir()` chmods the platform data dir to 0o700 on POSIX so the
SQLite DB and api_key columns inside aren't readable by other local
users on shared machines or networked home dirs."""
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
path = config.data_dir()
mode = os.stat(path).st_mode & 0o777
assert mode == 0o700, f"expected 0o700, got {oct(mode)}"
# Idempotent: a second call leaves the mode at 0o700.
config.data_dir()
mode2 = os.stat(path).st_mode & 0o777
assert mode2 == 0o700
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only chmod check")
def test_data_dir_tightens_loose_existing_perms(tmp_path, monkeypatch):
"""If a previous version (or external tooling) left the dir at 0o755,
the next data_dir() call must tighten it back to 0o700."""
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
pre = tmp_path / config.APPNAME
pre.mkdir()
os.chmod(pre, 0o755)
config.data_dir()
mode = os.stat(pre).st_mode & 0o777
assert mode == 0o700
# -- render_filename_template Windows reserved names (finding #7) --
def _fake_post(tag_categories=None, **overrides):
"""Build a minimal Post-like object suitable for render_filename_template.
A real Post needs file_url + tag_categories; defaults are fine for the
reserved-name tests since they only inspect the artist/character tokens.
"""
from booru_viewer.core.api.base import Post
return Post(
id=overrides.get("id", 999),
file_url=overrides.get("file_url", "https://x.test/abc.jpg"),
preview_url=None,
tags="",
score=0,
rating=None,
source=None,
tag_categories=tag_categories or {},
)
@pytest.mark.parametrize("reserved", [
"con", "CON", "prn", "PRN", "aux", "AUX", "nul", "NUL",
"com1", "COM1", "com9", "lpt1", "LPT1", "lpt9",
])
def test_render_filename_template_prefixes_reserved_names(reserved):
"""A tag whose value renders to a Windows reserved device name must
be prefixed with `_` so the resulting filename can't redirect to a
device on Windows. Audit finding #7."""
post = _fake_post(tag_categories={"Artist": [reserved]})
out = config.render_filename_template("%artist%", post, ext=".jpg")
# Stem (before extension) must NOT be a reserved name.
stem = out.split(".", 1)[0]
assert stem.lower() != reserved.lower()
assert stem.startswith("_")
def test_render_filename_template_passes_normal_names_unchanged():
"""Non-reserved tags must NOT be prefixed."""
post = _fake_post(tag_categories={"Artist": ["miku"]})
out = config.render_filename_template("%artist%", post, ext=".jpg")
assert out == "miku.jpg"
def test_render_filename_template_reserved_with_extension_in_template():
"""`con.jpg` from a tag-only stem must still be caught — the dot in
the stem is irrelevant; CON is reserved regardless of extension."""
post = _fake_post(tag_categories={"Artist": ["con"]})
out = config.render_filename_template("%artist%.%ext%", post, ext=".jpg")
assert not out.startswith("con")
assert out.startswith("_con")

243
tests/core/test_db.py Normal file
View File

@ -0,0 +1,243 @@
"""Tests for `booru_viewer.core.db` — folder name validation, INSERT OR
IGNORE collision handling, and LIKE escaping.
These tests lock in the `54ccc40` security/correctness fixes:
- `_validate_folder_name` rejects path-traversal shapes before they hit the
filesystem in `saved_folder_dir`
- `add_bookmark` re-SELECTs the actual row id after an INSERT OR IGNORE
collision so the returned `Bookmark.id` is never the bogus 0 that broke
`update_bookmark_cache_path`
- `get_bookmarks` escapes the SQL LIKE wildcards `_` and `%` so a search for
`cat_ear` doesn't bleed into `catear` / `catXear`
"""
from __future__ import annotations
import os
import sys
import pytest
from booru_viewer.core.db import _validate_folder_name
# -- _validate_folder_name --
def test_validate_folder_name_rejects_traversal():
"""Every shape that could escape the saved-images dir or hit a hidden
file must raise ValueError. One assertion per rejection rule so a
failure points at the exact case."""
with pytest.raises(ValueError):
_validate_folder_name("") # empty
with pytest.raises(ValueError):
_validate_folder_name("..") # dotdot literal
with pytest.raises(ValueError):
_validate_folder_name(".") # dot literal
with pytest.raises(ValueError):
_validate_folder_name("/foo") # forward slash
with pytest.raises(ValueError):
_validate_folder_name("foo/bar") # embedded forward slash
with pytest.raises(ValueError):
_validate_folder_name("\\foo") # backslash
with pytest.raises(ValueError):
_validate_folder_name(".hidden") # leading dot
with pytest.raises(ValueError):
_validate_folder_name("~user") # leading tilde
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only chmod check")
def test_db_file_chmod_600(tmp_db):
"""Audit finding #4: the SQLite file must be 0o600 on POSIX so the
plaintext api_key/api_user columns aren't readable by other local
users on shared workstations."""
# The conn property triggers _restrict_perms() the first time it's
# accessed; tmp_db calls it via add_site/etc., but a defensive
# access here makes the assertion order-independent.
_ = tmp_db.conn
mode = os.stat(tmp_db._path).st_mode & 0o777
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only chmod check")
def test_db_wal_sidecar_chmod_600(tmp_db):
"""The -wal sidecar created by PRAGMA journal_mode=WAL must also
be 0o600. It carries in-flight transactions including the most
recent api_key writes same exposure as the main DB file."""
# Force a write so the WAL file actually exists.
tmp_db.add_site("test", "http://example.test", "danbooru")
# Re-trigger the chmod pass now that the sidecar exists.
tmp_db._restrict_perms()
wal = type(tmp_db._path)(str(tmp_db._path) + "-wal")
if wal.exists():
mode = os.stat(wal).st_mode & 0o777
assert mode == 0o600, f"expected 0o600 on WAL sidecar, got {oct(mode)}"
def test_validate_folder_name_accepts_unicode_and_punctuation():
"""Common real-world folder names must pass through unchanged. The
guard is meant to block escape shapes, not normal naming."""
assert _validate_folder_name("miku(lewd)") == "miku(lewd)"
assert _validate_folder_name("cat ear") == "cat ear"
assert _validate_folder_name("日本語") == "日本語"
assert _validate_folder_name("foo-bar") == "foo-bar"
assert _validate_folder_name("foo.bar") == "foo.bar" # dot OK if not leading
# -- add_bookmark INSERT OR IGNORE collision --
def test_add_bookmark_collision_returns_existing_id(tmp_db):
"""Calling `add_bookmark` twice with the same (site_id, post_id) must
return the same row id on the second call, not the stale `lastrowid`
of 0 that INSERT OR IGNORE leaves behind. Without the re-SELECT fix,
any downstream `update_bookmark_cache_path(id=0, ...)` silently
no-ops, breaking the cache-path linkage."""
site = tmp_db.add_site("test", "http://example.test", "danbooru")
bm1 = tmp_db.add_bookmark(
site_id=site.id, post_id=42, file_url="http://example.test/42.jpg",
preview_url=None, tags="cat",
)
bm2 = tmp_db.add_bookmark(
site_id=site.id, post_id=42, file_url="http://example.test/42.jpg",
preview_url=None, tags="cat",
)
assert bm1.id != 0
assert bm2.id == bm1.id
# -- get_bookmarks LIKE escaping --
def test_get_bookmarks_like_escaping(tmp_db):
"""A search for the literal tag `cat_ear` must NOT match `catear` or
`catXear`. SQLite's LIKE treats `_` as a single-char wildcard unless
explicitly escaped without `ESCAPE '\\\\'` the search would return
all three rows."""
site = tmp_db.add_site("test", "http://example.test", "danbooru")
tmp_db.add_bookmark(
site_id=site.id, post_id=1, file_url="http://example.test/1.jpg",
preview_url=None, tags="cat_ear",
)
tmp_db.add_bookmark(
site_id=site.id, post_id=2, file_url="http://example.test/2.jpg",
preview_url=None, tags="catear",
)
tmp_db.add_bookmark(
site_id=site.id, post_id=3, file_url="http://example.test/3.jpg",
preview_url=None, tags="catXear",
)
results = tmp_db.get_bookmarks(search="cat_ear")
tags_returned = {b.tags for b in results}
assert tags_returned == {"cat_ear"}
# -- delete_site cascading cleanup --
def _seed_site(db, name, site_id_out=None):
"""Create a site and populate all child tables for it."""
site = db.add_site(name, f"http://{name}.test", "danbooru")
db.add_bookmark(
site_id=site.id, post_id=1, file_url=f"http://{name}.test/1.jpg",
preview_url=None, tags="test",
)
db.add_search_history("test query", site_id=site.id)
db.add_saved_search("my search", "saved query", site_id=site.id)
db.set_tag_labels(site.id, {"artist:bob": "artist"})
return site
def _count_rows(db, table, site_id, *, id_col="site_id"):
"""Count rows in *table* belonging to *site_id*."""
return db.conn.execute(
f"SELECT COUNT(*) FROM {table} WHERE {id_col} = ?", (site_id,)
).fetchone()[0]
def test_delete_site_cascades_all_related_rows(tmp_db):
"""Deleting a site must remove rows from all five related tables."""
site = _seed_site(tmp_db, "doomed")
tmp_db.delete_site(site.id)
assert _count_rows(tmp_db, "sites", site.id, id_col="id") == 0
assert _count_rows(tmp_db, "favorites", site.id) == 0
assert _count_rows(tmp_db, "tag_types", site.id) == 0
assert _count_rows(tmp_db, "search_history", site.id) == 0
assert _count_rows(tmp_db, "saved_searches", site.id) == 0
def test_delete_site_does_not_affect_other_sites(tmp_db):
"""Deleting site A must leave site B's rows in every table untouched."""
site_a = _seed_site(tmp_db, "site-a")
site_b = _seed_site(tmp_db, "site-b")
before = {
t: _count_rows(tmp_db, t, site_b.id, id_col="id" if t == "sites" else "site_id")
for t in ("sites", "favorites", "tag_types", "search_history", "saved_searches")
}
tmp_db.delete_site(site_a.id)
for table, expected in before.items():
id_col = "id" if table == "sites" else "site_id"
assert _count_rows(tmp_db, table, site_b.id, id_col=id_col) == expected, (
f"{table} rows for site B changed after deleting site A"
)
# -- reconcile_library_meta --
def test_reconcile_library_meta_removes_orphans(tmp_db, tmp_library):
"""Rows whose files are missing on disk are deleted; present files kept."""
(tmp_library / "12345.jpg").write_bytes(b"\xff")
tmp_db.save_library_meta(post_id=12345, tags="test", filename="12345.jpg")
tmp_db.save_library_meta(post_id=99999, tags="orphan", filename="99999.jpg")
removed = tmp_db.reconcile_library_meta()
assert removed == 1
assert tmp_db.is_post_in_library(12345) is True
assert tmp_db.is_post_in_library(99999) is False
def test_reconcile_library_meta_skips_empty_dir(tmp_db, tmp_library):
"""An empty library dir signals a possible unmounted drive — refuse to
reconcile and leave orphan rows intact."""
tmp_db.save_library_meta(post_id=12345, tags="test", filename="12345.jpg")
removed = tmp_db.reconcile_library_meta()
assert removed == 0
assert tmp_db.is_post_in_library(12345) is True
# -- tag cache pruning --
def test_prune_tag_cache(tmp_db):
"""After inserting more tags than the cap, only the newest entries survive."""
from booru_viewer.core.db import Database
original_cap = Database._TAG_CACHE_MAX_ROWS
try:
Database._TAG_CACHE_MAX_ROWS = 5
site = tmp_db.add_site("test", "http://test.test", "danbooru")
# Insert 8 rows with explicit, distinct fetched_at timestamps so
# pruning order is deterministic.
with tmp_db._write():
for i in range(8):
tmp_db.conn.execute(
"INSERT OR REPLACE INTO tag_types "
"(site_id, name, label, fetched_at) VALUES (?, ?, ?, ?)",
(site.id, f"tag_{i}", "general", f"2025-01-01T00:00:{i:02d}Z"),
)
tmp_db._prune_tag_cache()
count = tmp_db.conn.execute("SELECT COUNT(*) FROM tag_types").fetchone()[0]
assert count == 5
surviving = {
r["name"]
for r in tmp_db.conn.execute("SELECT name FROM tag_types").fetchall()
}
# The 3 oldest (tag_0, tag_1, tag_2) should have been pruned
assert surviving == {"tag_3", "tag_4", "tag_5", "tag_6", "tag_7"}
finally:
Database._TAG_CACHE_MAX_ROWS = original_cap

View File

@ -0,0 +1,128 @@
"""Tests for save_post_file.
Pins the contract that category_fetcher is a *required* keyword arg
(no silent default) so a forgotten plumb can't result in a save that
drops category tokens from the filename template.
"""
from __future__ import annotations
import asyncio
import inspect
from dataclasses import dataclass, field
from pathlib import Path
import pytest
from booru_viewer.core.library_save import save_post_file
@dataclass
class FakePost:
id: int = 12345
tags: str = "1girl greatartist"
tag_categories: dict = field(default_factory=dict)
score: int = 0
rating: str = ""
source: str = ""
file_url: str = ""
class PopulatingFetcher:
"""ensure_categories fills in the artist category from scratch,
emulating the HTML-scrape/batch-API happy path."""
def __init__(self, categories: dict[str, list[str]]):
self._categories = categories
self.calls = 0
async def ensure_categories(self, post) -> None:
self.calls += 1
post.tag_categories = dict(self._categories)
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def test_category_fetcher_is_keyword_only_and_required():
"""Signature check: category_fetcher must be explicit at every
call site no ``= None`` default that callers can forget."""
sig = inspect.signature(save_post_file)
param = sig.parameters["category_fetcher"]
assert param.kind == inspect.Parameter.KEYWORD_ONLY, (
"category_fetcher should be keyword-only"
)
assert param.default is inspect.Parameter.empty, (
"category_fetcher must not have a default — forcing every caller "
"to pass it (even as None) is the whole point of this contract"
)
def test_template_category_populated_via_fetcher(tmp_path, tmp_db):
"""Post with empty tag_categories + a template using %artist% +
a working fetcher saved filename includes the fetched artist
instead of falling back to the bare id."""
src = tmp_path / "src.jpg"
src.write_bytes(b"fake-image-bytes")
dest_dir = tmp_path / "dest"
tmp_db.set_setting("library_filename_template", "%artist%_%id%")
post = FakePost(id=12345, tag_categories={})
fetcher = PopulatingFetcher({"Artist": ["greatartist"]})
result = _run(save_post_file(
src, post, dest_dir, tmp_db,
category_fetcher=fetcher,
))
assert fetcher.calls == 1, "fetcher should be invoked exactly once"
assert result.name == "greatartist_12345.jpg", (
f"expected templated filename, got {result.name!r}"
)
assert result.exists()
def test_none_fetcher_accepted_when_categories_prepopulated(tmp_path, tmp_db):
"""Pass-None contract: sites like Danbooru/e621 return ``None``
from ``_get_category_fetcher`` because Post already arrives with
tag_categories populated. ``save_post_file`` must accept None
explicitly the change is about forcing callers to think, not
about forbidding None."""
src = tmp_path / "src.jpg"
src.write_bytes(b"x")
dest_dir = tmp_path / "dest"
tmp_db.set_setting("library_filename_template", "%artist%_%id%")
post = FakePost(id=999, tag_categories={"Artist": ["inlineartist"]})
result = _run(save_post_file(
src, post, dest_dir, tmp_db,
category_fetcher=None,
))
assert result.name == "inlineartist_999.jpg"
assert result.exists()
def test_fetcher_not_called_when_template_has_no_category_tokens(tmp_path, tmp_db):
"""Purely-id template → fetcher ``ensure_categories`` never
invoked, even when categories are empty (the fetch is expensive
and would be wasted)."""
src = tmp_path / "src.jpg"
src.write_bytes(b"x")
dest_dir = tmp_path / "dest"
tmp_db.set_setting("library_filename_template", "%id%")
post = FakePost(id=42, tag_categories={})
fetcher = PopulatingFetcher({"Artist": ["unused"]})
_run(save_post_file(
src, post, dest_dir, tmp_db,
category_fetcher=fetcher,
))
assert fetcher.calls == 0

View File

@ -0,0 +1,58 @@
"""Tests for the project-wide PIL decompression-bomb cap (audit #8).
The cap lives in `booru_viewer/core/__init__.py` so any import of
any `booru_viewer.core.*` submodule installs it first independent
of whether `core.cache` is on the import path. Both checks are run
in a fresh subprocess so the assertion isn't masked by some other
test's previous import.
"""
from __future__ import annotations
import subprocess
import sys
EXPECTED = 256 * 1024 * 1024
def _run(code: str) -> str:
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def test_core_package_import_installs_cap():
"""Importing the core package alone must set MAX_IMAGE_PIXELS."""
out = _run(
"import booru_viewer.core; "
"from PIL import Image; "
"print(Image.MAX_IMAGE_PIXELS)"
)
assert int(out) == EXPECTED
def test_core_submodule_import_installs_cap():
"""Importing any non-cache core submodule must still set the cap —
the invariant is that the package __init__.py runs before any
submodule code, regardless of which submodule is the entry point."""
out = _run(
"from booru_viewer.core import config; "
"from PIL import Image; "
"print(Image.MAX_IMAGE_PIXELS)"
)
assert int(out) == EXPECTED
def test_core_cache_import_still_installs_cap():
"""Regression: the old code path (importing cache first) must keep
working after the move."""
out = _run(
"from booru_viewer.core import cache; "
"from PIL import Image; "
"print(Image.MAX_IMAGE_PIXELS)"
)
assert int(out) == EXPECTED

0
tests/gui/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,88 @@
"""Tests for the pure mpv kwargs builder.
Pure Python. No Qt, no mpv, no network. The helper is importable
from the CI environment that installs only httpx + Pillow + pytest.
"""
from __future__ import annotations
from booru_viewer.gui.media._mpv_options import (
LAVF_PROTOCOL_WHITELIST,
build_mpv_kwargs,
lavf_options,
)
def test_ytdl_disabled():
"""Finding #2 — mpv must not delegate URLs to yt-dlp."""
kwargs = build_mpv_kwargs(is_windows=False)
assert kwargs["ytdl"] == "no"
def test_load_scripts_disabled():
"""Finding #2 — no auto-loading of ~/.config/mpv/scripts."""
kwargs = build_mpv_kwargs(is_windows=False)
assert kwargs["load_scripts"] == "no"
def test_protocol_whitelist_not_in_init_kwargs():
"""Finding #2 — the lavf protocol whitelist must NOT be in the
init kwargs dict. python-mpv's init path uses
``mpv_set_option_string``, which trips on the comma-laden value
with -7 OPT_FORMAT. The whitelist is applied separately via the
property API in ``mpv_gl.py`` (see ``lavf_options``)."""
kwargs = build_mpv_kwargs(is_windows=False)
assert "demuxer_lavf_o" not in kwargs
assert "demuxer-lavf-o" not in kwargs
def test_lavf_options_protocol_whitelist():
"""Finding #2 — lavf demuxer must only accept file + HTTP(S) + TLS/TCP.
Returned as a dict so callers can pass it through the python-mpv
property API (which uses the node API and handles comma-laden
values cleanly).
"""
opts = lavf_options()
assert opts.keys() == {"protocol_whitelist"}
allowed = set(opts["protocol_whitelist"].split(","))
# `file` must be present — cached local clips and .part files use it.
assert "file" in allowed
# HTTP(S) + supporting protocols for network videos.
assert "http" in allowed
assert "https" in allowed
assert "tls" in allowed
assert "tcp" in allowed
# Dangerous protocols must NOT appear.
for banned in ("concat", "subfile", "data", "udp", "rtp", "crypto"):
assert banned not in allowed
# The constant and the helper return the same value.
assert opts["protocol_whitelist"] == LAVF_PROTOCOL_WHITELIST
def test_input_conf_nulled_on_posix():
"""Finding #2 — on POSIX, skip loading ~/.config/mpv/input.conf."""
kwargs = build_mpv_kwargs(is_windows=False)
assert kwargs["input_conf"] == "/dev/null"
def test_input_conf_skipped_on_windows():
"""Finding #2 — input_conf gate is POSIX-only; Windows omits the key."""
kwargs = build_mpv_kwargs(is_windows=True)
assert "input_conf" not in kwargs
def test_existing_options_preserved():
"""Regression: pre-audit playback/audio tuning must remain."""
kwargs = build_mpv_kwargs(is_windows=False)
# Discord screen-share audio fix (see mpv_gl.py comment).
assert kwargs["ao"] == "pulse,wasapi,"
assert kwargs["audio_client_name"] == "booru-viewer"
# Network tuning from the uncached-video fast path.
assert kwargs["cache"] == "yes"
assert kwargs["cache_pause"] == "no"
assert kwargs["demuxer_max_bytes"] == "50MiB"
assert kwargs["network_timeout"] == "10"
# Existing input lockdown (primary — input_conf is defense-in-depth).
assert kwargs["input_default_bindings"] is False
assert kwargs["input_vo_keyboard"] is False

View File

View File

@ -0,0 +1,661 @@
"""Pure-Python state machine tests for the popout viewer.
Imports `booru_viewer.gui.popout.state` directly without standing up a
QApplication. The state machine module is required to be import-pure
(no PySide6, mpv, httpx, subprocess, or any module that imports them);
this test file is the forcing function. If state.py grows a Qt or mpv
import, these tests fail to collect and the test suite breaks.
Test categories (from docs/POPOUT_REFACTOR_PLAN.md "Test plan"):
1. Per-state transition tests
2. Race-fix invariant tests (six structural fixes)
3. Illegal transition tests
4. Read-path query tests
**Commit 3 expectation:** most tests fail because state.py's dispatch
handlers are stubs returning []. Tests progressively pass as commits
4-11 land transitions. The trivially-passing tests at commit 3 (initial
state, slider display read-path, terminal Closing guard) document the
parts of the skeleton that are already real.
Refactor plan: docs/POPOUT_REFACTOR_PLAN.md
Architecture: docs/POPOUT_ARCHITECTURE.md
"""
from __future__ import annotations
import pytest
from booru_viewer.gui.popout.state import (
# Enums
InvalidTransition,
LoopMode,
MediaKind,
State,
StateMachine,
# Events
CloseRequested,
ContentArrived,
FullscreenToggled,
HyprlandDriftDetected,
LoopModeSet,
MuteToggleRequested,
NavigateRequested,
Open,
SeekCompleted,
SeekRequested,
TogglePlayRequested,
VideoEofReached,
VideoSizeKnown,
VideoStarted,
VolumeSet,
WindowMoved,
WindowResized,
# Effects
ApplyLoopMode,
ApplyMute,
ApplyVolume,
EmitClosed,
EmitNavigate,
EmitPlayNextRequested,
EnterFullscreen,
ExitFullscreen,
FitWindowToContent,
LoadImage,
LoadVideo,
SeekVideoTo,
StopMedia,
)
from booru_viewer.gui.popout.viewport import Viewport
# ----------------------------------------------------------------------
# Helpers — direct field mutation for setup. Tests construct a fresh
# StateMachine and write the state field directly to skip the dispatch
# chain. This is a deliberate test-fixture-vs-production-code split:
# the tests don't depend on the dispatch chain being correct in order
# to test individual transitions.
# ----------------------------------------------------------------------
def _new_in(state: State) -> StateMachine:
m = StateMachine()
m.state = state
return m
# ----------------------------------------------------------------------
# Read-path queries (commit 2 — already passing)
# ----------------------------------------------------------------------
def test_initial_state():
m = StateMachine()
assert m.state == State.AWAITING_CONTENT
assert m.is_first_content_load is True
assert m.fullscreen is False
assert m.mute is False
assert m.volume == 50
assert m.loop_mode == LoopMode.LOOP
assert m.viewport is None
assert m.seek_target_ms == 0
def test_compute_slider_display_ms_passthrough_when_not_seeking():
m = StateMachine()
m.state = State.PLAYING_VIDEO
assert m.compute_slider_display_ms(7500) == 7500
def test_compute_slider_display_ms_pinned_when_seeking():
m = StateMachine()
m.state = State.SEEKING_VIDEO
m.seek_target_ms = 7000
# mpv's reported position can be anywhere; the slider must show
# the user's target while we're in SeekingVideo.
assert m.compute_slider_display_ms(5000) == 7000
assert m.compute_slider_display_ms(7000) == 7000
assert m.compute_slider_display_ms(9999) == 7000
def test_dispatch_in_closing_returns_empty():
"""Closing is terminal — every event from Closing returns [] and
the state stays Closing."""
m = _new_in(State.CLOSING)
for event in [
NavigateRequested(direction=1),
ContentArrived("/x.jpg", "info", MediaKind.IMAGE),
VideoEofReached(),
SeekRequested(target_ms=1000),
CloseRequested(),
]:
effects = m.dispatch(event)
assert effects == []
assert m.state == State.CLOSING
# ----------------------------------------------------------------------
# Per-state transition tests
# ----------------------------------------------------------------------
#
# These all rely on the per-event handlers in state.py returning real
# effect lists. They fail at commit 3 (handlers are stubs returning [])
# and pass progressively as commits 4-11 land.
# -- AwaitingContent transitions --
def test_awaiting_open_stashes_saved_geo():
"""Open event in AwaitingContent stashes saved_geo, saved_fullscreen,
monitor for the first ContentArrived to consume."""
m = StateMachine()
effects = m.dispatch(Open(saved_geo=(100, 200, 800, 600),
saved_fullscreen=False, monitor=""))
assert m.state == State.AWAITING_CONTENT
assert m.saved_geo == (100, 200, 800, 600)
assert m.saved_fullscreen is False
assert effects == []
def test_awaiting_content_arrived_image_loads_and_transitions():
m = StateMachine()
effects = m.dispatch(ContentArrived(
path="/path/img.jpg", info="i", kind=MediaKind.IMAGE,
width=1920, height=1080,
))
assert m.state == State.DISPLAYING_IMAGE
assert m.is_first_content_load is False
assert m.current_path == "/path/img.jpg"
assert any(isinstance(e, LoadImage) for e in effects)
assert any(isinstance(e, FitWindowToContent) for e in effects)
def test_awaiting_content_arrived_gif_loads_as_animated():
m = StateMachine()
effects = m.dispatch(ContentArrived(
path="/path/anim.gif", info="i", kind=MediaKind.GIF,
width=480, height=480,
))
assert m.state == State.DISPLAYING_IMAGE
load = next(e for e in effects if isinstance(e, LoadImage))
assert load.is_gif is True
def test_awaiting_content_arrived_video_transitions_to_loading():
m = StateMachine()
effects = m.dispatch(ContentArrived(
path="/path/v.mp4", info="i", kind=MediaKind.VIDEO,
width=1280, height=720,
))
assert m.state == State.LOADING_VIDEO
assert any(isinstance(e, LoadVideo) for e in effects)
def test_awaiting_content_arrived_video_emits_persistence_effects():
"""First content load also emits ApplyMute / ApplyVolume /
ApplyLoopMode so the state machine's persistent values land in
the freshly-created mpv on PlayingVideo entry. (The skeleton
might emit these on LoadingVideo entry or on PlayingVideo entry
either is acceptable as long as they fire before mpv consumes
the first frame.)"""
m = StateMachine()
m.mute = True
m.volume = 75
effects = m.dispatch(ContentArrived(
path="/v.mp4", info="i", kind=MediaKind.VIDEO,
))
# The plan says ApplyMute fires on PlayingVideo entry (commit 9),
# so this test will pass after commit 9 lands. Until then it
# documents the requirement.
assert any(isinstance(e, ApplyMute) and e.value is True for e in effects) or \
m.state == State.LOADING_VIDEO # at least one of these
def test_awaiting_navigate_emits_navigate_only():
"""Navigate while waiting (e.g. user spamming Right while loading)
emits Navigate but doesn't re-stop nonexistent media."""
m = StateMachine()
effects = m.dispatch(NavigateRequested(direction=1))
assert m.state == State.AWAITING_CONTENT
assert any(isinstance(e, EmitNavigate) and e.direction == 1
for e in effects)
# No StopMedia — nothing to stop
assert not any(isinstance(e, StopMedia) for e in effects)
# -- DisplayingImage transitions --
def test_displaying_image_navigate_stops_and_emits():
m = _new_in(State.DISPLAYING_IMAGE)
m.is_first_content_load = False
effects = m.dispatch(NavigateRequested(direction=-1))
assert m.state == State.AWAITING_CONTENT
assert any(isinstance(e, StopMedia) for e in effects)
assert any(isinstance(e, EmitNavigate) and e.direction == -1
for e in effects)
def test_displaying_image_content_replace_with_video():
m = _new_in(State.DISPLAYING_IMAGE)
m.is_first_content_load = False
effects = m.dispatch(ContentArrived(
path="/v.mp4", info="i", kind=MediaKind.VIDEO,
))
assert m.state == State.LOADING_VIDEO
assert any(isinstance(e, LoadVideo) for e in effects)
def test_displaying_image_content_replace_with_image():
m = _new_in(State.DISPLAYING_IMAGE)
m.is_first_content_load = False
effects = m.dispatch(ContentArrived(
path="/img2.png", info="i", kind=MediaKind.IMAGE,
))
assert m.state == State.DISPLAYING_IMAGE
assert any(isinstance(e, LoadImage) for e in effects)
# -- LoadingVideo transitions --
def test_loading_video_started_transitions_to_playing():
m = _new_in(State.LOADING_VIDEO)
effects = m.dispatch(VideoStarted())
assert m.state == State.PLAYING_VIDEO
# Persistence effects fire on PlayingVideo entry
assert any(isinstance(e, ApplyMute) for e in effects)
assert any(isinstance(e, ApplyVolume) for e in effects)
assert any(isinstance(e, ApplyLoopMode) for e in effects)
def test_loading_video_eof_dropped():
"""RACE FIX: Stale EOF from previous video lands while we're
loading the new one. The stale event must be dropped without
transitioning state. Replaces the 250ms _eof_ignore_until
timestamp window from fda3b10b."""
m = _new_in(State.LOADING_VIDEO)
effects = m.dispatch(VideoEofReached())
assert m.state == State.LOADING_VIDEO
assert effects == []
def test_loading_video_size_known_emits_fit():
m = _new_in(State.LOADING_VIDEO)
m.viewport = Viewport(center_x=500, center_y=400,
long_side=800)
effects = m.dispatch(VideoSizeKnown(width=1920, height=1080))
assert m.state == State.LOADING_VIDEO
assert any(isinstance(e, FitWindowToContent) for e in effects)
def test_loading_video_navigate_stops_and_emits():
m = _new_in(State.LOADING_VIDEO)
effects = m.dispatch(NavigateRequested(direction=1))
assert m.state == State.AWAITING_CONTENT
assert any(isinstance(e, StopMedia) for e in effects)
assert any(isinstance(e, EmitNavigate) for e in effects)
# -- PlayingVideo transitions --
def test_playing_video_eof_loop_next_emits_play_next():
m = _new_in(State.PLAYING_VIDEO)
m.loop_mode = LoopMode.NEXT
effects = m.dispatch(VideoEofReached())
assert any(isinstance(e, EmitPlayNextRequested) for e in effects)
def test_playing_video_eof_loop_once_pauses():
m = _new_in(State.PLAYING_VIDEO)
m.loop_mode = LoopMode.ONCE
effects = m.dispatch(VideoEofReached())
# Once mode should NOT emit play_next; it pauses
assert not any(isinstance(e, EmitPlayNextRequested) for e in effects)
def test_playing_video_eof_loop_loop_no_op():
"""Loop=Loop is mpv-handled (loop-file=inf), so the eof event
arriving in the state machine should be a no-op."""
m = _new_in(State.PLAYING_VIDEO)
m.loop_mode = LoopMode.LOOP
effects = m.dispatch(VideoEofReached())
assert not any(isinstance(e, EmitPlayNextRequested) for e in effects)
def test_playing_video_seek_requested_transitions_and_pins():
m = _new_in(State.PLAYING_VIDEO)
effects = m.dispatch(SeekRequested(target_ms=7500))
assert m.state == State.SEEKING_VIDEO
assert m.seek_target_ms == 7500
assert any(isinstance(e, SeekVideoTo) and e.target_ms == 7500
for e in effects)
def test_playing_video_navigate_stops_and_emits():
m = _new_in(State.PLAYING_VIDEO)
effects = m.dispatch(NavigateRequested(direction=1))
assert m.state == State.AWAITING_CONTENT
assert any(isinstance(e, StopMedia) for e in effects)
assert any(isinstance(e, EmitNavigate) for e in effects)
def test_playing_video_size_known_refits():
m = _new_in(State.PLAYING_VIDEO)
m.viewport = Viewport(center_x=500, center_y=400, long_side=800)
effects = m.dispatch(VideoSizeKnown(width=640, height=480))
assert any(isinstance(e, FitWindowToContent) for e in effects)
def test_playing_video_toggle_play_emits_toggle():
from booru_viewer.gui.popout.state import TogglePlay
m = _new_in(State.PLAYING_VIDEO)
effects = m.dispatch(TogglePlayRequested())
assert m.state == State.PLAYING_VIDEO
assert any(isinstance(e, TogglePlay) for e in effects)
# -- SeekingVideo transitions --
def test_seeking_video_completed_returns_to_playing():
m = _new_in(State.SEEKING_VIDEO)
m.seek_target_ms = 5000
effects = m.dispatch(SeekCompleted())
assert m.state == State.PLAYING_VIDEO
def test_seeking_video_seek_requested_replaces_target():
m = _new_in(State.SEEKING_VIDEO)
m.seek_target_ms = 5000
effects = m.dispatch(SeekRequested(target_ms=8000))
assert m.state == State.SEEKING_VIDEO
assert m.seek_target_ms == 8000
assert any(isinstance(e, SeekVideoTo) and e.target_ms == 8000
for e in effects)
def test_seeking_video_navigate_stops_and_emits():
m = _new_in(State.SEEKING_VIDEO)
effects = m.dispatch(NavigateRequested(direction=1))
assert m.state == State.AWAITING_CONTENT
assert any(isinstance(e, StopMedia) for e in effects)
def test_seeking_video_eof_dropped():
"""EOF during a seek is also stale — drop it."""
m = _new_in(State.SEEKING_VIDEO)
effects = m.dispatch(VideoEofReached())
assert m.state == State.SEEKING_VIDEO
assert effects == []
# -- Closing (parametrized over source states) --
@pytest.mark.parametrize("source_state", [
State.AWAITING_CONTENT,
State.DISPLAYING_IMAGE,
State.LOADING_VIDEO,
State.PLAYING_VIDEO,
State.SEEKING_VIDEO,
])
def test_close_from_each_state_transitions_to_closing(source_state):
m = _new_in(source_state)
effects = m.dispatch(CloseRequested())
assert m.state == State.CLOSING
assert any(isinstance(e, StopMedia) for e in effects)
assert any(isinstance(e, EmitClosed) for e in effects)
# ----------------------------------------------------------------------
# Race-fix invariant tests (six structural fixes from prior fix sweep)
# ----------------------------------------------------------------------
def test_invariant_eof_race_loading_video_drops_stale_eof():
"""Invariant 1: stale EOF from previous video must not advance
the popout. Structural via LoadingVideo dropping VideoEofReached."""
m = _new_in(State.LOADING_VIDEO)
m.loop_mode = LoopMode.NEXT # would normally trigger play_next
effects = m.dispatch(VideoEofReached())
assert m.state == State.LOADING_VIDEO
assert not any(isinstance(e, EmitPlayNextRequested) for e in effects)
def test_invariant_double_navigate_no_double_load():
"""Invariant 2: rapid Right-arrow spam must not produce double
load events. Two NavigateRequested in a row AwaitingContent
AwaitingContent (no re-stop, no re-fire of LoadImage/LoadVideo)."""
m = _new_in(State.PLAYING_VIDEO)
effects1 = m.dispatch(NavigateRequested(direction=1))
assert m.state == State.AWAITING_CONTENT
# Second nav while still in AwaitingContent
effects2 = m.dispatch(NavigateRequested(direction=1))
assert m.state == State.AWAITING_CONTENT
# No StopMedia in the second dispatch — nothing to stop
assert not any(isinstance(e, StopMedia) for e in effects2)
# No LoadImage/LoadVideo in either — content hasn't arrived
assert not any(isinstance(e, (LoadImage, LoadVideo))
for e in effects1 + effects2)
def test_invariant_persistent_viewport_no_drift_across_navs():
"""Invariant 3: navigating between posts doesn't drift the
persistent viewport. Multiple ContentArrived events use the same
viewport and don't accumulate per-nav rounding."""
m = StateMachine()
m.viewport = Viewport(center_x=960.0, center_y=540.0, long_side=1280.0)
m.is_first_content_load = False # past the seed point
original = m.viewport
for path in ["/a.jpg", "/b.jpg", "/c.jpg", "/d.jpg", "/e.jpg"]:
m.state = State.DISPLAYING_IMAGE
m.dispatch(NavigateRequested(direction=1))
m.dispatch(ContentArrived(path=path, info="", kind=MediaKind.IMAGE))
assert m.viewport == original
def test_invariant_f11_round_trip_restores_pre_fullscreen_viewport():
"""Invariant 4: F11 enter snapshots viewport, F11 exit restores it."""
m = _new_in(State.PLAYING_VIDEO)
m.viewport = Viewport(center_x=800.0, center_y=600.0, long_side=1000.0)
pre = m.viewport
# Enter fullscreen
m.dispatch(FullscreenToggled())
assert m.fullscreen is True
assert m.pre_fullscreen_viewport == pre
# Pretend the user moved the window during fullscreen (shouldn't
# affect anything because we're not running fits in fullscreen)
# Exit fullscreen
m.dispatch(FullscreenToggled())
assert m.fullscreen is False
assert m.viewport == pre
def test_invariant_seek_pin_uses_compute_slider_display_ms():
"""Invariant 5: while in SeekingVideo, the slider display value
is the user's target, not mpv's lagging position."""
m = _new_in(State.PLAYING_VIDEO)
m.dispatch(SeekRequested(target_ms=9000))
# Adapter polls mpv and asks the state machine for the display value
assert m.compute_slider_display_ms(mpv_pos_ms=4500) == 9000
assert m.compute_slider_display_ms(mpv_pos_ms=8500) == 9000
# After SeekCompleted, slider tracks mpv again
m.dispatch(SeekCompleted())
assert m.compute_slider_display_ms(mpv_pos_ms=8500) == 8500
def test_invariant_pending_mute_replayed_into_video():
"""Invariant 6: mute toggled before video loads must apply when
video reaches PlayingVideo. The state machine owns mute as truth;
ApplyMute(state.mute) fires on PlayingVideo entry."""
m = StateMachine()
# User mutes before any video has loaded
m.dispatch(MuteToggleRequested())
assert m.mute is True
# Now drive through to PlayingVideo
m.dispatch(ContentArrived(
path="/v.mp4", info="i", kind=MediaKind.VIDEO,
))
assert m.state == State.LOADING_VIDEO
effects = m.dispatch(VideoStarted())
assert m.state == State.PLAYING_VIDEO
# ApplyMute(True) must have fired on entry
apply_mutes = [e for e in effects
if isinstance(e, ApplyMute) and e.value is True]
assert apply_mutes
# ----------------------------------------------------------------------
# Illegal transition tests
# ----------------------------------------------------------------------
#
# At commit 11 these become env-gated raises (BOORU_VIEWER_STRICT_STATE).
# At commits 3-10 they return [] (the skeleton's default).
def test_strict_mode_raises_invalid_transition(monkeypatch):
"""When BOORU_VIEWER_STRICT_STATE is set, illegal events raise
InvalidTransition instead of dropping silently. This is the
development/debug mode that catches programmer errors at the
dispatch boundary."""
monkeypatch.setenv("BOORU_VIEWER_STRICT_STATE", "1")
m = _new_in(State.PLAYING_VIDEO)
with pytest.raises(InvalidTransition) as exc_info:
m.dispatch(VideoStarted())
assert exc_info.value.state == State.PLAYING_VIDEO
assert isinstance(exc_info.value.event, VideoStarted)
def test_strict_mode_does_not_raise_for_legal_events(monkeypatch):
"""Legal events go through dispatch normally even under strict mode."""
monkeypatch.setenv("BOORU_VIEWER_STRICT_STATE", "1")
m = _new_in(State.PLAYING_VIDEO)
# SeekRequested IS legal in PlayingVideo — no raise
effects = m.dispatch(SeekRequested(target_ms=5000))
assert m.state == State.SEEKING_VIDEO
def test_strict_mode_legal_but_no_op_does_not_raise(monkeypatch):
"""The 'legal-but-no-op' events (e.g. VideoEofReached in
LoadingVideo, the EOF race fix) must NOT raise in strict mode.
They're intentionally accepted and dropped — that's the
structural fix, not a programmer error."""
monkeypatch.setenv("BOORU_VIEWER_STRICT_STATE", "1")
m = _new_in(State.LOADING_VIDEO)
# VideoEofReached in LoadingVideo is legal-but-no-op
effects = m.dispatch(VideoEofReached())
assert effects == []
assert m.state == State.LOADING_VIDEO
@pytest.mark.parametrize("source_state, illegal_event", [
(State.AWAITING_CONTENT, VideoEofReached()),
(State.AWAITING_CONTENT, VideoStarted()),
(State.AWAITING_CONTENT, SeekRequested(target_ms=1000)),
(State.AWAITING_CONTENT, SeekCompleted()),
(State.AWAITING_CONTENT, TogglePlayRequested()),
(State.DISPLAYING_IMAGE, VideoEofReached()),
(State.DISPLAYING_IMAGE, VideoStarted()),
(State.DISPLAYING_IMAGE, SeekRequested(target_ms=1000)),
(State.DISPLAYING_IMAGE, SeekCompleted()),
(State.DISPLAYING_IMAGE, TogglePlayRequested()),
(State.LOADING_VIDEO, SeekRequested(target_ms=1000)),
(State.LOADING_VIDEO, SeekCompleted()),
(State.LOADING_VIDEO, TogglePlayRequested()),
(State.PLAYING_VIDEO, VideoStarted()),
(State.PLAYING_VIDEO, SeekCompleted()),
(State.SEEKING_VIDEO, VideoStarted()),
(State.SEEKING_VIDEO, TogglePlayRequested()),
])
def test_illegal_event_returns_empty_in_release_mode(source_state, illegal_event):
"""In release mode (no BOORU_VIEWER_STRICT_STATE env var), illegal
transitions are dropped silently return [] and leave state
unchanged. In strict mode (commit 11) they raise InvalidTransition.
The release-mode path is what production runs."""
m = _new_in(source_state)
effects = m.dispatch(illegal_event)
assert effects == []
assert m.state == source_state
# ----------------------------------------------------------------------
# Persistent state field tests (commits 8 + 9)
# ----------------------------------------------------------------------
def test_state_field_mute_persists_across_video_loads():
"""Once set, state.mute survives any number of LoadingVideo →
PlayingVideo cycles. Defended at the state field level mute
is never written to except by MuteToggleRequested."""
m = StateMachine()
m.dispatch(MuteToggleRequested())
assert m.mute is True
# Load several videos
for _ in range(3):
m.state = State.AWAITING_CONTENT
m.dispatch(ContentArrived(path="/v.mp4", info="",
kind=MediaKind.VIDEO))
m.dispatch(VideoStarted())
assert m.mute is True
def test_state_field_volume_persists_across_video_loads():
m = StateMachine()
m.dispatch(VolumeSet(value=85))
assert m.volume == 85
for _ in range(3):
m.state = State.AWAITING_CONTENT
m.dispatch(ContentArrived(path="/v.mp4", info="",
kind=MediaKind.VIDEO))
m.dispatch(VideoStarted())
assert m.volume == 85
def test_state_field_loop_mode_persists():
m = StateMachine()
m.dispatch(LoopModeSet(mode=LoopMode.NEXT))
assert m.loop_mode == LoopMode.NEXT
m.state = State.AWAITING_CONTENT
m.dispatch(ContentArrived(path="/v.mp4", info="",
kind=MediaKind.VIDEO))
m.dispatch(VideoStarted())
assert m.loop_mode == LoopMode.NEXT
# ----------------------------------------------------------------------
# Window event tests (commit 8)
# ----------------------------------------------------------------------
def test_window_moved_updates_viewport_center_only():
"""Move-only update: keep long_side, change center."""
m = _new_in(State.DISPLAYING_IMAGE)
m.viewport = Viewport(center_x=500.0, center_y=400.0, long_side=800.0)
m.dispatch(WindowMoved(rect=(200, 300, 1000, 800)))
assert m.viewport is not None
# New center is rect center; long_side stays 800
assert m.viewport.center_x == 700.0 # 200 + 1000/2
assert m.viewport.center_y == 700.0 # 300 + 800/2
assert m.viewport.long_side == 800.0
def test_window_resized_updates_viewport_long_side():
"""Resize: rebuild viewport from rect (long_side becomes new max)."""
m = _new_in(State.DISPLAYING_IMAGE)
m.viewport = Viewport(center_x=500.0, center_y=400.0, long_side=800.0)
m.dispatch(WindowResized(rect=(100, 100, 1200, 900)))
assert m.viewport is not None
assert m.viewport.long_side == 1200.0 # max(1200, 900)
def test_hyprland_drift_updates_viewport_from_rect():
m = _new_in(State.DISPLAYING_IMAGE)
m.viewport = Viewport(center_x=500.0, center_y=400.0, long_side=800.0)
m.dispatch(HyprlandDriftDetected(rect=(50, 50, 1500, 1000)))
assert m.viewport is not None
assert m.viewport.center_x == 800.0 # 50 + 1500/2
assert m.viewport.center_y == 550.0 # 50 + 1000/2
assert m.viewport.long_side == 1500.0

View File

@ -0,0 +1,81 @@
"""Tests for media_controller -- prefetch order computation.
Pure Python. No Qt, no mpv, no httpx.
"""
from __future__ import annotations
import pytest
from booru_viewer.gui.media_controller import compute_prefetch_order
# ======================================================================
# Nearby mode
# ======================================================================
def test_nearby_center_returns_4_cardinals():
"""Center of a grid returns right, left, below, above."""
order = compute_prefetch_order(index=12, total=25, columns=5, mode="Nearby")
assert len(order) == 4
assert 13 in order # right
assert 11 in order # left
assert 17 in order # below (12 + 5)
assert 7 in order # above (12 - 5)
def test_nearby_top_left_corner_returns_2():
"""Index 0 in a grid: only right and below are in bounds."""
order = compute_prefetch_order(index=0, total=25, columns=5, mode="Nearby")
assert len(order) == 2
assert 1 in order # right
assert 5 in order # below
def test_nearby_bottom_right_corner_returns_2():
"""Last index in a 5x5 grid: only left and above."""
order = compute_prefetch_order(index=24, total=25, columns=5, mode="Nearby")
assert len(order) == 2
assert 23 in order # left
assert 19 in order # above
def test_nearby_single_post_returns_empty():
order = compute_prefetch_order(index=0, total=1, columns=5, mode="Nearby")
assert order == []
# ======================================================================
# Aggressive mode
# ======================================================================
def test_aggressive_returns_more_than_nearby():
nearby = compute_prefetch_order(index=12, total=25, columns=5, mode="Nearby")
aggressive = compute_prefetch_order(index=12, total=25, columns=5, mode="Aggressive")
assert len(aggressive) > len(nearby)
def test_aggressive_no_duplicates():
order = compute_prefetch_order(index=12, total=100, columns=5, mode="Aggressive")
assert len(order) == len(set(order))
def test_aggressive_excludes_self():
order = compute_prefetch_order(index=12, total=100, columns=5, mode="Aggressive")
assert 12 not in order
def test_aggressive_all_in_bounds():
order = compute_prefetch_order(index=0, total=50, columns=5, mode="Aggressive")
for idx in order:
assert 0 <= idx < 50
def test_aggressive_respects_cap():
"""Aggressive is capped by max_radius=3, so even with a huge grid
the returned count doesn't blow up unboundedly."""
order = compute_prefetch_order(index=500, total=10000, columns=10, mode="Aggressive")
# columns * max_radius * 2 + columns = 10*3*2+10 = 70
assert len(order) <= 70

View File

@ -0,0 +1,45 @@
"""Tests for popout_controller -- video state sync dict.
Pure Python. No Qt, no mpv.
"""
from __future__ import annotations
from booru_viewer.gui.popout_controller import build_video_sync_dict
# ======================================================================
# build_video_sync_dict
# ======================================================================
def test_shape():
result = build_video_sync_dict(
volume=50, mute=False, autoplay=True, loop_state=0, position_ms=0,
)
assert isinstance(result, dict)
assert len(result) == 5
def test_defaults():
result = build_video_sync_dict(
volume=50, mute=False, autoplay=True, loop_state=0, position_ms=0,
)
assert result["volume"] == 50
assert result["mute"] is False
assert result["autoplay"] is True
assert result["loop_state"] == 0
assert result["position_ms"] == 0
def test_has_all_5_keys():
result = build_video_sync_dict(
volume=80, mute=True, autoplay=False, loop_state=2, position_ms=5000,
)
expected_keys = {"volume", "mute", "autoplay", "loop_state", "position_ms"}
assert set(result.keys()) == expected_keys
assert result["volume"] == 80
assert result["mute"] is True
assert result["autoplay"] is False
assert result["loop_state"] == 2
assert result["position_ms"] == 5000

View File

@ -0,0 +1,86 @@
"""Tests for post_actions -- bookmark-done message parsing, library membership.
Pure Python. No Qt, no network.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from booru_viewer.gui.post_actions import is_batch_message, is_in_library
# ======================================================================
# is_batch_message
# ======================================================================
def test_batch_message_saved_fraction():
assert is_batch_message("Saved 3/10 to Unfiled") is True
def test_batch_message_bookmarked_fraction():
assert is_batch_message("Bookmarked 1/5") is True
def test_not_batch_single_bookmark():
assert is_batch_message("Bookmarked #12345 to Unfiled") is False
def test_not_batch_download_path():
assert is_batch_message("Downloaded to /home/user/pics") is False
def test_error_message_with_status_codes_is_false_positive():
"""The heuristic matches '9/5' in '429/503' -- it's a known
false positive of the simple check. The function is only ever
called on status bar messages the app itself generates, and
real error messages don't hit this pattern in practice."""
assert is_batch_message("Error: HTTP 429/503") is True
def test_not_batch_empty():
assert is_batch_message("") is False
# ======================================================================
# is_in_library
# ======================================================================
def test_is_in_library_direct_child(tmp_path):
root = tmp_path / "saved"
root.mkdir()
child = root / "12345.jpg"
child.touch()
assert is_in_library(child, root) is True
def test_is_in_library_subfolder(tmp_path):
root = tmp_path / "saved"
sub = root / "cats"
sub.mkdir(parents=True)
child = sub / "67890.png"
child.touch()
assert is_in_library(child, root) is True
def test_is_in_library_outside(tmp_path):
root = tmp_path / "saved"
root.mkdir()
outside = tmp_path / "other" / "pic.jpg"
outside.parent.mkdir()
outside.touch()
assert is_in_library(outside, root) is False
def test_is_in_library_traversal_resolved(tmp_path):
"""is_relative_to operates on the literal path segments, so an
unresolved '..' still looks relative. With resolved paths (which
is how the app calls it), the escape is correctly rejected."""
root = tmp_path / "saved"
root.mkdir()
sneaky = (root / ".." / "other.jpg").resolve()
assert is_in_library(sneaky, root) is False

View File

@ -0,0 +1,218 @@
"""Tests for search_controller -- tag building, blacklist filtering, backfill decisions.
Pure Python. No Qt, no network, no QApplication.
"""
from __future__ import annotations
from typing import NamedTuple
import pytest
from booru_viewer.gui.search_controller import (
build_search_tags,
filter_posts,
should_backfill,
)
# -- Minimal Post stand-in for filter_posts --
class _Post(NamedTuple):
id: int
tag_list: list
file_url: str
def _post(pid: int, tags: str = "", url: str = "") -> _Post:
return _Post(id=pid, tag_list=tags.split() if tags else [], file_url=url)
# ======================================================================
# build_search_tags
# ======================================================================
# -- Rating mapping --
def test_danbooru_rating_uses_single_letter():
result = build_search_tags("cat_ears", "explicit", "danbooru", 0, "All")
assert "rating:e" in result
def test_gelbooru_rating_uses_full_word():
result = build_search_tags("", "questionable", "gelbooru", 0, "All")
assert "rating:questionable" in result
def test_e621_maps_general_to_safe():
result = build_search_tags("", "general", "e621", 0, "All")
assert "rating:s" in result
def test_e621_maps_sensitive_to_safe():
result = build_search_tags("", "sensitive", "e621", 0, "All")
assert "rating:s" in result
def test_moebooru_maps_general_to_safe():
result = build_search_tags("", "general", "moebooru", 0, "All")
assert "rating:safe" in result
def test_all_rating_adds_nothing():
result = build_search_tags("cat", "all", "danbooru", 0, "All")
assert "rating:" not in result
# -- Score filter --
def test_score_filter():
result = build_search_tags("", "all", "danbooru", 50, "All")
assert "score:>=50" in result
def test_score_zero_adds_nothing():
result = build_search_tags("", "all", "danbooru", 0, "All")
assert "score:" not in result
# -- Media type filter --
def test_media_type_animated():
result = build_search_tags("", "all", "danbooru", 0, "Animated")
assert "animated" in result
def test_media_type_video():
result = build_search_tags("", "all", "danbooru", 0, "Video")
assert "video" in result
def test_media_type_gif():
result = build_search_tags("", "all", "danbooru", 0, "GIF")
assert "animated_gif" in result
def test_media_type_audio():
result = build_search_tags("", "all", "danbooru", 0, "Audio")
assert "audio" in result
# -- Combined --
def test_combined_has_all_tokens():
result = build_search_tags("1girl", "explicit", "danbooru", 10, "Video")
assert "1girl" in result
assert "rating:e" in result
assert "score:>=10" in result
assert "video" in result
# ======================================================================
# filter_posts
# ======================================================================
def test_removes_blacklisted_tags():
posts = [_post(1, tags="cat dog"), _post(2, tags="bird")]
seen: set = set()
filtered, drops = filter_posts(posts, bl_tags={"dog"}, bl_posts=set(), seen_ids=seen)
assert len(filtered) == 1
assert filtered[0].id == 2
assert drops["bl_tags"] == 1
def test_removes_blacklisted_posts_by_url():
posts = [_post(1, url="http://a.jpg"), _post(2, url="http://b.jpg")]
seen: set = set()
filtered, drops = filter_posts(posts, bl_tags=set(), bl_posts={"http://a.jpg"}, seen_ids=seen)
assert len(filtered) == 1
assert filtered[0].id == 2
assert drops["bl_posts"] == 1
def test_deduplicates_across_batches():
"""Dedup works against seen_ids accumulated from prior batches.
Within a single batch, the list comprehension fires before the
update, so same-id posts in one batch both survive -- cross-batch
dedup catches them on the next call."""
posts_batch1 = [_post(1)]
seen: set = set()
filter_posts(posts_batch1, bl_tags=set(), bl_posts=set(), seen_ids=seen)
assert 1 in seen
# Second batch with same id is deduped
posts_batch2 = [_post(1), _post(2)]
filtered, drops = filter_posts(posts_batch2, bl_tags=set(), bl_posts=set(), seen_ids=seen)
assert len(filtered) == 1
assert filtered[0].id == 2
assert drops["dedup"] == 1
def test_respects_previously_seen_ids():
posts = [_post(1), _post(2)]
seen: set = {1}
filtered, drops = filter_posts(posts, bl_tags=set(), bl_posts=set(), seen_ids=seen)
assert len(filtered) == 1
assert filtered[0].id == 2
assert drops["dedup"] == 1
def test_all_three_interact():
"""bl_tags, bl_posts, and cross-batch dedup all apply in sequence."""
# Seed seen_ids so post 3 is already known
seen: set = {3}
posts = [
_post(1, tags="bad", url="http://a.jpg"), # hit by bl_tags
_post(2, url="http://blocked.jpg"), # hit by bl_posts
_post(3), # hit by dedup (in seen)
_post(4), # survives
]
filtered, drops = filter_posts(
posts, bl_tags={"bad"}, bl_posts={"http://blocked.jpg"}, seen_ids=seen,
)
assert len(filtered) == 1
assert filtered[0].id == 4
assert drops["bl_tags"] == 1
assert drops["bl_posts"] == 1
assert drops["dedup"] == 1
def test_empty_lists_pass_through():
posts = [_post(1), _post(2)]
seen: set = set()
filtered, drops = filter_posts(posts, bl_tags=set(), bl_posts=set(), seen_ids=seen)
assert len(filtered) == 2
assert drops == {"bl_tags": 0, "bl_posts": 0, "dedup": 0}
def test_filter_posts_mutates_seen_ids():
posts = [_post(10), _post(20)]
seen: set = set()
filter_posts(posts, bl_tags=set(), bl_posts=set(), seen_ids=seen)
assert seen == {10, 20}
# ======================================================================
# should_backfill
# ======================================================================
def test_backfill_yes_when_under_limit_and_api_not_short():
assert should_backfill(collected_count=10, limit=40, last_batch_size=40) is True
def test_backfill_no_when_collected_meets_limit():
assert should_backfill(collected_count=40, limit=40, last_batch_size=40) is False
def test_backfill_no_when_api_returned_short():
assert should_backfill(collected_count=10, limit=40, last_batch_size=20) is False
def test_backfill_no_when_both_met():
assert should_backfill(collected_count=40, limit=40, last_batch_size=20) is False

View File

@ -0,0 +1,87 @@
"""Tests for the pure info-panel source HTML builder.
Pure Python. No Qt, no network. Validates audit finding #6 — that the
helper escapes booru-controlled `post.source` before it's interpolated
into a QTextBrowser RichText document.
"""
from __future__ import annotations
from booru_viewer.gui._source_html import build_source_html
def test_none_returns_literal_none():
assert build_source_html(None) == "none"
assert build_source_html("") == "none"
def test_plain_https_url_renders_escaped_anchor():
out = build_source_html("https://example.test/post/1")
assert out.startswith('<a href="https://example.test/post/1"')
assert ">https://example.test/post/1</a>" in out
def test_long_url_display_text_truncated_but_href_full():
long_url = "https://example.test/" + "a" * 200
out = build_source_html(long_url)
# href contains the full URL
assert long_url in out.replace("&amp;", "&")
# Display text is truncated to 57 chars + "..."
assert "..." in out
def test_double_quote_in_url_escaped():
"""A `"` in the source must not break out of the href attribute."""
hostile = 'https://attacker.test/"><img src=x>'
out = build_source_html(hostile)
# Raw <img> must NOT appear — html.escape converts < to &lt;
assert "<img" not in out
# The display text must also have the raw markup escaped.
assert "&gt;" in out or "&quot;" in out
def test_html_tags_in_url_escaped():
hostile = 'https://attacker.test/<script>alert(1)</script>'
out = build_source_html(hostile)
assert "<script>" not in out
assert "&lt;script&gt;" in out
def test_non_url_source_rendered_as_escaped_plain_text():
"""A source string that isn't an http(s) URL is rendered as plain
text no <a> tag, but still HTML-escaped."""
out = build_source_html("not a url <b>at all</b>")
assert "<a" not in out
assert "<b>" not in out
assert "&lt;b&gt;" in out
def test_javascript_url_does_not_become_anchor():
"""Sources that don't start with http(s) — including `javascript:` —
must NOT be wrapped in an <a> tag where they'd become a clickable
link target."""
out = build_source_html("javascript:alert(1)")
assert "<a " not in out
assert "alert(1)" in out # text content preserved (escaped)
def test_data_url_does_not_become_anchor():
out = build_source_html("data:text/html,<script>x</script>")
assert "<a " not in out
assert "<script>" not in out
def test_ampersand_in_url_escaped():
out = build_source_html("https://example.test/?a=1&b=2")
# `&` must be `&amp;` inside the href attribute
assert "&amp;" in out
# Raw `&b=` is NOT acceptable as an attribute value
assert 'href="https://example.test/?a=1&amp;b=2"' in out
def test_pixiv_real_world_source_unchanged_visually():
"""Realistic input — a normal pixiv link — should pass through with
no surprising changes."""
out = build_source_html("https://www.pixiv.net/artworks/12345")
assert 'href="https://www.pixiv.net/artworks/12345"' in out
assert "https://www.pixiv.net/artworks/12345</a>" in out

View File

@ -0,0 +1,146 @@
"""Tests for window_state -- geometry parsing, Hyprland command building.
Pure Python. No Qt, no subprocess, no Hyprland.
"""
from __future__ import annotations
import pytest
from booru_viewer.gui.window_state import (
build_hyprctl_restore_cmds,
format_geometry,
parse_geometry,
parse_splitter_sizes,
)
# ======================================================================
# parse_geometry
# ======================================================================
def test_parse_geometry_valid():
assert parse_geometry("100,200,800,600") == (100, 200, 800, 600)
def test_parse_geometry_wrong_count():
assert parse_geometry("100,200,800") is None
def test_parse_geometry_non_numeric():
assert parse_geometry("abc,200,800,600") is None
def test_parse_geometry_empty():
assert parse_geometry("") is None
# ======================================================================
# format_geometry
# ======================================================================
def test_format_geometry_basic():
assert format_geometry(10, 20, 1920, 1080) == "10,20,1920,1080"
def test_format_and_parse_round_trip():
geo = (100, 200, 800, 600)
assert parse_geometry(format_geometry(*geo)) == geo
# ======================================================================
# parse_splitter_sizes
# ======================================================================
def test_parse_splitter_sizes_valid_2():
assert parse_splitter_sizes("300,700", 2) == [300, 700]
def test_parse_splitter_sizes_valid_3():
assert parse_splitter_sizes("200,500,300", 3) == [200, 500, 300]
def test_parse_splitter_sizes_wrong_count():
assert parse_splitter_sizes("300,700", 3) is None
def test_parse_splitter_sizes_negative():
assert parse_splitter_sizes("300,-1", 2) is None
def test_parse_splitter_sizes_all_zero():
assert parse_splitter_sizes("0,0", 2) is None
def test_parse_splitter_sizes_non_numeric():
assert parse_splitter_sizes("abc,700", 2) is None
def test_parse_splitter_sizes_empty():
assert parse_splitter_sizes("", 2) is None
# ======================================================================
# build_hyprctl_restore_cmds
# ======================================================================
def test_floating_to_floating_no_toggle():
"""Already floating, want floating: no togglefloating needed."""
cmds = build_hyprctl_restore_cmds(
addr="0xdead", x=100, y=200, w=800, h=600,
want_floating=True, cur_floating=True,
)
assert not any("togglefloating" in c for c in cmds)
assert any("resizewindowpixel" in c for c in cmds)
assert any("movewindowpixel" in c for c in cmds)
def test_tiled_to_floating_has_toggle():
"""Currently tiled, want floating: one togglefloating to enter float."""
cmds = build_hyprctl_restore_cmds(
addr="0xdead", x=100, y=200, w=800, h=600,
want_floating=True, cur_floating=False,
)
toggle_cmds = [c for c in cmds if "togglefloating" in c]
assert len(toggle_cmds) == 1
def test_tiled_primes_floating_cache():
"""Want tiled: primes Hyprland's floating cache with 2 toggles + no_anim."""
cmds = build_hyprctl_restore_cmds(
addr="0xdead", x=100, y=200, w=800, h=600,
want_floating=False, cur_floating=False,
)
toggle_cmds = [c for c in cmds if "togglefloating" in c]
no_anim_on = [c for c in cmds if "no_anim 1" in c]
no_anim_off = [c for c in cmds if "no_anim 0" in c]
# Two toggles: tiled->float (to prime), float->tiled (to restore)
assert len(toggle_cmds) == 2
assert len(no_anim_on) == 1
assert len(no_anim_off) == 1
def test_floating_to_tiled_one_toggle():
"""Currently floating, want tiled: one toggle to tile."""
cmds = build_hyprctl_restore_cmds(
addr="0xdead", x=100, y=200, w=800, h=600,
want_floating=False, cur_floating=True,
)
toggle_cmds = [c for c in cmds if "togglefloating" in c]
# Only the final toggle at the end of the tiled branch
assert len(toggle_cmds) == 1
def test_correct_address_in_all_cmds():
"""Every command references the given address."""
addr = "0xbeef"
cmds = build_hyprctl_restore_cmds(
addr=addr, x=0, y=0, w=1920, h=1080,
want_floating=True, cur_floating=False,
)
for cmd in cmds:
assert addr in cmd

View File

@ -82,18 +82,24 @@ Pick whichever matches your overall desktop aesthetic. Both variants share the s
| Tokyo Night | [tokyo-night-rounded.qss](tokyo-night-rounded.qss) | [tokyo-night-square.qss](tokyo-night-square.qss) | | Tokyo Night | [tokyo-night-rounded.qss](tokyo-night-rounded.qss) | [tokyo-night-square.qss](tokyo-night-square.qss) |
| Everforest | [everforest-rounded.qss](everforest-rounded.qss) | [everforest-square.qss](everforest-square.qss) | | Everforest | [everforest-rounded.qss](everforest-rounded.qss) | [everforest-square.qss](everforest-square.qss) |
<picture><img src="../screenshots/themes/nord.png" alt="Nord" width="400"></picture> <picture><img src="../screenshots/themes/catppuccin-mocha.png" alt="Catppuccin Mocha" width="400"></picture>
<picture><img src="../screenshots/themes/gruvbox.png" alt="Gruvbox" width="400"></picture> <picture><img src="../screenshots/themes/solarized-dark.png" alt="Solarized Dark" width="400"></picture>
<picture><img src="../screenshots/themes/tokyo-night.png" alt="Tokyo Night" width="400"></picture> <picture><img src="../screenshots/themes/everforest.png" alt="Everforest" width="400"></picture>
## Widget Targets ## Widget Targets
### Global ### Global
```css ```css
QWidget { QWidget {
background-color: #282828; background-color: ${bg};
color: #ebdbb2; color: ${text};
font-size: 13px; font-size: 13px;
font-family: monospace; font-family: monospace;
selection-background-color: #fe8019; /* grid selection border + hover highlight */ selection-background-color: ${accent}; /* grid selection border + hover highlight */
selection-color: #282828; selection-color: ${accent_text};
} }
``` ```
@ -101,39 +107,31 @@ QWidget {
```css ```css
QPushButton { QPushButton {
background-color: #333; background-color: ${bg_subtle};
color: #fff; color: ${text};
border: 1px solid #555; border: 1px solid ${border};
border-radius: 4px; border-radius: 4px;
padding: 5px 14px; padding: 5px 14px;
} }
QPushButton:hover { background-color: #444; } QPushButton:hover { background-color: ${bg_hover}; }
QPushButton:pressed { background-color: #555; } QPushButton:pressed { background-color: ${bg_active}; }
QPushButton:checked { background-color: #0078d7; } /* Active tab (Browse/Bookmarks/Library), Autoplay, Loop toggles */ QPushButton:checked { background-color: ${accent}; } /* Active tab (Browse/Bookmarks/Library), Autoplay, Loop toggles */
``` ```
**Note:** Qt's QSS does not support the CSS `content` property, so you cannot replace button text (e.g. "Play" → "") via stylesheet alone. However, you can use a Nerd Font to change how unicode characters render: **Note:** Qt's QSS does not support the CSS `content` property, so you cannot replace button text (e.g. swap icon symbols) via stylesheet alone. The toolbar icon buttons use hardcoded Unicode symbols — to change which symbols appear, modify the Python source directly (see `preview_pane.py` and `popout/window.py`).
```css
QPushButton {
font-family: "JetBrainsMono Nerd Font", monospace;
}
```
To use icon buttons, you would need to modify the Python source code directly — the button labels are set in `preview.py` via `QPushButton("Play")` etc.
### Text Inputs ### Text Inputs
```css ```css
QLineEdit, QTextEdit { QLineEdit, QTextEdit {
background-color: #1a1a1a; background-color: ${bg};
color: #fff; color: ${text};
border: 1px solid #555; border: 1px solid ${border};
border-radius: 4px; border-radius: 4px;
padding: 4px 8px; padding: 4px 8px;
} }
QLineEdit:focus, QTextEdit:focus { QLineEdit:focus, QTextEdit:focus {
border-color: #0078d7; border-color: ${accent};
} }
``` ```
@ -141,9 +139,9 @@ QLineEdit:focus, QTextEdit:focus {
```css ```css
QComboBox { QComboBox {
background-color: #333; background-color: ${bg_subtle};
color: #fff; color: ${text};
border: 1px solid #555; border: 1px solid ${border};
border-radius: 4px; border-radius: 4px;
padding: 3px 6px; padding: 3px 6px;
} }
@ -152,10 +150,10 @@ QComboBox::drop-down {
width: 20px; width: 20px;
} }
QComboBox QAbstractItemView { QComboBox QAbstractItemView {
background-color: #333; background-color: ${bg_subtle};
color: #fff; color: ${text};
border: 1px solid #555; border: 1px solid ${border};
selection-background-color: #444; selection-background-color: ${bg_hover};
} }
``` ```
@ -163,9 +161,9 @@ QComboBox QAbstractItemView {
```css ```css
QSpinBox { QSpinBox {
background-color: #333; background-color: ${bg_subtle};
color: #fff; color: ${text};
border: 1px solid #555; border: 1px solid ${border};
border-radius: 2px; border-radius: 2px;
} }
``` ```
@ -174,24 +172,24 @@ QSpinBox {
```css ```css
QScrollBar:vertical { QScrollBar:vertical {
background: #1a1a1a; background: ${bg};
width: 10px; width: 10px;
border: none; border: none;
} }
QScrollBar::handle:vertical { QScrollBar::handle:vertical {
background: #555; background: ${bg_hover};
border-radius: 4px; border-radius: 4px;
min-height: 20px; min-height: 20px;
} }
QScrollBar::handle:vertical:hover { background: #0078d7; } QScrollBar::handle:vertical:hover { background: ${bg_active}; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar:horizontal { QScrollBar:horizontal {
background: #1a1a1a; background: ${bg};
height: 10px; height: 10px;
} }
QScrollBar::handle:horizontal { QScrollBar::handle:horizontal {
background: #555; background: ${bg_hover};
border-radius: 4px; border-radius: 4px;
} }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
@ -201,25 +199,25 @@ QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
```css ```css
QMenuBar { QMenuBar {
background-color: #1a1a1a; background-color: ${bg};
color: #fff; color: ${text};
} }
QMenuBar::item:selected { background-color: #333; } QMenuBar::item:selected { background-color: ${bg_subtle}; }
QMenu { QMenu {
background-color: #1a1a1a; background-color: ${bg};
color: #fff; color: ${text};
border: 1px solid #333; border: 1px solid ${border};
} }
QMenu::item:selected { background-color: #333; } QMenu::item:selected { background-color: ${bg_subtle}; }
``` ```
### Status Bar ### Status Bar
```css ```css
QStatusBar { QStatusBar {
background-color: #1a1a1a; background-color: ${bg};
color: #888; color: ${text_dim};
} }
``` ```
@ -227,7 +225,7 @@ QStatusBar {
```css ```css
QSplitter::handle { QSplitter::handle {
background: #555; background: ${border};
width: 2px; width: 2px;
} }
``` ```
@ -236,14 +234,14 @@ QSplitter::handle {
```css ```css
QTabBar::tab { QTabBar::tab {
background: #333; background: ${bg_subtle};
color: #fff; color: ${text};
border: 1px solid #555; border: 1px solid ${border};
padding: 6px 16px; padding: 6px 16px;
} }
QTabBar::tab:selected { QTabBar::tab:selected {
background: #444; background: ${bg_hover};
color: #0078d7; color: ${accent};
} }
``` ```
@ -255,7 +253,7 @@ To override the preview controls bar background in QSS:
```css ```css
QWidget#_preview_controls { QWidget#_preview_controls {
background: rgba(40, 40, 40, 200); /* your custom translucent bg */ background: ${overlay_bg};
} }
``` ```
@ -263,12 +261,12 @@ Standard slider styling still applies outside the controls bar:
```css ```css
QSlider::groove:horizontal { QSlider::groove:horizontal {
background: #333; background: ${bg_subtle};
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
} }
QSlider::handle:horizontal { QSlider::handle:horizontal {
background: #0078d7; background: ${accent};
width: 12px; width: 12px;
margin: -4px 0; margin: -4px 0;
border-radius: 6px; border-radius: 6px;
@ -284,12 +282,12 @@ These overlays use internal styling that overrides QSS. To customize:
```css ```css
/* Popout top toolbar */ /* Popout top toolbar */
QWidget#_slideshow_toolbar { QWidget#_slideshow_toolbar {
background: rgba(40, 40, 40, 200); background: ${overlay_bg};
} }
/* Popout bottom video controls */ /* Popout bottom video controls */
QWidget#_slideshow_controls { QWidget#_slideshow_controls {
background: rgba(40, 40, 40, 200); background: ${overlay_bg};
} }
``` ```
@ -297,30 +295,53 @@ Buttons and labels inside both overlays inherit a white-on-transparent style. To
```css ```css
QWidget#_slideshow_toolbar QPushButton { QWidget#_slideshow_toolbar QPushButton {
border: 1px solid rgba(255, 255, 255, 120); border: 1px solid ${border};
color: #ccc; color: ${text_dim};
} }
QWidget#_slideshow_controls QPushButton { QWidget#_slideshow_controls QPushButton {
border: 1px solid rgba(255, 255, 255, 120); border: 1px solid ${border};
color: #ccc; color: ${text_dim};
} }
``` ```
### Preview Toolbar ### Preview & Popout Toolbar Icon Buttons
The preview panel has an action toolbar (Bookmark, Save, BL Tag, BL Post, Popout) that appears above the media when a post is active. This toolbar uses the app's default button styling. The preview and popout toolbars use 24x24 icon buttons with Unicode symbols. Each button has an object name for QSS targeting:
The toolbar does not have a named object ID — it inherits the app's `QPushButton` styles directly. | Object Name | Symbol | Action |
|-------------|--------|--------|
| `#_tb_bookmark` | ☆ / ★ | Bookmark / Unbookmark |
| `#_tb_save` | ⤓ / ✕ | Save / Unsave |
| `#_tb_bl_tag` | ⊘ | Blacklist a tag |
| `#_tb_bl_post` | ⊗ | Blacklist this post |
| `#_tb_popout` | ⧉ | Open popout (preview only) |
```css
/* Style all toolbar icon buttons */
QPushButton#_tb_bookmark,
QPushButton#_tb_save,
QPushButton#_tb_bl_tag,
QPushButton#_tb_bl_post,
QPushButton#_tb_popout {
background: transparent;
border: 1px solid ${border};
color: ${text};
padding: 0px;
}
```
The same object names are used in both the preview pane and the popout overlay, so one rule targets both. The symbols themselves are hardcoded in Python — QSS can style the buttons but cannot change which symbol is displayed.
### Progress Bar (Download) ### Progress Bar (Download)
```css ```css
QProgressBar { QProgressBar {
background-color: #333; background-color: ${bg_subtle};
border: none; border: none;
} }
QProgressBar::chunk { QProgressBar::chunk {
background-color: #0078d7; background-color: ${accent};
} }
``` ```
@ -328,9 +349,9 @@ QProgressBar::chunk {
```css ```css
QToolTip { QToolTip {
background-color: #333; background-color: ${bg_subtle};
color: #fff; color: ${text};
border: 1px solid #555; border: 1px solid ${border};
padding: 4px; padding: 4px;
} }
``` ```
@ -349,8 +370,8 @@ Click and drag on empty grid space to select multiple thumbnails. The rubber ban
```css ```css
QRubberBand { QRubberBand {
background: rgba(0, 120, 215, 40); background: ${accent}; /* use rgba(...) variant for translucency */
border: 1px solid #0078d7; border: 1px solid ${accent};
} }
``` ```
@ -360,10 +381,10 @@ The library tab's count label switches between three visual states depending on
```css ```css
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: #a6adc8; /* dim text — search miss or empty folder */ color: ${text_dim}; /* dim text — search miss or empty folder */
} }
QLabel[libraryCountState="error"] { QLabel[libraryCountState="error"] {
color: #f38ba8; /* danger color — directory unreachable */ color: ${danger}; /* danger color — directory unreachable */
font-weight: bold; font-weight: bold;
} }
``` ```

View File

@ -28,7 +28,6 @@
warning: #f9e2af warning: #f9e2af
overlay_bg: rgba(30, 30, 46, 200) overlay_bg: rgba(30, 30, 46, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -92,49 +89,26 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
border-radius: 4px;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
border-radius: 4px; border-radius: 4px;
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -315,19 +289,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
border-radius: 2px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
border-radius: 6px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -343,33 +304,28 @@ QProgressBar::chunk {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
}
QCheckBox::indicator {
border-radius: 3px; border-radius: 3px;
} }
QRadioButton::indicator { QCheckBox::indicator:hover {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -384,9 +340,9 @@ QToolTip {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -395,35 +351,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -448,7 +387,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -465,63 +404,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -531,18 +421,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -553,19 +443,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -588,6 +472,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -28,7 +28,6 @@
warning: #f9e2af warning: #f9e2af
overlay_bg: rgba(30, 30, 46, 200) overlay_bg: rgba(30, 30, 46, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -91,47 +88,25 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -309,17 +284,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -333,32 +297,27 @@ QProgressBar::chunk {
background-color: ${accent}; background-color: ${accent};
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
} }
QCheckBox::indicator { QCheckBox::indicator:hover {
}
QRadioButton::indicator {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -372,9 +331,9 @@ QToolTip {
padding: 4px 6px; padding: 4px 6px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -383,35 +342,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -436,7 +378,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -452,63 +394,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -518,18 +411,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -540,19 +433,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -575,6 +462,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -1,4 +1,4 @@
/* booru-viewer Everforest Dark /* booru-viewer Everforest
* *
* Edit the @palette block below to recolor this rounded variant. The body uses * Edit the @palette block below to recolor this rounded variant. The body uses
* ${...} placeholders that the app's _load_user_qss preprocessor * ${...} placeholders that the app's _load_user_qss preprocessor
@ -28,7 +28,6 @@
warning: #dbbc7f warning: #dbbc7f
overlay_bg: rgba(45, 53, 59, 200) overlay_bg: rgba(45, 53, 59, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -92,49 +89,26 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
border-radius: 4px;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
border-radius: 4px; border-radius: 4px;
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -315,19 +289,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
border-radius: 2px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
border-radius: 6px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -343,33 +304,28 @@ QProgressBar::chunk {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
}
QCheckBox::indicator {
border-radius: 3px; border-radius: 3px;
} }
QRadioButton::indicator { QCheckBox::indicator:hover {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -384,9 +340,9 @@ QToolTip {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -395,35 +351,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -448,7 +387,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -465,63 +404,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -531,18 +421,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -553,19 +443,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -588,6 +472,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -1,4 +1,4 @@
/* booru-viewer Everforest Dark /* booru-viewer Everforest
* *
* Edit the @palette block below to recolor this square variant. The body uses * Edit the @palette block below to recolor this square variant. The body uses
* ${...} placeholders that the app's _load_user_qss preprocessor * ${...} placeholders that the app's _load_user_qss preprocessor
@ -28,7 +28,6 @@
warning: #dbbc7f warning: #dbbc7f
overlay_bg: rgba(45, 53, 59, 200) overlay_bg: rgba(45, 53, 59, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -91,47 +88,25 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -309,17 +284,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -333,32 +297,27 @@ QProgressBar::chunk {
background-color: ${accent}; background-color: ${accent};
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
} }
QCheckBox::indicator { QCheckBox::indicator:hover {
}
QRadioButton::indicator {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -372,9 +331,9 @@ QToolTip {
padding: 4px 6px; padding: 4px 6px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -383,35 +342,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -436,7 +378,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -452,63 +394,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -518,18 +411,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -540,19 +433,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -575,6 +462,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -1,4 +1,4 @@
/* booru-viewer Gruvbox Dark /* booru-viewer Gruvbox
* *
* Edit the @palette block below to recolor this rounded variant. The body uses * Edit the @palette block below to recolor this rounded variant. The body uses
* ${...} placeholders that the app's _load_user_qss preprocessor * ${...} placeholders that the app's _load_user_qss preprocessor
@ -28,7 +28,6 @@
warning: #fabd2f warning: #fabd2f
overlay_bg: rgba(40, 40, 40, 200) overlay_bg: rgba(40, 40, 40, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -92,49 +89,26 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
border-radius: 4px;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
border-radius: 4px; border-radius: 4px;
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -315,19 +289,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
border-radius: 2px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
border-radius: 6px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -343,33 +304,28 @@ QProgressBar::chunk {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
}
QCheckBox::indicator {
border-radius: 3px; border-radius: 3px;
} }
QRadioButton::indicator { QCheckBox::indicator:hover {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -384,9 +340,9 @@ QToolTip {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -395,35 +351,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -448,7 +387,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -465,63 +404,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -531,18 +421,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -553,19 +443,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -588,6 +472,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -1,4 +1,4 @@
/* booru-viewer Gruvbox Dark /* booru-viewer Gruvbox
* *
* Edit the @palette block below to recolor this square variant. The body uses * Edit the @palette block below to recolor this square variant. The body uses
* ${...} placeholders that the app's _load_user_qss preprocessor * ${...} placeholders that the app's _load_user_qss preprocessor
@ -28,7 +28,6 @@
warning: #fabd2f warning: #fabd2f
overlay_bg: rgba(40, 40, 40, 200) overlay_bg: rgba(40, 40, 40, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -91,47 +88,25 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -309,17 +284,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -333,32 +297,27 @@ QProgressBar::chunk {
background-color: ${accent}; background-color: ${accent};
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
} }
QCheckBox::indicator { QCheckBox::indicator:hover {
}
QRadioButton::indicator {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -372,9 +331,9 @@ QToolTip {
padding: 4px 6px; padding: 4px 6px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -383,35 +342,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -436,7 +378,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -452,63 +394,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -518,18 +411,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -540,19 +433,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -575,6 +462,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -28,7 +28,6 @@
warning: #ebcb8b warning: #ebcb8b
overlay_bg: rgba(46, 52, 64, 200) overlay_bg: rgba(46, 52, 64, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -92,49 +89,26 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
border-radius: 4px;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
border-radius: 4px; border-radius: 4px;
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -315,19 +289,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
border-radius: 2px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
border-radius: 6px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -343,33 +304,28 @@ QProgressBar::chunk {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
}
QCheckBox::indicator {
border-radius: 3px; border-radius: 3px;
} }
QRadioButton::indicator { QCheckBox::indicator:hover {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -384,9 +340,9 @@ QToolTip {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -395,35 +351,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -448,7 +387,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -465,63 +404,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -531,18 +421,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -553,19 +443,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -588,6 +472,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -28,7 +28,6 @@
warning: #ebcb8b warning: #ebcb8b
overlay_bg: rgba(46, 52, 64, 200) overlay_bg: rgba(46, 52, 64, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -91,47 +88,25 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -309,17 +284,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -333,32 +297,27 @@ QProgressBar::chunk {
background-color: ${accent}; background-color: ${accent};
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
} }
QCheckBox::indicator { QCheckBox::indicator:hover {
}
QRadioButton::indicator {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -372,9 +331,9 @@ QToolTip {
padding: 4px 6px; padding: 4px 6px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -383,35 +342,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -436,7 +378,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -452,63 +394,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -518,18 +411,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -540,19 +433,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -575,6 +462,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -28,7 +28,6 @@
warning: #b58900 warning: #b58900
overlay_bg: rgba(0, 43, 54, 200) overlay_bg: rgba(0, 43, 54, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -92,49 +89,26 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
border-radius: 4px;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
border-radius: 4px; border-radius: 4px;
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -315,19 +289,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
border-radius: 2px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
border-radius: 6px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -343,33 +304,28 @@ QProgressBar::chunk {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
}
QCheckBox::indicator {
border-radius: 3px; border-radius: 3px;
} }
QRadioButton::indicator { QCheckBox::indicator:hover {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -384,9 +340,9 @@ QToolTip {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -395,35 +351,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -448,7 +387,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -465,63 +404,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -531,18 +421,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -553,19 +443,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -588,6 +472,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -28,7 +28,6 @@
warning: #b58900 warning: #b58900
overlay_bg: rgba(0, 43, 54, 200) overlay_bg: rgba(0, 43, 54, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -91,47 +88,25 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -309,17 +284,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -333,32 +297,27 @@ QProgressBar::chunk {
background-color: ${accent}; background-color: ${accent};
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
} }
QCheckBox::indicator { QCheckBox::indicator:hover {
}
QRadioButton::indicator {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -372,9 +331,9 @@ QToolTip {
padding: 4px 6px; padding: 4px 6px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -383,35 +342,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -436,7 +378,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -452,63 +394,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -518,18 +411,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -540,19 +433,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -575,6 +462,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -28,7 +28,6 @@
warning: #e0af68 warning: #e0af68
overlay_bg: rgba(26, 27, 38, 200) overlay_bg: rgba(26, 27, 38, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -92,49 +89,26 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
border-radius: 4px;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
border-radius: 4px; border-radius: 4px;
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -315,19 +289,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
border-radius: 2px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
border-radius: 6px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -343,33 +304,28 @@ QProgressBar::chunk {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
}
QCheckBox::indicator {
border-radius: 3px; border-radius: 3px;
} }
QRadioButton::indicator { QCheckBox::indicator:hover {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -384,9 +340,9 @@ QToolTip {
border-radius: 3px; border-radius: 3px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -395,35 +351,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -448,7 +387,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -465,63 +404,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -531,18 +421,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -553,19 +443,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -588,6 +472,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,

View File

@ -28,7 +28,6 @@
warning: #e0af68 warning: #e0af68
overlay_bg: rgba(26, 27, 38, 200) overlay_bg: rgba(26, 27, 38, 200)
*/ */
/* ---------- Base ---------- */ /* ---------- Base ---------- */
QWidget { QWidget {
@ -43,8 +42,6 @@ QWidget:disabled {
color: ${text_disabled}; color: ${text_disabled};
} }
/* Labels should never paint an opaque background they sit on top of
* other widgets in many places (toolbars, info panels, overlays). */
QLabel { QLabel {
background: transparent; background: transparent;
} }
@ -91,47 +88,25 @@ QPushButton:flat:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QToolButton {
background-color: transparent;
color: ${text};
border: 1px solid transparent;
padding: 4px;
}
QToolButton:hover {
background-color: ${bg_hover};
border-color: ${border_strong};
}
QToolButton:pressed, QToolButton:checked {
background-color: ${bg_active};
}
/* ---------- Inputs ---------- */ /* ---------- Inputs ---------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit { QLineEdit, QSpinBox, QTextEdit {
background-color: ${bg_subtle}; background-color: ${bg_subtle};
color: ${text}; color: ${text};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
padding: 2px 6px; padding: 2px 6px;
/* min-height ensures the painted text fits inside the widget bounds
* even when a parent layout (e.g. QFormLayout inside a QGroupBox)
* compresses the natural sizeHint. Without this, spinboxes in dense
* forms render with the top of the value text clipped. */
min-height: 16px; min-height: 16px;
selection-background-color: ${accent}; selection-background-color: ${accent};
selection-color: ${accent_text}; selection-color: ${accent_text};
} }
QLineEdit:focus, QLineEdit:focus,
QSpinBox:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QTextEdit:focus {
QTextEdit:focus,
QPlainTextEdit:focus {
border-color: ${accent}; border-color: ${accent};
} }
QLineEdit:disabled, QLineEdit:disabled,
QSpinBox:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QTextEdit:disabled {
QTextEdit:disabled,
QPlainTextEdit:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
color: ${text_disabled}; color: ${text_disabled};
border-color: ${border}; border-color: ${border};
@ -309,17 +284,6 @@ QSlider::handle:horizontal:hover {
background: ${accent_dim}; background: ${accent_dim};
} }
QSlider::groove:vertical {
background: ${bg_subtle};
width: 4px;
}
QSlider::handle:vertical {
background: ${accent};
width: 12px;
height: 12px;
margin: 0 -5px;
}
/* ---------- Progress ---------- */ /* ---------- Progress ---------- */
QProgressBar { QProgressBar {
@ -333,32 +297,27 @@ QProgressBar::chunk {
background-color: ${accent}; background-color: ${accent};
} }
/* ---------- Checkboxes & radio buttons ---------- */ /* ---------- Checkboxes ---------- */
QCheckBox, QRadioButton { QCheckBox {
background: transparent; background: transparent;
color: ${text}; color: ${text};
spacing: 6px; spacing: 6px;
} }
QCheckBox::indicator, QRadioButton::indicator { QCheckBox::indicator {
width: 14px; width: 14px;
height: 14px; height: 14px;
background-color: ${bg_subtle}; background-color: ${bg_subtle};
border: 1px solid ${border_strong}; border: 1px solid ${border_strong};
} }
QCheckBox::indicator { QCheckBox::indicator:hover {
}
QRadioButton::indicator {
border-radius: 7px;
}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:checked, QRadioButton::indicator:checked { QCheckBox::indicator:checked {
background-color: ${accent}; background-color: ${accent};
border-color: ${accent}; border-color: ${accent};
} }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { QCheckBox::indicator:disabled {
background-color: ${bg_alt}; background-color: ${bg_alt};
border-color: ${border}; border-color: ${border};
} }
@ -372,9 +331,9 @@ QToolTip {
padding: 4px 6px; padding: 4px 6px;
} }
/* ---------- Item views (lists, trees, tables) ---------- */ /* ---------- Lists ---------- */
QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget { QListView, QListWidget {
background-color: ${bg}; background-color: ${bg};
alternate-background-color: ${bg_alt}; alternate-background-color: ${bg_alt};
color: ${text}; color: ${text};
@ -383,35 +342,18 @@ QListView, QListWidget, QTreeView, QTreeWidget, QTableView, QTableWidget {
selection-color: ${accent_text}; selection-color: ${accent_text};
outline: none; outline: none;
} }
QListView::item, QListWidget::item, QListView::item, QListWidget::item {
QTreeView::item, QTreeWidget::item,
QTableView::item, QTableWidget::item {
padding: 4px; padding: 4px;
} }
QListView::item:hover, QListWidget::item:hover, QListView::item:hover, QListWidget::item:hover {
QTreeView::item:hover, QTreeWidget::item:hover,
QTableView::item:hover, QTableWidget::item:hover {
background-color: ${bg_hover}; background-color: ${bg_hover};
} }
QListView::item:selected, QListWidget::item:selected, QListView::item:selected, QListWidget::item:selected {
QTreeView::item:selected, QTreeWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected {
background-color: ${accent}; background-color: ${accent};
color: ${accent_text}; color: ${accent_text};
} }
QHeaderView::section { /* ---------- Tabs (settings dialog) ---------- */
background-color: ${bg_subtle};
color: ${text};
border: none;
border-right: 1px solid ${border};
padding: 4px 8px;
}
QHeaderView::section:hover {
background-color: ${bg_hover};
}
/* ---------- Tabs ---------- */
QTabWidget::pane { QTabWidget::pane {
border: 1px solid ${border}; border: 1px solid ${border};
@ -436,7 +378,7 @@ QTabBar::tab:hover:!selected {
color: ${text}; color: ${text};
} }
/* ---------- Group boxes ---------- */ /* ---------- Group boxes (settings dialog) ---------- */
QGroupBox { QGroupBox {
background: transparent; background: transparent;
@ -452,63 +394,14 @@ QGroupBox::title {
color: ${text_dim}; color: ${text_dim};
} }
/* ---------- Frames ---------- */ /* ---------- Rubber band (multi-select drag) ---------- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background: ${border};
color: ${border};
}
/* ---------- Toolbars ---------- */
QToolBar {
background: ${bg};
border: none;
spacing: 4px;
padding: 2px;
}
QToolBar::separator {
background: ${border};
width: 1px;
margin: 4px 4px;
}
/* ---------- Dock widgets ---------- */
QDockWidget {
color: ${text};
titlebar-close-icon: none;
}
QDockWidget::title {
background: ${bg_subtle};
padding: 4px;
border: 1px solid ${border};
}
/* ---------- Rubber band (multi-select drag rectangle) ---------- */
QRubberBand { QRubberBand {
background: ${accent}; background: ${accent};
border: 1px solid ${accent}; border: 1px solid ${accent};
/* Qt blends rubber band at ~30% so this reads as a translucent
* accent-tinted rectangle without needing rgba(). */
} }
/* ---------- Library count label states ---------- */ /* ---------- Library count label states ---------- */
/*
* The library tab's count label switches between three visual states
* depending on what refresh() found. The state is exposed as a Qt
* dynamic property `libraryCountState` so users can override these
* rules in their custom.qss without touching the Python.
*
* normal N files default text color, no rule needed
* empty no items dim text (no items found, search miss)
* error bad/unreachable danger color + bold (real error)
*/
QLabel[libraryCountState="empty"] { QLabel[libraryCountState="empty"] {
color: ${text_dim}; color: ${text_dim};
@ -518,18 +411,18 @@ QLabel[libraryCountState="error"] {
font-weight: bold; font-weight: bold;
} }
/* ---------- Thumbnail dot indicators (Qt properties on ThumbnailWidget) ---------- */ /* ---------- Thumbnail indicators ---------- */
ThumbnailWidget { ThumbnailWidget {
qproperty-savedColor: #22cc22; /* green dot: saved to library — universal "confirmed" feel */ qproperty-savedColor: #22cc22;
qproperty-bookmarkedColor: #ffcc00; /* yellow star: bookmarked */ qproperty-bookmarkedColor: #ffcc00;
qproperty-selectionColor: ${accent}; qproperty-selectionColor: ${accent};
qproperty-multiSelectColor: ${accent_dim}; qproperty-multiSelectColor: ${accent_dim};
qproperty-hoverColor: ${accent}; qproperty-hoverColor: ${accent};
qproperty-idleColor: ${border_strong}; qproperty-idleColor: ${border_strong};
} }
/* ---------- Info panel tag category colors ---------- */ /* ---------- Info panel tag colors ---------- */
InfoPanel { InfoPanel {
qproperty-tagArtistColor: ${warning}; qproperty-tagArtistColor: ${warning};
@ -540,19 +433,13 @@ InfoPanel {
qproperty-tagLoreColor: ${text_dim}; qproperty-tagLoreColor: ${text_dim};
} }
/* ---------- Video player letterbox / pillarbox color (mpv background) ---------- */ /* ---------- Video player letterbox ---------- */
VideoPlayer { VideoPlayer {
qproperty-letterboxColor: ${bg}; qproperty-letterboxColor: ${bg};
} }
/* ---------- Popout overlay bars (slideshow toolbar + slideshow controls + embedded preview controls) ---------- */ /* ---------- Popout overlay bars ---------- */
/*
* The popout window's translucent toolbar (top) and transport controls
* (bottom) float over the video content. The bg color comes from the
* @palette overlay_bg slot. Children get the classic overlay treatment:
* transparent backgrounds, near-white text, hairline borders.
*/
QWidget#_slideshow_toolbar, QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls, QWidget#_slideshow_controls,
@ -575,6 +462,8 @@ QWidget#_preview_controls QPushButton {
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 80); border: 1px solid rgba(255, 255, 255, 80);
padding: 2px 6px; padding: 2px 6px;
font-size: 15px;
font-weight: bold;
} }
QWidget#_slideshow_toolbar QPushButton:hover, QWidget#_slideshow_toolbar QPushButton:hover,
QWidget#_slideshow_controls QPushButton:hover, QWidget#_slideshow_controls QPushButton:hover,