430 Commits

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