Add gallery-py-qt: high-performance PySide6 media viewer - #1
Open
ChronicallyAcute wants to merge 83 commits into
Open
Add gallery-py-qt: high-performance PySide6 media viewer#1ChronicallyAcute wants to merge 83 commits into
ChronicallyAcute wants to merge 83 commits into
Conversation
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Key Changes
Core Architecture:
main_window.py— Main application window with toolbar, gallery view, and streaming scan infrastructuremodel.py— Virtualized list model with O(1) path lookup and progressive batch insertiongallery_view.py— Icon grid view with floating action overlaylightbox.py— Full-screen image/video viewer with zoom, pan, rotatemultiview.py— Adaptive 3-up (portrait) / 2×2 (landscape) grid with pinnable slotsMedia Engine:
engine/scan.py— Generator-based folder scanning with incremental batch emissionengine/media.py— Thread-safe image/video decoding (PIL + OpenCV)engine/cache.py— Disk thumbnail cache with single-stat optimizationloader.py— Priority-scheduled threaded thumbnail loader with auto-CPU-count sizingengine/favorites.py— Favorites persistence with Downloads mirrorUI & Styling:
delegate.py— Card-style item delegate with video badge and favorite indicatorseekbar.py— Custom seek bar for video scrubbingexif_panel.py— Lightweight file info dialogtheme.py— Application-wide Qt stylesheetconfig.py— Centralized configuration (paths, palette, icons, file types)Application:
app.py— Bootstrap with argument parsing and crash handler__main__.py/__init__.py— Module entry pointsNotable Implementation Details
Performance Optimizations:
_StreamScanJob, allowing the gallery to populate as files are discovered rather than blocking until full enumeration_scan_gen) ensures stale batches from superseded scans are silently dropped_all_set(O(1) membership) and_path_to_row(O(1) reverse lookup) to avoid O(n²) behavior during rapid thumbnail arrivalos.stat()call in cache lookup replaces separateexists()+getmtime()callsMemory Efficiency:
pil_to_qimage()uses Format_RGB888 for opaque images (25% memory savings vs. RGBA)UI/UX:
https://claude.ai/code/session_018SUNMyV28Pnxqcm866Rf2M