Skip to content

Add gallery-py-qt: high-performance PySide6 media viewer - #1

Open
ChronicallyAcute wants to merge 83 commits into
mainfrom
claude/gallery-py-qt-code-aqounc
Open

Add gallery-py-qt: high-performance PySide6 media viewer#1
ChronicallyAcute wants to merge 83 commits into
mainfrom
claude/gallery-py-qt-code-aqounc

Conversation

@ChronicallyAcute

Copy link
Copy Markdown
Owner

This PR introduces gallery-py-qt, a high-performance fork of gallery-qt built with PySide6 instead of PyQt5. The application is a full-featured media gallery viewer with streaming scan, adaptive thumbnails, multi-view support, and native video playback.

Summary

Gallery-py-qt is a complete media viewer application featuring:

  • Streaming folder scan with incremental UI population as files are discovered
  • Adaptive thumbnail caching with memory-aware LRU eviction
  • Multi-view grid with pinnable slots and native video playback
  • Full-screen lightbox with zoom/pan/rotate and video scrubbing
  • Favorites system with Downloads mirror (shared with gallery-qt)
  • Trash/undo functionality with persistent history
  • Advanced filtering & sorting (by name, type, dimensions, favorites)
  • Keyboard shortcuts and floating control bar with auto-hide

Key Changes

Core Architecture:

  • main_window.py — Main application window with toolbar, gallery view, and streaming scan infrastructure
  • model.py — Virtualized list model with O(1) path lookup and progressive batch insertion
  • gallery_view.py — Icon grid view with floating action overlay
  • lightbox.py — Full-screen image/video viewer with zoom, pan, rotate
  • multiview.py — Adaptive 3-up (portrait) / 2×2 (landscape) grid with pinnable slots

Media Engine:

  • engine/scan.py — Generator-based folder scanning with incremental batch emission
  • engine/media.py — Thread-safe image/video decoding (PIL + OpenCV)
  • engine/cache.py — Disk thumbnail cache with single-stat optimization
  • loader.py — Priority-scheduled threaded thumbnail loader with auto-CPU-count sizing
  • engine/favorites.py — Favorites persistence with Downloads mirror

UI & Styling:

  • delegate.py — Card-style item delegate with video badge and favorite indicator
  • seekbar.py — Custom seek bar for video scrubbing
  • exif_panel.py — Lightweight file info dialog
  • theme.py — Application-wide Qt stylesheet
  • config.py — Centralized configuration (paths, palette, icons, file types)

Application:

  • app.py — Bootstrap with argument parsing and crash handler
  • __main__.py / __init__.py — Module entry points

Notable Implementation Details

Performance Optimizations:

  • Streaming scan emits path batches incrementally via _StreamScanJob, allowing the gallery to populate as files are discovered rather than blocking until full enumeration
  • Generation counter (_scan_gen) ensures stale batches from superseded scans are silently dropped
  • Model maintains _all_set (O(1) membership) and _path_to_row (O(1) reverse lookup) to avoid O(n²) behavior during rapid thumbnail arrival
  • Adaptive pixmap LRU cap based on thumbnail size and memory budget (256 MB default) instead of fixed item count
  • Priority scheduling in thumbnail loader ensures recently-requested paths decode first, enabling instant scroll-to-position in large galleries
  • Single os.stat() call in cache lookup replaces separate exists() + getmtime() calls

Memory Efficiency:

  • pil_to_qimage() uses Format_RGB888 for opaque images (25% memory savings vs. RGBA)
  • Detects alpha presence before converting, skipping unconditional RGBA conversion
  • ThumbnailLoader auto-sizes thread count to CPU count (capped at 16) instead of hard-coded 4

UI/UX:

  • Floating control bar with auto-hide timer
  • Undo bar for trash operations with configurable timeout
  • Keyboard shortcuts (F11 fullscreen, Ctrl+O open, Space autoscroll, H toggle bar)
  • Autoscroll feature with 20ms tick timer
  • Hover overlay on gallery items with fav/enlarge/rotate/trash actions
  • Multi-view with independent video players and scrubbers per slot

https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M

claude added 30 commits June 20, 2026 01:30
Performance improvements targeting large file volumes:

engine/scan.py
  – scan_iter() generator yields path batches as files are discovered so the
    gallery populates incrementally instead of blocking until enumeration is
    complete.  Also adds optional recursive folder scanning.

model.py
  – _path_to_row reverse index gives O(1) row lookup on every thumbnail
    arrival, favourite toggle, and rotation.  The original did an O(n) linear
    scan each time, producing O(n²) behaviour on large collections.
  – _all_set provides O(1) membership tests on the master path list.
  – add_paths_batch / finalize_scan wire the streaming scan: items appear as
    batches arrive (beginInsertRows), then a single re-sort runs on completion.
  – remove_path uses beginRemoveRows / endRemoveRows instead of a full model
    reset so deleting a file doesn't force every visible cell to repaint.
  – Adaptive pixmap cap: expressed as a 256 MB memory ceiling and converted to
    an item count based on the current thumbnail size, replacing the fixed 400.

loader.py
  – Priority scheduling: each request gets a monotonically increasing integer
    priority so the most-recently-requested path (typically the current
    viewport) is decoded before older queued items.
  – Thread count auto-sizes to min(max(4, cpu_count), 16) instead of 4.
  – cancel() marks queued jobs as skip-on-run for stale requests.

engine/cache.py
  – Single os.stat() replaces the original os.path.exists() + os.path.getmtime()
    pair, halving syscall count per cache lookup.

engine/media.py
  – load_thumbnail / load_full_qimage detect alpha before converting: opaque
    images (JPEG, BMP, non-alpha PNG) stay in RGB888 rather than RGBA8888,
    saving 25 % decode memory and conversion time for the most common case.

main_window.py
  – Wires _StreamScanJob (uses scan_iter) with batch + done signals and a
    generation counter that silently drops stale batches when the user opens
    a new folder while a scan is still running.
  – ThumbnailLoader constructed without explicit max_threads so it picks up the
    CPU-count default from the updated loader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
A bad save-encoding round-trip on Windows can corrupt non-ASCII source bytes
(e.g. an em-dash becomes mojibake), causing "SyntaxError: invalid character
'€' (U+20AC)" at import. To make the package immune to that entire class of
failure, every source file is now pure ASCII on disk:

  - UI glyphs and status-text symbols (hearts, arrows, play/pause, the column
    dot, the dimensions x, the ellipsis, etc.) are now written as \uXXXX /
    \U00XXXXXX escapes. These are ASCII bytes in the file and Python decodes
    them to the identical glyph at runtime, so the rendered UI is unchanged.
  - Decorative comment characters (em-dashes, bullets, box-drawing) are
    transliterated to ASCII equivalents.

Verified: no file contains a byte > 127, all modules compile, and every
ICON_* escape decodes back to its original glyph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
…skbar fix

Bug fixes:
- gallery_view: remove single-click -> lightbox (was causing accidental
  lightbox opens on any overlay button click, masking rotate/fav results)
- gallery_view: overlay hides immediately after rotate/trash/enlarge so
  the user sees the result; fav stays visible to show updated heart state
- multiview: pin button now shows a blue checked state (was visually
  indistinguishable from unpinned)
- multiview: layout change (portrait/landscape detection) no longer
  destroys pinned slots -- _render skips _build_slots if any slot is pinned
- lightbox/multiview: Qt.WindowType.Window flag ensures showFullScreen()
  covers the OS taskbar on Windows (QDialog without this flag is
  constrained by parent window geometry)
- main_window: _toggle_fs, _open_lightbox, _open_multiview all call
  raise_() + activateWindow() after showFullScreen()

New features:
- gallery_view: video items that scroll into the viewport auto-play as
  looping muted previews via a pool of 4 QMediaPlayers; they stop when
  scrolled out of view. Frames are throttled to 20 fps to cap repaints.
- gallery_view: arrow-key navigation (Left/Right/Up/Down) between items;
  Enter opens lightbox
- main_window: QFileDialog replaced with _FolderPickDlg -- a custom
  dialog with a QFileSystemModel tree that adds checkbox support to every
  directory entry. Recent folders are shown as quick-add buttons. Both
  checkbox ticks and Ctrl/Shift multi-select are honoured on Accept.
- lightbox: volume slider added to transport bar; [ / ] keys adjust volume
- lightbox: 'F' key toggles favourite for the current item

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
…rid layout

multiview.py:
- _Slot stores _path directly and emits it from favToggled/trashed signals,
  eliminating the row→path lookup race that caused wrong-item deletion
- Replace QVBoxLayout show/hide pattern with QStackedWidget so image page
  always receives full slot geometry (fixes rotate/scaling at half height)
- Pin button driven by explicit _PIN_OFF/_PIN_ON stylesheet swap in
  _toggle_pin() instead of QSS :checked, which was silently overridden by
  the inherited background cascade
- Slot background via QPalette instead of setStyleSheet to prevent cascade

delegate.py:
- Remove 2px cell padding (was rect.adjusted(2,2,-2,-2)); use full cell rect
- Switch KeepAspectRatio → KeepAspectRatioByExpanding with centre-crop so
  media fills each cell without letterbox bars or dark card backgrounds
- Selection indicator is now a plain rect border, not a rounded rect card

gallery_view.py:
- setSpacing(0); cell size = viewport_width / cols (no gap arithmetic)
  so grid tiles sit flush edge-to-edge with no black gaps between them

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
- setFrameShape(NoFrame): removes the 1px QListView border that appeared
  as a gap around the grid
- setContentsMargins(0,0,0,0) + viewport().setContentsMargins(0,0,0,0):
  strips any style-applied padding inside the scroll area
- Ceiling division for cell width (-(-vw // cols)) ensures cells together
  sum to exactly the viewport width, eliminating the fractional-pixel
  remainder that left a sliver of empty space at the right edge

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
Three gap sources addressed:

1. theme.py: QListView background was #0a0a0a (near-black, not pure black).
   Changed to #000 and added QListView::item { padding:0; margin:0; border:none }
   so Fusion/platform style cannot inject per-item insets that shrink option.rect.

2. delegate.py: always fillRect(rect, #000) before drawing the pixmap so any
   sub-pixel edge from SmoothTransformation or HiDPI rounding is black-on-black
   and invisible rather than the #0a0a0a viewport background bleeding through.

3. gallery_view.py: set viewport palette Base+Window to #000 via QPalette so
   Qt's viewport pre-fill between repaints uses pure black instead of the
   inherited widget background color.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
…e, red when pinned

- config.py: ICON_PIN_OFF (round pushpin 📍, text mode) for inactive state;
  ICON_PIN_ON (standard pushpin 📌, text mode) for active state — same
  outline/filled glyph-swap pattern as ICON_HEART_EMPTY / ICON_HEART_FULL
- multiview.py _PIN_OFF: color changed from OVERLAY_FG (white) to FG_DIM
  (gray) so the unpinned state looks dim/outline, not bright white
- multiview.py _PIN_ON: color+border changed from ACCENT (blue) to RED,
  background changed to red-tinted — matches the filled-heart red highlight
- _toggle_pin() now swaps both the glyph text and the stylesheet, mirroring
  how the fav button swaps ♡ ↔ ♥ on toggle

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
…onal to image aspect ratio

Replaces QListView-based uniform-grid with a custom QAbstractScrollArea that
packs items into columns greedily (shortest-column-first).  Cell width is
viewport_width / cols; cell height is derived from the image's actual aspect
ratio so portrait shots are tall and landscape shots are short — nothing is
cropped or letter-boxed, and every image fills edge-to-edge with no gaps.

Dimension sourcing: model._dims (pre-scan) → _pm_dims (extracted from loaded
pixmaps as thumbnails arrive) → 1:1 square fallback.  An 80 ms coalescing
timer batches rapid thumbnail-arrival events so initial loading doesn't
trigger O(n²) re-layouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
Root cause: _pm_dims was cleared on every modelReset (sort change, folder
rescan, finalize_scan), but the model's pixmap cache still held all loaded
thumbnails. Those cached pixmaps never re-emit dataChanged, so _pm_dims stayed
empty and every cell fell back to the 1:1 square — producing letterboxed
portrait images at 1/3 the cell width.

Three changes:
- Stop clearing _pm_dims on reset: path→aspect-ratio is invariant, so stale
  entries are always correct and eliminate the relayout cycle penalty.
- Seed _pm_dims from the model's in-memory pixmap cache during _on_model_reset
  so the first relayout after a reset immediately uses correct cell heights for
  any thumbnails already loaded.
- Wire rowsInserted to _relayout instead of _on_model_reset: batch insertions
  during progressive scanning no longer wipe the overlay, stop video previews,
  or clear accumulated dims.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
…cells

The previous masonry fix relied solely on extracting aspect ratios lazily from
thumbnails as they loaded. For the default "name" sort that left a window where
cells were square (portrait media filling only ~1/3 width with black bars on
either side) until every thumbnail had arrived — and the dimension pre-scan
only ran for area/width/height sorts.

This makes correct masonry the steady state regardless of sort mode:

- main_window._on_scan_done now ALWAYS runs the background dimension scan
  (peek_size() — a fast header-only read off the GUI thread), so model._dims
  holds true (w, h) for every item right after a folder scan, not just when a
  dimension sort is active.

- GalleryModel gains a dimsChanged signal, emitted from set_dims() when no
  re-sort is required (dimension sorts still re-index via modelReset). The
  masonry view listens and reflows, so heights snap to the true aspect ratios
  the moment the scan finishes even under a name sort.

- GalleryView._on_data_changed now skips rows whose pixmap isn't cached
  (LoadedRole) before touching DecorationRole. set_thumb_px() re-emits
  dataChanged for every row on a column/resize change; without this guard that
  stampeded the loader with the entire gallery instead of just visible cells.

- GalleryView.showEvent re-runs _relayout, covering the case where the initial
  layout during setModel happened before the viewport had a real width.

Verified headless: portrait 1:3 -> 3x-tall cell, landscape 3:1 -> short cell,
square -> square; dimsChanged fires for name sort and stays silent for area
sort (where modelReset already drives the relayout).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
…ent previews

Audited every site that touches audio playback and applied the better approach
at each:

- lightbox.py: the volume slider mapped its position straight onto
  QAudioOutput.setVolume()'s linear amplitude, so nearly all the audible change
  happened in the bottom fifth of the track. Route it through
  QtAudio.convertVolume() (logarithmic→linear, Qt's recommended remap) so the
  slider feels uniform end to end. The 80% default and the [/] volume keys now
  also follow the perceptual curve.

- gallery_view.py _VideoPreviewPool: these previews only pull video frames
  through a QVideoSink, yet each of the 4 pooled players carried a muted
  QAudioOutput. Drop them — no audio output means the backend skips audio-stream
  decoding entirely instead of decoding into a muted sink, and the pool no
  longer holds four audio device handles. Pool tuples slim to (player, sink).

- multiview.py: multi-view tiles are silent by design (several play at once),
  but each attached a muted QAudioOutput. Drop it for the same decode savings.

Verified headless: pool play/stop works with the leaner tuples; multiview
constructs with no audio output; lightbox 50% slider now maps to 0.151
amplitude (was 0.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
lightbox.py:
- Stack fills the full window; top bar and transport are now floating
  QWidget overlays (children of self, not in the root layout).
- _bar_widget uses WA_TranslucentBackground so the background is clear
  and each button keeps its own rgba(0,0,0,90) backdrop.
- _transport (video only) starts hidden and is shown/positioned in
  show_row(); its solid BAR_BG background stays legible over any content.
- resizeEvent + _position_overlays() keep both overlays flush to the
  top/bottom edges of the dialog on every resize.

multiview.py _Slot:
- _img_displayed_rect() computes the (x, y, w, h) of the KeepAspectRatio
  image within the slot (mirrors _fit_video's existing _disp tracking).
- _position_overlays() replaces the old full-width btnbar placement:
  for images it uses _img_displayed_rect(), for video it uses _disp,
  so the button bar is constrained to the rendered media area — portrait
  media no longer has buttons floating over the black letterbox bands.
- _fit_video() calls _position_overlays() instead of _position_seek()
  directly; _position_overlays() then calls _position_seek() for video.
- show_item() and clear() both call _position_overlays() so the overlay
  position updates immediately when content changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…istence

- Replace pin icon (pushpin emoji) with pause glyph (⏸); ghost/transparent
  when inactive, red highlight when the slot is held/pinned
- _btnbar starts hidden and fades in/out on slot hover (400 ms debounce);
  pinned slots keep the bar visible until unpinned
- Fix layout auto-switch: only rebuild the grid when all slots are empty
  (row < 0), not merely when nothing is pinned — prevents unpinning from
  resetting the grid and clearing content
The U+23F8 pause codepoint renders inconsistently (colour emoji on some
platforms, tofu on others), which is why the icon appeared unchanged. Draw
two rounded bars with QPainter so it displays identically everywhere, using
alpha for the requested transparent / minimally-obstructing look:
  - idle: semi-transparent white bars (alpha 120) on a faint backing
  - held: solid red bars with a red border
Icons are created per-slot, so a layout rebuild can never reset a held tile's
state (rebuilds already only fire on an empty grid).
Drop the dark backing on the idle pause button and lower the bar alpha
(120 -> 70) so it is barely-there until hovered, per requested look.
…lesheets

- Move the auto-scroll play/pause button + seconds spinner out of the top
  chrome into their own translucent bar pinned to the bottom-centre of the
  window (_position_autoscroll), keeping the top edge clear for media icons.
- Slim each tile's overlay bar margins (6 -> 4/3) so the icons sit flush in
  the top margin of the displayed media.
- Fix a systemic stylesheet bug in multiview.py and lightbox.py: closing
  braces written as `}}` in plain (non-f-string) segments produced a doubled
  `}` in the final CSS, so Qt rejected the whole rule ("Could not parse
  stylesheet") and dropped every button/spinbox/slider style. Collapsed the
  plain-string `}}` closers to `}` (leaving genuine f-string `}}` intact in
  theme.py, seekbar.py, and the one f-string closer in lightbox).
The counter now sits centred at the bottom alongside the play/pause and
seconds controls; the top chrome keeps only prev/next and fullscreen/close.
Groups media so like-dimensioned files sit together in the gallery:
  1. Orientation bucket  — portrait / square / landscape
  2. Quantized aspect ratio (0.05 steps) — all 9:16 together, all 4:3 together, etc.
  3. Pixel area within each ratio group — smallest to largest
  4. Filename as tiebreaker

Files with unknown dimensions sort to the end until the background
dimension scan fills them in, at which point _reindex fires automatically.

Changes:
- model.py: _like_dims_key() method; "like_dims" added to needs_dimensions();
  default self._sort changed from "name" to "like_dims"
- main_window.py: "Dimensions · like sizes" inserted as the first combo item
  so it is selected on first run (no saved prefs); saved prefs still restore
  correctly via findData()
Drag-and-drop reorder:
- Drag one tile's media onto another to swap their positions. The bare media
  body is made transparent to mouse events so the slot owns the drag gesture,
  while the floating overlays (pin/fav/etc. and the seek bar) keep their clicks.
- _Slot starts a QDrag carrying its file path past the drag threshold; the drop
  target emits reordered(src, dst), which MultiView forwards to the model.
- model.swap_paths() swaps the two items and switches to a new "manual" sort
  mode, freezing the currently-displayed order as the baseline first so the
  gallery doesn't snap back to scan order. It emits sortChanged("manual"); the
  main window syncs its sort combo (new "Manual order" entry) without looping.

Video speed in multi-view:
- Each video tile gains a speed button in its seek bar cycling
  0.25/0.5/1/1.25/1.5/1.75/2x (same set as the lightbox). The current rate is
  applied via setPlaybackRate whenever a video loads into the tile.
Items addressed:
1. MultiView changed from QDialog to QWidget, embedded via QStackedWidget in
   MainWindow. "← Gallery" button in the top chrome returns to gallery view.
   Lightbox closes itself before triggering multi-view open to avoid layering.

2. Dimensions pre-computed before populating slots: _refresh_orientation_lists()
   reads model.dim_at() (cached) and falls back to a synchronous media.peek_size()
   header read for any paths not yet dimensioned. MainWindow._ensure_dims() is
   triggered on every multiview open so the background job is already running.

3. 3×1 portrait layout restored. Model paths are partitioned into portrait and
   landscape groups; _detect_layout() returns 3 (3×1) for portrait, 4 (2×2) for
   landscape. Groups never mix — remaining slots fill with duplicates from the
   same group. _switch_layout() preserves pinned media across 3↔4 slot changes by
   snapshotting pin state, rebuilding the grid, then restoring pinned content to
   the first N slots with correct speed and pin visual.

4. Overlay / OS chrome overlap eliminated — multiview is now inside the main
   window, so the floating chrome overlay never conflicts with window buttons.

5. Audio enabled per tile via QAudioOutput (muted by default). Mute/unmute icon
   button in each tile's seek bar toggles audio. Clicking unmute shows a
   pop-out vertical volume slider with perceptual (quadratic) volume mapping.
   model.dim_at() and model.row_for_path() added for O(1) lookups from multiview.
   config.py gains ICON_MUTE, ICON_UNMUTE, ICON_BACK constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
Removes the session-restore logic that re-opened the most recently used folder
on startup. The gallery now always launches empty; users open folders explicitly
via the Open button or a CLI argument. The --no-restore flag is removed (no
longer needed) and last_folder is no longer written to prefs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
- model.py: add add_paths_silent() to stage paths in _all without
  inserting them into the visible _rows. Used during scans when a
  dimension-based sort is active so media never appears unsorted.

- main_window.py: _on_scan_batch uses add_paths_silent when
  needs_dimensions() is true; _on_scan_done defers finalize_scan()
  for dimension sorts and shows "Computing dimensions..." until
  _on_dims_done -> set_dims -> _reindex() reveals everything in
  correct sorted order.

- main_window.py: stop saving/restoring sort mode in prefs so the
  app always opens with the "Dimensions · like sizes" default (index 0)
  rather than restoring a stale "Name" from a previous session.
- Layout: convert chrome and bottom bars from floating overlays to proper
  QVBoxLayout members so the grid host sits between them with no z-order
  overlap. This makes seek bar, speed button, and mute button fully
  interactable without being blocked by a raised sibling widget.

- Side-scroll autoscroll: the autoscroll button now starts a 60 fps
  pixel-by-pixel left-scroll instead of page-jumping. n+1 slots are
  managed as a circular buffer — when a tile exits the left edge it is
  recycled to the right and loaded with the next item. Speed is
  controlled by the spin box (1×–10× = 0.5–5 px per 16 ms tick).
  Prev/Next stop the scroll and switch to manual page mode.

- Orientation toggle: ↔/↕ button in the bottom bar switches between
  the portrait and landscape path groups so users can navigate across
  orientation groups in the same session. Disabled when the target
  group is empty; tooltip shows the item count of the target group.

- Ctrl+M: unmutes all currently-displayed video slots at once.

- _Slot.unmute() public helper used by Ctrl+M.
gallery_view.py: paint a centred guidance message when the grid is empty
  instead of a bare black screen. set_empty_hint(primary, secondary) lets
  the main window distinguish "no folder open", "scanning", "no media
  found", and "filter hides everything".

main_window.py: drive the empty-state hint through open_folders (scanning),
  _on_scan_done (no media found), and _apply_filter (filter hides all). This
  directly addresses the blank screen left by the empty-on-startup default
  and the silent dimension-staging scan.

exif_panel.py: fix a real bug — line 23 set a bogus attribute
  (lbl.setTextInteractionByMouse = None) instead of enabling selection. Now
  all values are selectable/copyable, word-wrap, and the dialog inherits the
  app theme. Added the full file Path row, B/KB/MB/GB size formatting, and
  m:ss video duration.

multiview.py: replace the ad-hoc x^2 volume curve with Qt's perceptual
  QtAudio.convertVolume log->linear remap, matching the lightbox transport
  so the slider feels uniform end to end.
The model never connected the loader's `failed` signal, so a corrupt or
unreadable file silently showed "…" forever AND was re-submitted to the
decode pool on every single repaint/scroll (the data(DecorationRole) path
re-requests any path not in the pixmap cache). For a folder with a few bad
files this quietly hammers the thread pool during scrolling.

- GalleryModel now connects loader.failed → _on_thumb_failed, recording the
  path in a _failed set and emitting dataChanged(FailedRole).
- data(DecorationRole) skips re-requesting paths known to have failed.
- A successful later decode (file replaced on disk) clears the failed flag.
- _failed is cleared on set_paths (new folder) and pruned on remove_path.
- New FailedRole; GalleryView paints a dim "⚠ unreadable" marker for failed
  cells instead of the same "…" used for still-loading cells.
The lightbox has ~18 keyboard shortcuts that were completely invisible.
Add a help overlay listing them, toggled by ? or F1 (and a "?" button in
the action bar). Esc now dismisses the help overlay first and only closes
the viewer on a second press, so the help is easy to back out of.
The autoscroll control previously only did smooth side-scroll. Add a
switchable slideshow with two styles sharing one play/pause button:

- Set: holds the current 3x1 / 2x2 grid for N seconds, then jumps to the
  next set (paged advance, wrapping at the end). Driven by a new _set_timer.
- Side-scroll: the existing smooth continuous left-scroll.

A mode button (⊞ Set / ⇆ Scroll) and the S key toggle the style; the
spin-box adapts its units to the active style (seconds for Set, 1x-10x
speed for Scroll) and remembers each style's last value independently.
Manual prev/next, orientation switch, trash, and reorder all stop the
slideshow via a single _stop_slideshow() helper (replacing the scattered
side-scroll-only stop checks).

Also fixes a latent stylesheet bug: the slot favourite-heart button's
stylesheet opened with an f-string `{{` (one brace) but closed with a
plain-string `}}` (two braces), producing invalid CSS that Qt silently
dropped — so the heart button lost its styling every time a slot loaded
an item. Closing braces corrected to single `}`.
- config/theme: platform-aware font. "Segoe UI" only exists on Windows; on
  Linux/macOS Qt fell back to an ugly default. Pick a native family per OS
  with a CSS fallback stack (FONT_STACK) used by the stylesheet.

- Favourites-only filter: new "♥ Favs" toolbar toggle and a favs_only flag
  in GalleryModel.set_filter / _passes_filter. Un-favouriting an item while
  the filter is active drops it from the view; a tailored empty-state hint
  shows when there are no favourites.

- Recursive scan: "Include subfolders" checkbox in the folder picker, plumbed
  through open_folders -> _StreamScanJob -> scan_iter (engine already
  supported recursion; it was just hardcoded off).

- Dead code: removed unused delegate.py (CardDelegate was never imported) and
  the URL_CACHE_DIR constant + its mkdir (never read or written anywhere).
Adds a real selection model to the masonry grid — the single biggest
capability gap. Previously every action targeted one item at a time.

GalleryView:
- Click selects; Ctrl+click toggles; Shift+click / Shift+arrows extend a
  range; Ctrl+A selects all; Esc or click-empty clears. Selected cells get
  a translucent accent fill + border.
- F favourites the selection (or cursor); Delete/Backspace trashes it.
- New signals favBatch(rows) / trashBatch(rows) / selectionChanged(count);
  selection is cleared on any model reset (folder/filter/sort change).
- StrongFocus so the keys land after a click.

MainWindow:
- _on_grid_fav_batch: favourites all if any is unfavourited, else clears
  all (standard toggle semantics); respects the favourites filter.
- _on_grid_trash_batch: resolves paths up front (remove_path shifts rows),
  clears the selection, trashes as one undoable batch.
- Trash undo refactored from a flat (orig,trash) stack to a stack of
  batches, so one Undo restores everything deleted together. Single-item
  trash (grid/lightbox/multiview) funnels through the same path with
  identical behaviour.
- Status bar shows the live "N selected" count.
claude added 30 commits July 18, 2026 22:41
Three fixes for media on external/slow storage (USB SSDs, hubs, HDDs,
drives waking from autosuspend):

1. Rotation runs on a worker (_RotateJob): the old synchronous path did the
   full read + re-encode + write on the GUI thread — seconds of freeze when
   the file lives on (or the drive is waking over) USB. The click returns
   in ~0.2 ms with a "Rotating…" status; completion refreshes the model,
   dims, favourites mirror, and any lightbox/multi-view currently showing
   the file. An in-flight guard drops duplicate requests per path.

2. Deletes never touch the disk on the GUI thread: the cross-device os.stat
   check is gone (a stat against a sleeping USB drive blocks for seconds).
   ALL trash moves now run on the worker with optimistic model removal —
   same-device renames complete in milliseconds and the undo bar arms when
   the worker reports back.

3. Slow-storage throttle: drive-type APIs can't reliably identify external
   SSDs (USB enclosures report as fixed disks), so the app MEASURES —
   on each folder open a worker reads 32 KB from a few sample files; if the
   average read exceeds 6 ms the thumbnail loader drops to 2 threads and
   dims probing to 1 (over one USB pipe, 8 parallel readers finish slower
   in aggregate than 2 and starve video playback). Restores full
   parallelism below 2 ms, with hysteresis between. A "low_io_mode":
   true/false prefs entry forces the mode and skips probing.
…box return

- The floating settings bar overlaid the top edge of the multi-view tiles,
  which both OBSCURED each tile's icon-button row and swallowed its clicks —
  this is why the tile rotate button 'stopped working' (fav/pin/enlarge on
  the top row were equally dead). The multi-view layout now reserves a real
  strip below the chrome (set_top_inset) sized to the bar, so tiles start
  below it: buttons visible and clickable again. Verified with a real
  button-click driving the async rotate pipeline.
- Lightbox entered from multi-view now shows a '← Multi-view' button (and
  only then): it returns to the EXACT page the viewer was opened from —
  same orientation group, page index, forced layout, and pins — via the new
  MultiView.reopen(), which refreshes lists against the model (deletions/
  rotations that happened in the viewer are reflected) without resetting
  state the way open() does.
- Filtering now distinguishes three media classes: Images / GIFs / Videos,
  each with its own toolbar toggle (model set_filter gains a gifs flag;
  GIFs default to following the images toggle for older callers).

- Favourites now mirror into a Favorites subfolder INSIDE the folder each
  file lives in, replacing the catch-all destination: hearting
  X\pics\a.jpg copies it to X\pics\Favorites\a.jpg; unhearting removes it
  (only if identical), rotation resyncs it. Recursive scans skip Favorites
  subfolders so mirror copies are never re-imported as duplicates. A new
  toolbar toggle (folder+heart) views the Favorites subfolder(s) of the
  currently opened folder(s) and restores the previous folders when
  toggled off. The old global favorites_dir prefs override is retired.

- Multi-view tiles get a row of very small tag buttons along the media's
  bottom edge (above the seekbar on videos): T, BT, HT, Az, Bcs, WAM, Jz,
  Ahg. One click toggles the descriptor on the file's tags; the button
  highlights while present. Tags persist in a JSON sidecar store
  (works for all formats incl. video) and are additionally embedded into
  JPEG EXIF XPKeywords — the field Explorer shows as "Tags" — losslessly
  via piexif when that package is installed, on the background worker.
Extends embedded-metadata tagging beyond JPEG:
- GIF: insert an XMP Application-Extension block (dc:subject) via direct
  binary surgery — no frame re-encode. Parses the GIF block structure to
  find the trailer and strip any prior XMP block, then re-inserts. Verified
  lossless on animated GIFs: frames, per-frame durations, NETSCAPE loop, and
  comment blocks all preserved through embed/re-embed/clear round-trips;
  refuses to touch a GIF whose structure doesn't parse.
- Video (.mp4/.m4v/.mov/.wmv): write System.Keywords (Explorer's "Tags")
  through the Windows Property System via ctypes — the OS writes its own
  containers; we never hand-edit video files. Clean no-op on non-Windows.
- Failed embeds (e.g. a video still held open by a player) queue in _PENDING
  and retry via flush_pending() when multi-view releases media, and on the
  next toggle. Rotation re-embeds tags after the file is re-encoded.

The JSON sidecar store remains the source of truth for every format, so
in-app tags already work everywhere; this adds portable/Explorer-visible
metadata. Hardening from an in-progress adversarial verification pass
(XML escaping, GIF87a->89a, video permanent-failure handling, thread
safety) follows in a subsequent commit.
…ified

Extends embedded tag-writing beyond JPEG so tags survive outside the app for
GIFs and videos too. The JSON store remains the source of truth; embedding is
best-effort on the background worker.

- GIF: inserts an XMP Application-Extension block (dc:subject) by splicing the
  GIF byte stream — NO frame re-encode, so animation, loop (NETSCAPE2.0),
  frame durations, disposal, and comments are all preserved (verified on real
  animated GIFs incl. end-to-end via the multi-view tag buttons). Idempotent
  re-embed (single XMP block, stable size), lossless embed+clear round trip.
- Video (.mp4/.m4v/.mov/.wmv): writes System.Keywords via the Windows Property
  System (SHGetPropertyStoreFromParsingName + IPropertyStore::SetValue/Commit)
  — Explorer's "Tags" field, written by the OS's own handler, never by hand-
  editing the container. No-op off Windows.

Hardening from an adversarial verification pass (9 confirmed findings):
- Unbounded-retry fix (the one medium): permanent failures (unparseable file,
  no writable property handler, access-denied) are dropped, not queued;
  transient failures (file busy/locked/sharing-violation while a video plays)
  retry up to 6x; _PENDING is size-capped. Prevents an ever-growing futile
  retry batch. _PENDING/_retries guarded by a lock (worker vs GUI thread).
- GIF87a signature bumped to GIF89a when adding the (89a-only) extension.
- XML-escape tag values (JSON store is user-editable) + unescape on read.
- JPEG XPKeywords now NUL-terminated (Explorer convention); empty tag set
  deletes the field instead of writing a zero-length one.
- Video ctypes: NULL-store guard (prevents an access-violation crash),
  transient-vs-permanent HRESULT classification, balanced CoUninitialize,
  consistent HRESULT masking.
- Rotation now reembeds tags BEFORE resyncing the favourite mirror (same
  FIFO worker), so the mirror copy carries the restored tags.
…avorites

Creates one subfolder per tag name (T, BT, HT, Az, Bcs, WAM, Jz, Ahg) inside
the Gallery Favorites folder (config.FAVORITES_DIR). A file is copied into
<FAVORITES_DIR>/<TAG>/ while it is BOTH favourited AND carries <TAG> AND is an
image, GIF, or short video (<= 10 min); the copy is removed when it loses the
tag, loses the favourite, or (for videos) exceeds the length limit.

- Reconciliation (tags.sync_tag_folders) runs on the mirror worker and is
  triggered from every favourite change (grid, batch, lightbox/multi-view via
  _toggle_fav_path) and every tag toggle (multi-view tile), reading the tag set
  on the GUI thread and doing disk work + the video-duration probe off it.
- A JSON manifest records each copy's exact path per (source, tag). Identifying
  a copy by content is unreliable — embedding tags and rotation mutate the
  source so it no longer matches its earlier copy — and basenames collide
  across source folders; the manifest makes add/remove exact and handles
  collisions (a.jpg, a_1.jpg). Single-worker access, no lock needed.
- Short-video gate uses media.peek_duration (<= 600 s); unknown/zero duration
  is excluded. Files already inside FAVORITES_DIR are skipped.

Verified end-to-end via the real UI: tag-then-favourite copies in; untag or
unfavourite cleans up; grid/batch/multi-view all trigger it; collisions and
the 10-minute video cutoff behave.
Append "Bp" to the TAGS tuple — the single source of truth that drives both
the multi-view tag buttons and the per-tag Favorites subfolders, so the new
button and its Bp/ aggregation folder appear with no other changes.
First committed automated tests for the app, extracted and hardened from the
inline verification used across development. Covers the intricate, regression-
prone subsystems:

- tests/test_tags_embed.py — GIF XMP byte-splice preserves animation/loop/
  durations/pixels; idempotent re-embed; XML escaping; GIF87a->89a; corrupt-
  GIF refusal; retry classification (permanent dropped, transient capped,
  _PENDING size-bounded); JPEG XPKeywords NUL-termination + delete-on-empty.
- tests/test_tag_folders.py — per-tag aggregation folders: copy/remove
  reconciliation via the manifest (immune to source content drift), basename
  collisions, short-vs-long video gating.
- tests/test_model.py — Images/GIFs/Videos filter, chained multi-sort
  (like_dims -> name, secondary tiebreak), batch removal index integrity.
- tests/test_favorites.py — per-folder mirror, trash/restore, manifest.
- tests/test_views.py — masonry hit-testing & visible-cell culling vs brute
  force; MultiView 3x1/2x2 auto, forced-layout reset, justified tile aspect,
  tag buttons.

conftest.py isolates every persistent path + module cache per test.
.github/workflows/gallery-tests.yml runs it headless on push/PR.
gallery_py_qt/requirements{,-dev}.txt pin the deps. 40 tests, all green.
Closes the retrieval half of the tag system — you can now show only items
carrying chosen tags, not just apply tags.

- model.set_filter gains tag_filter (set) + tag_match_all (ANY default / ALL)
  params, folded into the single reindex; _passes_filter reads the tags store.
  Back-compatible: no tag_filter -> no tag filtering.
- Toolbar "Tags ▾" menu: a checkable action per tag, a "Match all (AND)"
  toggle, and "Clear tag filter". The button shows a count badge when active.
  Combines with the Images/GIFs/Videos/Favs filters and search.

tests/test_tag_filter.py: single/ANY/ALL matching, media-class combination,
and toolbar wiring. Full suite now 46 tests, green.
Tags were only settable one tile at a time in multi-view; now a grid
multi-selection can be tagged at once.

- GalleryView emits contextMenu(globalPos) on right-click, selecting the
  clicked cell first if it isn't already in the selection (file-manager UX).
- MainWindow builds a selection menu (_build_grid_menu, separated from exec
  for testability): Favourite selection, a Tag submenu (each tag checked when
  ALL selected items carry it), and Delete selection.
- _batch_tag toggles a tag across the selection with the batch-favourite
  convention (add to all if any lacks it, else remove from all), embedding +
  syncing tag folders per file, refreshing multi-view highlights, and
  re-applying an active tag filter.
- MultiView.refresh_tag(path) updates tile tag highlights in place.

tests/test_batch_tag.py: add-to-all / remove-when-all / partial-add,
tag-folder sync for favourited items, context-menu selection, menu builder.
Suite: 52 tests, green.
The tag set is no longer hardcoded — users manage it, and it persists.

engine/tags.py:
- Tag set loads from _TAGSET_FILE (default = the shipped nine); get_tags(),
  set_tags(), add_tag(), remove_tag(), rename_tag().
- remove_tag strips the tag from the JSON store and deletes its Favorites
  subfolder + manifest entries. rename_tag migrates the store, renames the
  Favorites subfolder (merging on collision), and repoints the manifest so
  later add/remove of copies still works. Embedded metadata refreshes on the
  file's next tag change (store is source of truth).

UI:
- tag_manager.TagManagerDialog: add / remove / rename with confirmation.
- Reachable via "Manage tags…" in the Tags filter menu; on change it rebuilds
  the filter menu, the multi-view tile tag buttons (MultiView/_Slot
  .rebuild_tag_buttons), and re-applies the filter — all live.

Test hardening (fixes intermittent full-suite slowness from the shared
mirror worker bleeding across tests): conftest drains the mirror pool in
teardown; pytest.ini adds a per-test --timeout guard (pytest-timeout).

tests/test_tag_customize.py covers persistence, dedup, store + folder
migration, rename collision rejection, and the filter/multiview rebuilds.
Suite: 62 tests, green and stable across repeated runs.
Search now goes beyond filename substring. Tokens are ANDed:
  holiday                filename contains "holiday"
  tag:BT                 carries tag BT
  fav:yes / fav:no       favourite state
  type:image|gif|video   media class (img/vid accepted)
  w>1920  h<=1080        pixel dimensions (<,>,<=,>=,=,:)
Bare words are substrings (all must match); unknown keys fall back to
substring so a stray colon never silently zeroes results.

- engine/query.compile_query(text) -> predicate(info) | None. Every predicate
  reads only already-cached data (dims/tags/fav/class), so per-item evaluation
  during filtering stays cheap.
- model.set_filter compiles the query and _passes_filter evaluates it, ANDed
  with the toolbar Images/GIFs/Videos/Favs/tag filters. Search box gains a
  syntax tooltip.

tests/test_query.py: parser (substring, tag, fav, type, numeric dims,
combined, unknown-key fallback) + model integration. Suite: 76 tests, green.
…ility)

Favourites and per-tag aggregation can now hardlink or symlink the original
instead of copying it — big disk savings, especially for videos.

- favorites.place_file(src, dst) honours LINK_MODE (copy|hardlink|symlink),
  replacing the destination and falling back to a real copy when linking
  isn't possible (cross-device hardlink, no symlink privilege, FS without
  link support). Used by the favourites mirror (_mirror/_resync/_unmirror,
  which now handle links) and the tag-folder sync.
- resync after rotation re-places the entry so a hardlink can't point at
  stale pre-rotation content. _unmirror removes a link we placed or a
  same-content copy (never an unrelated same-named file).
- MainWindow loads link_mode from prefs at startup (Settings UI next).

Media-init stability (lazy pipelines — also real runtime wins):
- MultiView slot audio (QAudioOutput) is created lazily on first unmute
  instead of one per slot up front.
- GalleryView's video-preview pool creates its QMediaPlayers lazily up to
  MAX instead of all eagerly per gallery.
- conftest destroys widgets + drains the mirror worker after each test, so
  media pipelines don't accumulate and stall Qt under offscreen; forces the
  raster viewport (GALLERY_NO_GL) in tests.

tests/test_link_mode.py: copy/hardlink/symlink placement, unfavourite
removes the link, fallback-to-copy, tag folders honour the mode. Suite: 83
tests, green and stable across repeated runs (~1.25s).
Ratings (engine/ratings.py): per-file 0–5 stars in a JSON store, independent
of tags/favourites.
- Wired into search (rating>=4, rating=0 for unrated), sort ("Rating" option,
  highest first), and the model's query info.
- Lightbox gains a 5-star control: click the Nth star to set N, click it again
  to clear; stars reflect the current value and update on navigation.

Settings dialog (settings_dialog.py), opened from a ⚙ toolbar button —
surfaces options that were prefs-file-only, applied live and persisted:
- Theme, favourites/tag-folder placement (copy / hardlink / symlink — makes
  the link-mode option usable without editing JSON), parallel-reads mode
  (auto / force reduced / force full), trash auto-purge days, and a shortcut
  to Manage tags.

tests/test_ratings.py: store (set/get/clamp/zero-removes/cycle/persist),
query, sort + filter, lightbox star UI, settings-dialog result mapping.
Suite: 93 tests, green and stable (~1.4s).
Gallery grid: dragging the mouse horizontally across a hovered video
preview now seeks its timeline. The video preview pool gains scrub()
(pause + seek to the mouse-x fraction of duration) and resume(); the
view tracks the scrubbed path and resumes playback when the pointer
moves to another cell, leaves the view, or previews are suspended.

Import picker: the folder/file dialog gains a "Show:" combo to filter
by media class (all / images / GIFs / videos), driving the filesystem
model's name filters. Directories stay visible regardless.

Tests: test_hover_scrub.py (5) and test_import_picker.py (3).
New engine.dupes finds byte-identical media across the loaded set in
cheapening stages — bucket by size, split by a first+last-chunk partial
key, then confirm with a full content hash — so mostly-unique folders pay
almost nothing. It is pure (no Qt), runs on a worker thread, and reports
progress / honours cancellation.

DuplicatesDialog scans off the GUI thread and groups the results, each
member shown with a thumbnail, path, and modified time. "Select all but
newest" pre-picks the redundant copies; trashing routes through the main
window's existing worker-move + undo pipeline, and "Reveal" selects and
scrolls to a copy in the grid (new GalleryView.reveal_row). A "Duplicates"
button in the control bar opens it.

Tests: test_dupes.py (7) and test_dupes_dialog.py (7).
engine.foldertags is the folder-level analogue of engine.tags: assigning a
tag to a whole album folder mirrors the entire directory into
<FAVORITES_DIR>/folder tags/<TAG>/<album>/ as a copy or a link, honouring the
existing LINK_MODE preference (copy / hardlink / symlink). copytree skips the
nested Favorites / folder-tags dirs; symlink mode makes a single directory
symlink; hardlink mode recreates the tree with per-file hardlinks. Same-
basename albums get a uniquified mirror name. The tag→album assignment lives
in a JSON store (store mutated on the caller thread), with a manifest DB and
all disk work on the shared mirror worker, so copy-on/off never races.

The shared tag vocabulary is reused, so removing or renaming a tag now also
cleans up / migrates its folder-tag mirror (new hooks in tags.remove_tag and
tags.rename_tag).

AlbumTagsDialog lists the loaded folders (plus any the user adds) with
checkboxes and toggles tags across the checked albums with the grid's batch
semantics; a "Tag albums" control-bar button opens it. conftest isolates the
new sidecar stores.

Tests: test_foldertags.py (10) and test_album_tags_dialog.py (5).
Seven UI improvements:

1. Album tagging now browses like the import picker. _CheckFSModel, the
   hover thumbnail preview and the quick-access/media-class row are
   factored out of main_window into a new fs_picker module (main_window
   re-exports _CheckFSModel for compatibility). AlbumTagsDialog gains a
   checkbox filesystem tree, a thumbnail contents pane showing the browsed
   folder's media, and multi-folder ticking that feeds the batch tag
   toggle. Folders under the Favorites tree are reported as skipped rather
   than silently no-op'd; the shared loader is disconnected on close.

2. The tag chips at the bottom of each tile lose their dark backing bar —
   the container is translucent with a drop shadow for contrast, and the
   chips paint no fill (state shown by accent colour + weight).

3. The orientation toggle button shows the other group's item count
   (e.g. "↕ 12"), marked provisional ("~") while dimensions are measuring.

4. New +/- buttons (and +/-/0 keys) zoom each tile's media coverage in 10%
   steps, 50%–200%, for images and videos; a clickable readout resets it.

5. The per-tile rotate button gains a 90/180/270 drop-down; the rotated
   signal now carries degrees. Disabled on video tiles; the tile bar no
   longer auto-hides while its popup is open.

6. The lightbox gains an origin-aware "← Back" button (returns to the
   multi-view set it was opened from, or the gallery) and an
   exit-full-screen button; Esc follows the same return path.

7. Each tile shows a minimal transparent "↗ filename" link that reveals
   the file in the OS file manager (new engine.shell reveal_path), in both
   the grid and the side-scroll view.

Tests: test_mv_chrome, test_lightbox_back, test_shell, rewritten
test_album_tags_dialog. Full suite 183 passing.
Three medium-severity defects found by an adversarial review of the source
hyperlink feature:

- shell.reveal_path built the Freedesktop D-Bus ShowItems argument as a raw
  file:// URI, so any path with a space, '#', '%' or non-ASCII byte resolved
  to the wrong path and selected nothing (and fire-and-forget launch meant the
  open-folder fallback never fired). Percent-encode the path.

- shell.reveal_path passed `explorer /select,<path>` as an argv list, which
  list2cmdline quotes as one token; Explorer then ignores /select and opens
  Documents. Pass it as a single command string so only the path is quoted.

- AlbumTagsDialog._inside_favorites used a bare startswith with no path
  boundary, so a sibling folder sharing the Favorites leaf-name prefix (e.g.
  "Gallery Favorites_backup") was wrongly reported as inside Favorites and
  skipped from tagging. Compare on a path-separator boundary.

Tests: 4 new in test_shell.py, 1 in test_album_tags_dialog.py. Suite 187 green.
New WelcomeDialog gives a sectioned tour of what the program does — browse,
organise (favourites / tags / ratings / album tagging), find (search +
duplicates), view (lightbox / multi-view / hover-scrub / source links) and
housekeeping (trash / settings) — with the copy in a module-level SECTIONS
table so it's testable and easy to keep current.

It shows automatically on first launch (triggered from the app bootstrap
after the window paints, never during construction, so building a MainWindow
in tests raises no popup) and is re-openable anytime from a new "Guide"
button. A "Show this guide at startup" checkbox persists via the prefs flag
welcome_seen, so first-run auto-display and the manual button share one
setting and the user can opt out or back in.

Tests: test_welcome_dialog.py (8) — content coverage, startup-flag
round-trip, and that construction shows no dialog. Suite 195 green.
Right-clicking a video's seek bar now drops loop points: the first click sets
the loop-in point (A), the second the loop-out point (B) — after which
playback repeats only the A–B span — and a third click clears it. B set before
A is swapped so order doesn't matter. The selected span is painted as a
translucent accent band with end markers over the groove.

The state machine, painting and a loopChanged(a, b) signal (fractions; <0 =
unset) live in SeekBar, so both the lightbox and every multi-view tile get the
feature. Each player enforces it in its positionChanged handler — jumping back
to A the instant playback reaches B, and snapping into the span immediately if
the loop is set while already past B. The loop resets whenever the media
changes (show_row / show_item / clear).

Help overlays and the welcome guide document the right-click gesture.

Tests: test_ab_loop.py (13) — the SeekBar cycle/swap/clear/paint, right-vs-left
click routing, and enforcement in both the slot and the lightbox. Suite 208.
The recursion guard skipped every folder located anywhere under
config.FAVORITES_DIR, so anyone whose media library lives inside their
Gallery Favorites directory hit "N folder(s) live inside your Gallery
Favorites ... they were skipped" on essentially every tag — and the tag was
never applied. The guard was also a bare startswith with no path boundary, so
a sibling like ".../Gallery Favorites_backup" matched too.

The real hazard is only re-mirroring a folder that is itself a mirror copy
(nesting copies inside copies); place_folder already skips the "folder tags"
and "Favorites" names, so a library folder elsewhere under Favorites copies
cleanly. Narrow the guard to just the "folder tags" mirror tree, boundary-
aware, exposed as foldertags.is_mirror_path() and shared by the dialog. The
skip message is reworded to match.

Tests: engine now proves a folder under FAVORITES_DIR still mirrors while the
mirror tree stays guarded (+ sibling boundary); the dialog proves such a
folder tags with no bogus dialog.
GIFs never animated: both viewers special-cased only *video*, so a GIF (neither
video nor a still) fell through to a one-shot image decode and showed a single
frame. load_gif_frames() existed in media.py but was wired to nothing.

Add a QMovie-based GifPlayer that decodes and times frames correctly (per-frame
disposal + the file's loop count) and hands each frame's pixmap to a sink, so
the existing image widgets do the scaling — the lightbox's zoomable _ImageView
(first frame fits, later frames swap without refitting, preserving zoom/pan)
and multi-view's fit/fill/zoom _AspectLabel. An undecodable ".gif" falls back
to the old static decode. Space pauses/resumes a GIF in the lightbox; the movie
is stopped (releasing its file handle) on navigate-away, clear, trash and close.

Adds media.is_gif().

Tests: test_gif_playback.py (12) — GifPlayer frame delivery / pause / fallback,
and animation + cleanup in both the slot and the lightbox. Suite 223 green.
The album manager's folder tree now supports normal file management on a
multi-selection of files and folders — right-click menu plus F2 (rename),
Del (delete), Ctrl+X (cut) and Ctrl+V (paste), and a New folder action.

New engine.fileops (Qt-free, batch, total — never aborts on one bad item):
move_paths (collision-suffixing, refuses moving a folder into itself),
rename_path (validates separators/empties/collisions), delete_paths (to the
app's recoverable trash), make_folder. Deletes go to trash, not an
irreversible unlink; the trash machinery now handles directories too
(list_trash includes them, purge_item rmtree's them), so a deleted folder is
restorable from the Trash browser.

Folder-tag bookkeeping follows the filesystem: deleting a tagged album forgets
its tags and removes its mirrors (foldertags.forget_folder); renaming or
moving one re-mirrors it at the new path across any link mode
(foldertags.relocate_folder). A ticked folder keeps its tick across a
rename/move (new _CheckFSModel.is_checked, which ignores on-disk existence).

Tests: test_fileops.py (15) and 11 dialog tests covering rename/delete/
cut-paste/new-folder, tick re-keying, tag relocate/forget, and error
surfacing. Suite 248 green.
When a tile's media is scaled past its bounds — Fill mode, or the ±zoom over
100% — it was always centre-cropped with no way to choose which part showed.
Now, on whichever axis the media overflows, a thin edge slider appears (with
the hover button bar): a horizontal one along the bottom, a vertical one down
the right. They pan the visible window of the over-scaled media.

Works for both images and video: _AspectLabel crops at an _ox/_oy offset
(centre by default) and reports per-axis overflow; _fit_video offsets the
QGraphicsVideoItem the same way. A letterboxed axis stays centred and its
slider stays hidden. Pan recentres whenever the tile's media changes.

Help overlay and welcome guide mention it.

Tests: test_pan_slider.py (12) — overflow detection, offset panning of the
crop, video item repositioning, slider→offset wiring (incl. the vertical
top=top mapping), reset on new media, and hover/overflow-gated visibility.
Suite 260 green.
- Uniform tile windows: the 3×1 / 2×2 cells are now equal, fixed-size windows
  that don't change with the media on show or the zoom level. Only the media
  inside each fixed window scales (Fit contains, Fill covers, ± zoom grows or
  shrinks it), so the layout stays put while zooming instead of re-justifying
  every tile to its item's aspect ratio.

- Pan arrows replace the pan sliders: the reposition control for overflowing
  media is now edge arrow buttons (◀▶ / ▲▼) that nudge the crop a fixed step
  per click (with press-and-hold auto-repeat). This re-renders the crop a
  handful of times instead of continuously as a slider is dragged.

- Repeat-tags "ditto" button (〃) in each tile's tag bar applies the most
  recently used tag set to the current file (tags.recent_tags / apply_recent);
  it lights up only when there are remembered tags the file still lacks.

Tests updated for uniform windows (+ a zoom-doesn't-move-windows check); new
test_recent_tags.py (7); pan tests rewritten for the arrows. Suite green.
Import picker & album manager:
- The media-type filter becomes multi-select: the single-choice combo is
  replaced by a "Show ▾" button whose checkable menu (Images / GIFs / Videos)
  lets several classes show together — e.g. GIFs AND videos by unchecking
  Images. _CheckFSModel.set_media_classes() takes the union; set_media_class()
  stays as a single-class wrapper.

Album manager contents pane:
- Subfolders are now listed (with a folder icon, trailing "/") BEFORE the
  media files, so the folder structure shows first; double-clicking a folder
  tile browses into it. "Favorites" mirror subfolders are excluded.
- A "Sort by" control orders the media by Type, Name, Size, Dimensions or
  Tags (folders always first, sorted by name). Dimensions reads sizes via
  media.peek_size only when that sort is chosen.

Tests: import-picker multi-select (2 rewritten), album pane subfolders-first
+ each sort key + folder navigation (7 new). Suite 275 green.
Behaviour change (as requested): in the album manager, ticking folders and
clicking a tag now applies that tag to EVERY media file inside the ticked
folders (recursively) as a real per-file tag — so each file carries the tag on
its chip and is searchable by tag: — instead of mirroring the whole folder
into a "folder tags" subfolder. New tags.apply_tag_to_paths() writes the whole
batch in a single store save.

Click-drag selection: the contents pane is now an ExtendedSelection icon view
with a visible rubber band, so you can drag to highlight specific files; when
any are highlighted the tag buttons act on just those, otherwise on all media
in the ticked folders. Both pickers' trees keep their drag-swipe range
selection.

The folder-tags mirror engine (foldertags) is retained and still drives the
rename/move/delete bookkeeping, but is no longer the tag action; the dialog's
mirror-skip guard, link-mode label and folder-tag messaging are removed.

Tests: per-file tagging (recursive, ticked-only, batch toggle, highlighted-
files target, no-op), apply_tag_to_paths (2); obsolete mirror-guard dialog
tests removed. Suite 274 green.
The import picker's quick-access buttons joined folder names naively onto
the home path (~/Downloads), so a Downloads folder relocated by OneDrive or
an XDG user-dirs override was missing, and clicking a quick-access button on
a folder the tree had never expanded silently did nothing (QFileSystemModel
populates on a gatherer thread, leaving index() invalid on cold paths).

- Resolve quick-access locations through QStandardPaths.writableLocation so
  redirected Downloads/Pictures/Videos/Desktop folders reach their real
  targets, falling back to the naive home join and de-duplicating.
- Give _FolderPickDlg._goto the same cold-path retry the album dialog has:
  stash the target, setRootPath its parent, and finish the jump from
  directoryLoaded once the model has populated.
- Add regression tests for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
Auto-detection can still miss a Downloads folder (Windows OneDrive layouts,
locked-down profiles), leaving the user with no way to reach it.  Add a
free-text path box beneath the quick-access shortcuts: paste or type any
directory and press Enter (or Go) to jump the tree straight there via the
cold-path-aware _goto.

normalize_pasted_path() copes with what people actually paste — file:// URLs,
surrounding quotes (Windows 'Copy as path'), trailing separators, ~ home
shortcuts, and a pasted file (navigates to its folder) — and returns '' for
junk so the box flags a bad entry instead of navigating nowhere.  Shared by
both the import picker and the album-tagging dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants