Feedback-fix rounds for #111, #86, #64, #89, #109, #57, #58, #21 — bump to 0.2.0 #129

Merged
ValleyGeek merged 16 commits from 0.1.47/issue-111-86-64-89-feedback-fixes into main 2026-06-22 02:48:17 +00:00
Member

Summary

Final PR in the 11-PR stacked chain. Bundles every re-tested-feedback round on top of the 10 preceding feature PRs, and bumps VERSION 0.1.x → 0.2.0 per docs/VERSIONING.md's feature-segment policy, marking this feature tranche complete.

  • #111 (dupe-scan / phash) — three follow-up rounds: exact-only scans no longer compute the perceptual hash at all (pure waste when nothing needs it); the combined exact+visual hash pass was split so an exact-only failure doesn't block visual hashing; and imagehash.phash() was replaced with a scipy-free reimplementation (DCT via numpy only) after a packaged-build crash traced to an undeclared scipy transitive dependency.
  • #86 / #64 — re-tested feedback rounds, plus: a Benchmark + "Set Optimal" button for the thumbnail-thread setting (times a representative batch across thread counts and applies the fastest); batched DB writes in the live ordered-metadata scan (was one transaction per image); and parallelized the live-scan's per-image enrichment via chunked ThreadPoolExecutor dispatch, aligned with the existing Benchmark code path.
  • #89 — polish pass on the Export Tags dialog from re-tested feedback.
  • #109 (Advance on Tag) — made AoT the sole navigation decision after a viewer delete (previously could double-advance with the old unconditional behavior), and fixed AoT losing the grid selection when combined with Hide Deleted (a QSortFilterProxyModel.setDynamicSortFilter(True) row-removal racing the advance logic — fixed by capturing the target row before the mutation that can prune it).
  • #57 (Multi-view zoom/pan) — fixed the view not re-fitting on resize while at baseline zoom, the DELETE outline becoming invisible behind the new per-tile QGraphicsView (z-order), and switched panning from middle-button to left-click-drag (with a drag threshold) per follow-up feedback.
  • #58 (WASD grid nav) — two real regressions found via fresh testing: (1) the default "Previous/next duplicate group" shortcuts also default to W/S and, being registered application-wide, silently intercepted those keys before the grid's own WASD handling ever saw them — fixed by scoping those two shortcuts to only be active in the Duplicates workbench; (2) returning to the grid from the Image Viewer updated the visual selection highlight but not the grid's internal navigation cursor, so WASD continued from the stale pre-viewer row — fixed by using setCurrentIndex instead of select on return, matching the pattern already used elsewhere in the same file.
  • #21 (sharpness) — the sharpness score (previously computed only internally, as Keep Best's resolution tiebreaker) is now displayed in the Duplicates Multi view's metadata row for every image in a group, not just the ones tied on highest resolution, computed in the background per image so opening a group never blocks on it.

See docs/DECISIONS.md (DR-026 through DR-032) for the design rationale behind each judgment call in this set.

Test plan

  • ruff check / ruff format --check — clean
  • Full pytest suite — 517 passed, 1 skipped
  • ./scripts/smoke-ui-exit.sh (offscreen) — no Qt shutdown errors
  • ./scripts/check-version-bump.sh against this PR's base — confirms 0.1.47 → 0.2.0
  • Every fix in this set was empirically verified against the pre-fix code (regression test fails on old code, passes on new) before being committed
  • Re-tested and confirmed by the reporter on Forgejo for #111, #64, #109, #57; #58 and #21 implemented and commented for re-test, not yet re-confirmed at the time of this PR

Closes #111, #86, #64, #89, #109, #57. Refs #58, #21 (commented for re-test; see issue threads).

Stacked on #128 — last of the 11-PR sequential chain into main.

## Summary Final PR in the 11-PR stacked chain. Bundles every re-tested-feedback round on top of the 10 preceding feature PRs, and bumps `VERSION` 0.1.x → **0.2.0** per `docs/VERSIONING.md`'s feature-segment policy, marking this feature tranche complete. - **#111** (dupe-scan / phash) — three follow-up rounds: exact-only scans no longer compute the perceptual hash at all (pure waste when nothing needs it); the combined exact+visual hash pass was split so an exact-only failure doesn't block visual hashing; and `imagehash.phash()` was replaced with a scipy-free reimplementation (DCT via numpy only) after a packaged-build crash traced to an undeclared scipy transitive dependency. - **#86 / #64** — re-tested feedback rounds, plus: a Benchmark + "Set Optimal" button for the thumbnail-thread setting (times a representative batch across thread counts and applies the fastest); batched DB writes in the live ordered-metadata scan (was one transaction per image); and parallelized the live-scan's per-image enrichment via chunked `ThreadPoolExecutor` dispatch, aligned with the existing Benchmark code path. - **#89** — polish pass on the Export Tags dialog from re-tested feedback. - **#109** (Advance on Tag) — made AoT the *sole* navigation decision after a viewer delete (previously could double-advance with the old unconditional behavior), and fixed AoT losing the grid selection when combined with Hide Deleted (a `QSortFilterProxyModel.setDynamicSortFilter(True)` row-removal racing the advance logic — fixed by capturing the target row before the mutation that can prune it). - **#57** (Multi-view zoom/pan) — fixed the view not re-fitting on resize while at baseline zoom, the DELETE outline becoming invisible behind the new per-tile `QGraphicsView` (z-order), and switched panning from middle-button to left-click-drag (with a drag threshold) per follow-up feedback. - **#58** (WASD grid nav) — two real regressions found via fresh testing: (1) the default "Previous/next duplicate group" shortcuts also default to W/S and, being registered application-wide, silently intercepted those keys before the grid's own WASD handling ever saw them — fixed by scoping those two shortcuts to only be active in the Duplicates workbench; (2) returning to the grid from the Image Viewer updated the visual selection highlight but not the grid's internal navigation cursor, so WASD continued from the stale pre-viewer row — fixed by using `setCurrentIndex` instead of `select` on return, matching the pattern already used elsewhere in the same file. - **#21** (sharpness) — the sharpness score (previously computed only internally, as Keep Best's resolution tiebreaker) is now displayed in the Duplicates Multi view's metadata row for *every* image in a group, not just the ones tied on highest resolution, computed in the background per image so opening a group never blocks on it. See `docs/DECISIONS.md` (DR-026 through DR-032) for the design rationale behind each judgment call in this set. ## Test plan - [x] `ruff check` / `ruff format --check` — clean - [x] Full `pytest` suite — 517 passed, 1 skipped - [x] `./scripts/smoke-ui-exit.sh` (offscreen) — no Qt shutdown errors - [x] `./scripts/check-version-bump.sh` against this PR's base — confirms 0.1.47 → 0.2.0 - [x] Every fix in this set was empirically verified against the pre-fix code (regression test fails on old code, passes on new) before being committed - [x] Re-tested and confirmed by the reporter on Forgejo for #111, #64, #109, #57; #58 and #21 implemented and commented for re-test, not yet re-confirmed at the time of this PR Closes #111, #86, #64, #89, #109, #57. Refs #58, #21 (commented for re-test; see issue threads). Stacked on #128 — last of the 11-PR sequential chain into `main`.
Issue #111 (Critical, dupe-scan crash): the prior try/except wrapping did not
catch the reported Windows crash (no dialog, no log entry per the reporter, and
this run's CHANGELOG/main code diff between v0.1.35 and v0.1.36 shows nothing
touching the dupe-scan path, ruling out an obvious code regression). Since the
crash could not be reproduced from this Linux dev environment, this commit
focuses on what's verifiably actionable: a `default_log_file_path()` helper
plus an "Open Log Folder" button in the Help workbench (the reporter couldn't
find the log file at all), the resolved log path now shown directly in the
"Duplicate Scan Error" dialog, checkpoint logging through the scan-start and
worker-run path so a future crash report has actionable detail, and a
defensive try/except around the per-image `_compute_hashes()` call in
`DupeWorker._run_scan()` so any exception type outside what `_compute_hashes`
already catches degrades to a single hash failure instead of aborting the scan.

Issue #86 (re-tested): the original fix only covered tag-click paths.
`TagFilterDropdown._on_untagged_only()` cleared `self._active` when checking
Untagged Only but never moved those tags back into `self._available`, so they
vanished from the dropdown's Available section until it was closed and
reopened (a fresh `_rebuild_tag_lists()`/`set_state()` call masked it). Fixed
to mirror `_move_tag`/`_set_exclusive_active`'s existing move-back behavior.

Issue #64 (re-tested): verified via a local benchmark that
`generate_thumbnails_for_db()`'s ThreadPoolExecutor wiring does produce real
throughput scaling (~2x from 1->4 workers on an 8-core machine) using the exact
code path `_thumbnail_worker_threads()` feeds. No code defect found in the
threading/pref-plumbing; the reporter's "no change at all" result looks
environment-specific (disk I/O or a low effective core count) rather than a
bug in this codebase — noted in the issue rather than guessing further.

Issue #89 (polish): Export Tags' "Copy to Clipboard" button now shows "Copied"
for 5 seconds (QTimer.singleShot) before reverting, per the follow-up request.

Threading notes: all changes are either pure UI (main thread) or worker-thread
exception handling around existing CPU/IO calls; no new cross-thread Qt access
introduced. DupeWorker's hash-loop guard only adds a try/except around an
existing pure-function call already executed on the worker thread.
docs: PR-prep for issues 111, 86, 64, 89 - bump to 0.1.48
All checks were successful
CI / lint (push) Successful in 8s
CI / test (push) Successful in 1m41s
CI / appimage (push) Successful in 8m10s
CI / build-appimage (push) Successful in 0s
CI / windows-exe (push) Successful in 10m10s
CI / build-windows-exe (push) Successful in 0s
53b35032e4
The reporter's logs (0.1.35 working / 0.1.36 broken / 0.1.48 still broken with
this round's checkpoint logging) show the crash happening immediately inside
DupeWorker's per-image hashing loop, with zero further log output - consistent
with a native crash bypassing Python's exception handling, not something any
try/except can catch. Both crash reports were running an *exact* scan, but
_compute_hashes() unconditionally computed the imagehash.phash() perceptual
hash too, even though exact scans never use it. imagehash depends on
numpy/scipy/PyWavelets - a much larger native-dependency surface (BLAS/LAPACK/
FFT, often with vendored DLLs) than the plain hashlib.sha256 exact-hash path,
and a well-known source of silent crashes in frozen Windows builds.

Split _compute_hashes() into _compute_exact_hash() (pure hashlib, no Pillow/
imagehash) and _compute_perceptual_hash() (Pillow + imagehash); DupeWorker now
calls only the one its scan_kind actually needs. _compute_hashes() remains as
a wrapper for the one other caller that wants both (image_scanner.py's ad-hoc
include_hashes=True path), preserving its original all-or-nothing semantics.
Also added per-image INFO-level checkpoint logging in the hash loop so a
future crash report (if this doesn't fully resolve "visual" scans too)
pinpoints the exact file. See DR-027 for the full reasoning and alternatives.

Threading notes: no change to the threading model - both new functions run on
the worker thread exactly where _compute_hashes() already ran; only the work
done there is now conditional.
docs: PR-prep for issue 111 hash-split fix - bump to 0.1.49
All checks were successful
CI / lint (push) Successful in 12s
CI / test (push) Successful in 2m28s
CI / windows-exe (push) Successful in 9m53s
CI / build-windows-exe (push) Successful in 0s
CI / appimage (push) Successful in 14m14s
CI / build-appimage (push) Successful in 0s
ec359451c6
_on_viewer_delete_requested() had a leftover hide_prunes guard that
unconditionally suppressed Advance on Tag whenever Hide Deleted was active and
the delete was new -- exactly the common case for triage workflows, per user
report ("when Hide Deleted is enabled, AoT on delete isn't triggering").

That guard predates AoT and isn't actually needed: issue #38's pending-resync
pin mechanism (defer_resync/_resolve_pending_resync) already resolves itself
correctly inside navigate_next()/advance_to_next_non_deleted() via
_go_to_session_index, which checks _pending_resync before moving. AoT calling
navigate_next() immediately after a delete is exactly "the user navigating
away" that mechanism was designed to handle. Removed the guard so
_should_advance_on_tag() is the only thing deciding whether to advance, in
either direction: Tags & Delete / Delete Only now advance correctly even when
the delete prunes the image under Hide Deleted, and Disabled still holds
steady exactly as #38 intended.

Added regression tests for both directions (advances when AoT is on, holds
steady when AoT is off, both with Hide Deleted active).

Threading notes: pure main-thread UI logic, no change to the threading model.
Adds a "Benchmark and Set Optimal" button next to the thumbnail generation
thread-count setting (Settings -> Advanced). On click (requires an open
directory): samples up to 200 images from the working DB, clears their cached
thumbnail before each level, times generate_thumbnails_for_db() at every
thread level from 1 up to min(os.cpu_count(), 16), then persists the fastest
level as the new thread-count pref and updates the spinbox to match. Shows a
copyable report dialog (mirrors ExportTagsDialog's Copy to Clipboard/"Copied"
pattern) listing every level's images/sec, with the chosen optimal marked.

New files:
- core/thumbnail_benchmark.py: pure benchmark loop + report formatting, no Qt.
- core/thumbnail_benchmark_worker.py: QRunnable wrapper, plain-data signals.
- ui/thumbnail_benchmark_dialog.py: result dialog.

SettingsWorkbench gains a benchmark_threads_requested signal (it has no
knowledge of the open directory) that MainWindow connects to, since only
MainWindow holds _current_root/_working_db. A QProgressDialog with Cancel is
shown while the worker runs across all thread levels.

Threading notes: ThumbnailBenchmarkWorker follows the same contract as
ThumbnailWorker/DupeWorker - CPU/IO and plain-data signals only on the worker
thread; WorkingDB writes happen via the existing generate_thumbnails_for_db()
path, which already opens its own short transactions per call.

Verified end-to-end with a real (non-monkeypatched) QThreadPool run in
addition to unit tests, confirming the full async signal chain works.
docs: PR-prep for issues 64 and 109 follow-ups - bump to 0.1.50
All checks were successful
CI / lint (push) Successful in 7s
CI / test (push) Successful in 2m10s
CI / appimage (push) Successful in 8m27s
CI / build-appimage (push) Successful in 0s
CI / windows-exe (push) Successful in 10m45s
CI / build-windows-exe (push) Successful in 0s
2964448dc5
DR-027 fixed exact scans by skipping the perceptual hash entirely, but left
visual scans as an open question since they still need it. The reporter
retested: exact scans now work, but a visual scan crashed identically -- and
this round's checkpoint logging pinpointed it exactly: the log's last line is
"Hashing 1/19: ... (need_visual=True)" followed by total silence, i.e. the
crash happens inside the very first _compute_perceptual_hash() call.

Root cause confirmed by reading imagehash's source: phash() does
`import scipy.fftpack` *lazily, inside its own function body* -- not at
module load time. That's exactly why everything else (including
imagehash.ImageHash/hex_to_hash, used elsewhere and numpy-only) works fine,
while the first call to phash() specifically is what crashes: that's the
first and only point scipy.fftpack actually gets imported and exercised.

Added core/perceptual_hash.py::phash() -- a hand-rolled 2D DCT-II using only
numpy (already a working dependency in this codebase), wrapped in
imagehash.ImageHash for drop-in compatibility with dupe_detector.py's
existing Hamming-distance comparisons. Verified bit-for-bit identical output
against the real imagehash.phash() across 10 random images, 5 size/mode
combinations, and an alpha-channel case, and confirmed it still works with
scipy forced unimportable via sys.modules. Since the output is identical, no
hash-format migration is needed -- existing cached perceptual_hash values
remain valid against newly computed ones.

image_scanner.py::_compute_perceptual_hash() now calls this instead of
imagehash.phash(); imagehash itself stays a dependency since dupe_detector.py
still uses ImageHash/hex_to_hash, which never touch scipy. See DR-028.

Threading notes: pure CPU computation, same call site/thread as before
(DupeWorker's worker thread); no change to the threading model.
docs: PR-prep for issue 111 scipy-free phash fix - bump to 0.1.51
All checks were successful
CI / lint (push) Successful in 7s
CI / test (push) Successful in 1m33s
CI / appimage (push) Successful in 8m0s
CI / build-appimage (push) Successful in 0s
CI / windows-exe (push) Successful in 10m58s
CI / build-windows-exe (push) Successful in 0s
cecb5ae6dc
fix(scan): batch DB writes in the live ordered metadata scan (#64)
All checks were successful
CI / lint (push) Successful in 8s
CI / test (push) Successful in 1m35s
CI / appimage (push) Successful in 8m0s
CI / build-appimage (push) Successful in 0s
CI / windows-exe (push) Successful in 10m10s
CI / build-windows-exe (push) Successful in 0s
bd3c329d6b
The benchmark added in 0.1.50 and the real scan are different code
paths: the benchmark (and the separate missing-thumbnails catch-up
pass) use the configurable thread pool, but the live scan
(scan_metadata_ordered_and_upsert -> enrich_image_record) is a
single-image-at-a-time loop that never used it and was never affected
by the thread-count setting. Profiling traced the reporter's 14 img/s
real-scan vs 33-89 img/s benchmark discrepancy to the dominant actual
cost: a working_db.upsert_images([rec]) call flushed after every
single image (~20.5ms each) instead of batched (~100x cheaper per row
when batched). The loop already accepted a batch_size parameter
(default 64) but never used it to control flush cadence.

scan_metadata_ordered_and_upsert now buffers enriched records and
flushes once len(batch) >= batch_size, plus a final flush after the
loop ends so cancellation or a trailing partial batch isn't lost.
_collect_pending_enrichment_queue gained an enriched_unflushed
exclusion set so an unflushed row isn't re-selected and reprocessed
before its write lands -- this preserves issue #17/DR-014's live
sort-order reactivity exactly as before; only write cadence changed.

Threading notes: still single-threaded by design (DR-014's contract
re-reads sort/discovery state every iteration); this fix does not
parallelize the live scan itself, so the thread-count setting still
has no effect there -- documented as a known, separate gap in
DR-029 rather than implied as fixed.

Verified end to end: 300 real 1600x1200 JPEGs, ~25.9 img/s before
(batch_size=1) -> ~46.5 img/s after (batch_size=64) in this sandbox.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(scan): parallelize live-scan thumbnail generation; align benchmark (#64)
All checks were successful
CI / lint (push) Successful in 7s
CI / test (push) Successful in 1m28s
CI / appimage (push) Successful in 7m56s
CI / build-appimage (push) Successful in 0s
CI / windows-exe (push) Successful in 17m57s
CI / build-windows-exe (push) Successful in 0s
821940401f
The user clarified the original #64 intent: the live directory scan
that populates the grid with thumbnails as they're generated is the
specific target of the thread-count setting, not just the separate
catch-up pass and benchmark (DR-029 had fixed write batching but left
the live scan single-threaded, with the setting still having no
effect on it).

scan_metadata_ordered_and_upsert() now takes a thread_count parameter:
each outer-loop iteration dispatches up to that many rows (in current
sort order) to a ThreadPoolExecutor instead of exactly one, claiming
the whole chunk as enriched_unflushed before submitting so an in-flight
row can't be re-selected. on_batch now fires once per completed chunk
with that chunk's ready records -- decoupled from the DB write batch
(batch_size), which still flushes on its own cadence per DR-029. With
thread_count=1 (the default) this is behaviorally identical to the
pre-existing single-row loop. ScanWorker gained a thread_count field;
main_window.py's live-scan worker construction now passes
thread_count=self._thumbnail_worker_threads() (the same pref-backed
helper the catch-up pass and benchmark already used).

The Benchmark and Set Optimal feature was also realigned: it
previously timed generate_thumbnails_for_db() (the catch-up pass's
code path), which the user pointed out doesn't represent the
first-time scan operation the setting is meant to tune.
benchmark_thumbnail_threads() now dispatches enrich_image_record()
through the identical chunked-ThreadPoolExecutor pattern the live scan
uses, so its "optimal thread count" suggestion is measured against the
literal operation being tuned.

Threading notes: enrich_image_record() is a pure CPU/IO function (no
DB/Qt access) -- the only thing now run on the extra worker threads.
working_db writes and on_progress/on_batch signal emission remain
exclusively on the scan/benchmark's own calling thread, unchanged from
before. See DR-030.

Verified end to end: 300 real 1600x1200 JPEGs, this sandbox (8 CPUs):
thread_count 1/2/4/8/16 -> 60.1/87.3/135.0/118.6/93.2 img/s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(grid,dupe): AoT selection loss with Hide Deleted; multi-view fit/border/pan (#109, #57)
All checks were successful
CI / lint (push) Successful in 8s
CI / test (push) Successful in 1m30s
CI / appimage (push) Successful in 7m55s
CI / build-appimage (push) Successful in 0s
CI / windows-exe (push) Successful in 10m13s
CI / build-windows-exe (push) Successful in 0s
2f2d150b69
#109 follow-up: applying DELETE on the grid while Hide Deleted is
active wiped the selection entirely instead of advancing. Hide
Deleted's dynamic proxy filter removes the just-deleted row from the
selection model synchronously as part of applying the tag -- before
_advance_grid_selection() ever got a chance to read "the next row"
from it. Capture the target image by relative_path before the
mutation (added _capture_grid_advance_target()) and re-locate it
afterward via row_for_relative_path/row_for_source_row, since paths
survive the removal but row indices don't. Added regression tests for
both the buggy case and the AoT-disabled case (selection should stay
empty, not be force-reselected).

#57 follow-up, three separate reported regressions/requests in the
Duplicates Multi view:
- Images weren't fit to their tile on first load. set_image() can
  only fitInView() against whatever viewport size exists right now,
  but cells are populated synchronously during grid rebuild, before
  the widget tree has gone through layout -- so the initial fit used
  a stale/default size. _DupeZoomPanView now re-fits on every resize
  while still at the 1.0x baseline, correcting itself once the real
  size is known.
- The red DELETE outline had become invisible. It was drawn by
  _DeleteOutlineHost's own paintEvent(), which runs before (i.e.
  beneath) its children in Qt's stacking order -- once the stacked
  child became a real QGraphicsView (for zoom/pan) instead of a plain
  label, the view's background painted over the border every frame.
  Replaced with a transparent _DeleteOutlineOverlay sibling that's
  explicitly raised above both stacked children whenever either one
  becomes current. Also widened the pen by 1px, brightened the color,
  and made it dashed per request.
- Panning now uses left-button click-and-hold-drag instead of the
  middle mouse button. A press only becomes a drag once movement
  exceeds PAN_DRAG_THRESHOLD (matching issue #20's existing rubber-band
  threshold convention), so a quick click still reaches single_clicked
  (mark others DELETE) via the existing double-click-aware timer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(grid): scope dupe_group_prev/next shortcuts to Duplicates workbench (#58)
Some checks failed
CI / lint (push) Successful in 16s
CI / test (push) Successful in 2m28s
CI / build-appimage (push) Has been cancelled
CI / build-windows-exe (push) Has been cancelled
CI / windows-exe (push) Has been cancelled
CI / appimage (push) Has been cancelled
b4bf226cf4
W/S didn't move the grid selection up/down, while A/D worked fine.
Root cause: "Previous/next duplicate group" defaults to W/S too
(shortcut_registry.py) and was registered as an application-wide
QShortcut -- Qt's shortcut dispatch intercepts a matching key press
before it's ever delivered as a normal key event to the focused
widget, so it silently ate W/S before GridWasdNavFilter's eventFilter
(installed on the grid's QListView) ever saw them. A/D had no
competing shortcut, so they worked. The original #58 tests used a
bare GridWorkbench that never registers these shortcuts, so they
couldn't catch the collision.

Track the two dupe-group-nav shortcuts in their own list and toggle
QShortcut.setEnabled() based on _current_mode == "dupes", synced at
registration, after a shortcut-override reload, and on every
_activate_workbench() call. _current_mode (not _in_viewer_sub) is the
right gate: it doesn't change when entering the Duplicates workbench's
Single viewer sub-mode, so W/S correctly keep navigating duplicate
groups while reviewing them there.

See DR-031.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(dupe): show sharpness score for every image in Multi view (#21)
All checks were successful
CI / lint (push) Successful in 13s
CI / test (push) Successful in 2m11s
CI / windows-exe (push) Successful in 10m20s
CI / build-windows-exe (push) Successful in 0s
CI / appimage (push) Successful in 13m19s
CI / build-appimage (push) Successful in 0s
9512cca536
DR-025 added sharpness_score() (Pillow edge-variance, since AGENTS.md
rules out OpenCV) purely as Keep Best's internal resolution
tie-breaker -- only computed for images tied on the group's highest
pixel count, never shown to the user. Follow-up request: surface it
as a metric in the Multi view's metadata row for every image, not
just the resolution leaders Keep Best happens to need it for.

Added core/dupe_sharpness_worker.py (DupeSharpnessSignals,
DupeSharpnessWorker, enqueue_dupe_sharpness_score), a near-exact
structural copy of viewer_image_loader.py's existing one-shot-per-image
worker pattern -- calls the existing sharpness_score() per image on a
background thread and emits a plain (relative_path, float) signal, so
opening a group never blocks the UI thread on Pillow decode/filter
work. DupeMultiGrid caches scores by relative_path for its lifetime
(same no-eviction convention as its existing image cache) and ignores
late results for images no longer in the displayed group, mirroring
the existing _on_image_loaded pattern.

_metadata_styles() now returns a 3-tuple (resolution/size/sharpness
styles) via the existing compare_tier/style_for_tier machinery, gated
behind the same compare_highlights flag the other two metrics use;
compare_tier's type hint widened to float since edge-variance scores
aren't integers. A new _DupeImageCell.set_sharpness() updates just the
sharpness label independent of set_metadata(), since a newly arrived
score can change the whole group's tier coloring and triggers a
re-render across all cells, not just the one it belongs to.

pick_keep_best_path()'s own contender-only sharpness computation is
unchanged -- Keep Best's decision algorithm (DR-008: resolution
primary) isn't part of this request, and its hot path (run across
potentially many groups in a bulk sweep) shouldn't pay for file I/O it
doesn't need just because the Multi view now wants more data.

See DR-032. Not manually verified in a real GUI session (no display
in this environment) -- tests exercise the real background-worker
round trip against real Pillow-generated JPEGs via qtbot.waitUntil,
not mocks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(grid): restore currentIndex (not just highlight) when leaving viewer (#58)
All checks were successful
CI / lint (push) Successful in 9s
CI / test (push) Successful in 2m18s
CI / windows-exe (push) Successful in 10m25s
CI / build-windows-exe (push) Successful in 0s
CI / appimage (push) Successful in 13m29s
CI / build-appimage (push) Successful in 0s
ae70ad84f3
After opening an image in the viewer, navigating to a different
image, and returning to the grid, the new image was correctly
highlighted but WASD/arrow-key navigation continued from whatever row
was active before opening the viewer instead.

_select_proxy_row() (used by every viewer-exit path back to grid/trash
and to the dupes Multi view) called selectionModel().select(idx,
ClearAndSelect | Rows) -- which updates the visual highlight/
selectedRows() but not currentIndex(). Qt's own arrow-key cursor
navigation (which GridWasdNavFilter's WASD remap rides on) moves from
currentIndex(), not from whatever is merely highlighted, so the two
fell out of sync. Two other call sites in this same file already get
this right via setCurrentIndex(idx, ClearAndSelect) in one call
(grid_selection_filter.py's click handling, and the post-tag-apply
grid-advance method) -- _select_proxy_row was the outlier.

Switched to selectionModel().setCurrentIndex(idx, ClearAndSelect |
Rows), which updates both together.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs: PR-prep for the 0.1.x feature-tranche close-out - bump to 0.2.0
All checks were successful
CI / lint (push) Successful in 12s
CI / test (push) Successful in 2m24s
CI / windows-exe (push) Successful in 10m13s
CI / build-windows-exe (push) Successful in 0s
CI / appimage (push) Successful in 14m3s
CI / build-appimage (push) Successful in 0s
cb50a19e7e
Final PR-prep commit for this branch chain: marks the completion of
the grid/dupe/tagging feature-enhancement tranche (issues #20, #21,
#40, #50, #55, #57, #58, #64, #67, #72, #76, #80, #86, #88, #89, #91,
#109, #111) per docs/VERSIONING.md's feature-segment bump policy.

Verified before this commit: ruff check/format clean, full pytest
suite (517 passed, 1 skipped), offscreen UI-exit smoke clean,
check-version-bump.sh confirms 0.1.47 -> 0.2.0 against this branch's
parent in the PR chain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ValleyGeek changed target branch from 0.1.46/issue-109-advance-on-tag to main 2026-06-22 02:32:41 +00:00
Claude force-pushed 0.1.47/issue-111-86-64-89-feedback-fixes from cb50a19e7e
All checks were successful
CI / lint (push) Successful in 12s
CI / test (push) Successful in 2m24s
CI / windows-exe (push) Successful in 10m13s
CI / build-windows-exe (push) Successful in 0s
CI / appimage (push) Successful in 14m3s
CI / build-appimage (push) Successful in 0s
to c60b3fef92 2026-06-22 02:35:05 +00:00
Compare
ValleyGeek deleted branch 0.1.47/issue-111-86-64-89-feedback-fixes 2026-06-22 02:48:17 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ai-collab/bulk-image-organizer!129
No description provided.