373 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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