diff --git a/.agents/skills/improved_sdd_tdd_cycle.md b/.agents/skills/improved_sdd_tdd_cycle.md index d7a0ba3..7b4d9c3 100644 --- a/.agents/skills/improved_sdd_tdd_cycle.md +++ b/.agents/skills/improved_sdd_tdd_cycle.md @@ -31,7 +31,7 @@ understanding more than once: | **Peer-tier delegation** | An agent as strong as the Lead redoes what the Lead already knows. | Delegate *down* or not at all. §4 | | **Reading to answer a grep** | Whole files loaded to establish one fact. | Ask the shell first. §10 | | **Regex work sent to a model** | A model reasons through what `sed` does for free. | Script it. §7, rule S | -| **Full-suite runs while iterating** | `go test -timeout 20m -race ./...` × every loop. | Targeted packages until the final gate. §10 | +| **Full-suite runs while iterating** | `go test -timeout 30m -race ./...` × every loop. | Targeted packages until the final gate. §10 | | **Verifying by re-reading** | Re-derive from the diff what one command would have told you. | Evidence is command output. §10 | The one delegation that *saves* Lead context rather than spending it is @@ -379,7 +379,7 @@ say so plainly. | "It works on Windows" | A Windows run, or an explicit statement that it is unverified | **While iterating:** targeted packages only — `go test ./internal/update/`, -`go test ./internal/ui/ -run TestUpdateFailure`. The full `-race` suite is 20 +`go test ./internal/ui/ -run TestUpdateFailure`. The full `-race` suite is 30 minutes; running it every loop is pure cost with no new information. **Final gate, once, before handoff** (matches CI — `AGENTS.md §Build and Verification`): @@ -388,7 +388,7 @@ minutes; running it every loop is pure cost with no new information. make fmt-check go vet ./... go build ./... -go test -timeout 20m -race ./... +go test -timeout 30m -race ./... ``` Cross-platform work adds `GOOS=windows GOARCH=amd64 go vet ./internal/...`. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 691932b..f10d879 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -10,7 +10,7 @@ participating, you're expected to uphold it. ## Getting set up -- Go 1.26.6 or newer (see the `go` directive in [go.mod](../go.mod)) +- Go 1.27.1 or newer (see the `go` directive in [go.mod](../go.mod)) - A C toolchain for cgo (Fyne's OpenGL bindings require it) — Xcode Command Line Tools on macOS, `gcc` + `libgl1-mesa-dev`/`xorg-dev` on Linux - See the [README](../README.md#requirements) for the full list, including @@ -43,7 +43,7 @@ make run ```sh make fmt-check # goimports -local; should print nothing / exit 0 go vet ./... - go test -timeout 20m -race ./... + go test -timeout 30m -race ./... ``` Or via the [Makefile](../Makefile): `make fmt`, `make vet`, `make test`. @@ -74,7 +74,7 @@ make run - Open the PR against `main` and fill in the pull request template. - Keep the change focused — unrelated cleanup makes review harder and is easier to land as its own PR. -- CI (`goimports -local`, `go vet`, `go build`, `go test -timeout 20m -race`) must pass. +- CI (`goimports -local`, `go vet`, `go build`, `go test -timeout 30m -race`) must pass. - A maintainer will review and may ask for changes before merging. ## Reporting bugs and requesting features diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cc3640d..fb3a797 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,7 @@ ## Checklist -- [ ] `make fmt-check` is clean, `go vet ./...` and `go test -timeout 20m -race ./...` pass +- [ ] `make fmt-check` is clean, `go vet ./...` and `go test -timeout 30m -race ./...` pass - [ ] User-visible strings go through `lang.L`, with the key added to every bundle in `translations/` - [ ] `internal/ui/help/manual.md` and `manual_de.md` updated, if this diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95b0ce0..e218ab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,12 +69,12 @@ jobs: # BCP-47 tag, so Fyne logs a three-line parse fault before any test # runs. Naming a real one keeps that noise out of the test output. # - # -timeout 20m: go test defaults to 10m per package. internal/ui on + # -timeout 30m: go test defaults to 10m per package. internal/ui on # ubuntu-latest already took ~9m39s before folder-sibling tests; the # next commit then panicked at 10m0s while a later test was starting. env: LANG: en_US.UTF-8 - run: go test -timeout 20m -race ./... + run: go test -timeout 30m -race ./... windows-test: runs-on: windows-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9488894..b92f784 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,29 +91,138 @@ jobs: tar -czf "picfetch-linux-$arch.tar.gz" "picfetch-linux-$arch" done - - name: Upload Windows/Linux artifacts + - name: Upload unsigned Windows artifacts for signing uses: actions/upload-artifact@v7 with: - name: picfetch-cross + name: picfetch-windows-unsigned path: | bin/picfetch-windows-amd64.zip bin/picfetch-windows-arm64.zip + if-no-files-found: error + + - name: Upload Linux artifacts + uses: actions/upload-artifact@v7 + with: + name: picfetch-linux + path: | bin/picfetch-linux-amd64.tar.gz bin/picfetch-linux-arm64.tar.gz if-no-files-found: error + sign-windows: + # Keep the code-signing credentials behind the protected GitHub Environment + # configured in docs/release-signing.md. This job signs only tag-release + # artifacts after the test gate and must finish before publication. + needs: build-cross + runs-on: windows-latest + environment: release-signing + permissions: + contents: read + steps: + - name: Download unsigned Windows artifacts + uses: actions/download-artifact@v8 + with: + name: picfetch-windows-unsigned + path: dist/windows-unsigned + + - name: Download and verify Certum SimplySign Desktop + shell: pwsh + run: | + $installerUrl = 'https://files.certum.eu/software/SimplySignDesktop/Windows/9.4.4.92/SimplySignDesktop-9.4.4.92-64-bit-en.msi' + Invoke-WebRequest -Uri $installerUrl -OutFile SimplySignDesktop.msi -ErrorAction Stop + + $signature = Get-AuthenticodeSignature -FilePath SimplySignDesktop.msi + if ($signature.Status -ne 'Valid') { + throw "SimplySign installer has an invalid Authenticode signature: $($signature.Status)." + } + Write-Host "Verified SimplySign installer publisher: $($signature.SignerCertificate.Subject)" + + - name: Authenticate Certum SimplySign + # This commit is the reviewed v1 release of the third-party action. + # Do not replace it with a floating tag: it receives the TOTP secret. + uses: dismine/windows-app-signing-setup-action@89ae3b032d4bc7a5b98d1a42a34e61ecb6faad64 # v1 + with: + certum-username: ${{ secrets.CERTUM_USERNAME }} + certum-otp-uri: ${{ secrets.CERTUM_OTP_URI }} + certum-key-id: ${{ secrets.CERTUM_CERT_THUMBPRINT }} + # Install the verified local MSI; fail if it is missing instead of downloading a fallback. + simplysign-url: https://simplysign-installer.invalid/SimplySignDesktop.msi + + - name: Sign, timestamp, and verify Windows executables + shell: pwsh + env: + CERTUM_CERT_THUMBPRINT: ${{ secrets.CERTUM_CERT_THUMBPRINT }} + run: | + $signtool = Get-ChildItem -Path 'C:\Program Files (x86)\Windows Kits\10\bin' -Filter signtool.exe -Recurse | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Select-Object -First 1 + if ($null -eq $signtool) { + throw 'SignTool.exe was not found in the Windows SDK.' + } + + $archives = @(Get-ChildItem -Path dist/windows-unsigned -Filter 'picfetch-windows-*.zip' -File) + if ($archives.Count -ne 2) { + throw "Expected exactly two Windows archives, found $($archives.Count)." + } + + New-Item -ItemType Directory -Force -Path dist/windows-signed | Out-Null + foreach ($archive in $archives) { + $unpacked = Join-Path dist/unpacked $archive.BaseName + Expand-Archive -Path $archive.FullName -DestinationPath $unpacked -Force + $executable = Join-Path $unpacked 'picfetch.exe' + if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "Expected executable is missing from $($archive.Name)." + } + + & $signtool.FullName sign /fd sha256 /tr http://time.certum.pl /td sha256 /sha1 $env:CERTUM_CERT_THUMBPRINT /v $executable + if ($LASTEXITCODE -ne 0) { + throw "Signing failed for $($archive.Name)." + } + & $signtool.FullName verify /pa /all /v /tw $executable + if ($LASTEXITCODE -ne 0) { + throw "Signature verification failed for $($archive.Name)." + } + + Compress-Archive -Path $executable -DestinationPath (Join-Path dist/windows-signed $archive.Name) -Force + } + + - name: Upload verified signed Windows artifacts + uses: actions/upload-artifact@v7 + with: + name: picfetch-windows-signed + path: dist/windows-signed/picfetch-windows-*.zip + if-no-files-found: error + release: - needs: [build-macos, build-cross] + needs: [build-macos, build-cross, sign-windows] runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v7 - - name: Download artifacts + - name: Download macOS artifacts + uses: actions/download-artifact@v8 + with: + name: picfetch-macos-arm64 + path: dist + + - name: Download Intel macOS artifacts + uses: actions/download-artifact@v8 + with: + name: picfetch-macos-x86_64 + path: dist + + - name: Download Linux artifacts + uses: actions/download-artifact@v8 + with: + name: picfetch-linux + path: dist + + - name: Download signed Windows artifacts uses: actions/download-artifact@v8 with: + name: picfetch-windows-signed path: dist - merge-multiple: true - name: Create GitHub release uses: softprops/action-gh-release@v3 diff --git a/.scratch/swipe-unlinked-pointer-routing/issues/01-route-swipe-input-by-reveal.md b/.scratch/swipe-unlinked-pointer-routing/issues/01-route-swipe-input-by-reveal.md new file mode 100644 index 0000000..5e3b08c --- /dev/null +++ b/.scratch/swipe-unlinked-pointer-routing/issues/01-route-swipe-input-by-reveal.md @@ -0,0 +1,49 @@ +# 01 - Route Swipe input by revealed pane + +Status: resolved + +## Contract + +Through `compare.Feature.Overlay()` in a real Fyne test window, prove that +Swipe + Unlinked comparison assigns pointer input to the photo occupying the +revealed region under the pointer. Hit regions must follow the current divider +without changing either photo's full-viewport render geometry. + +Add a private `layoutPaneInput(index, input)` helper driven by +`paneVisibleArea`. Apply it during pane layout and every reveal/divider update, +and remove the full-viewport input reset from transform application. The +divider remains the exclusive drag target in its hit area, and a fully hidden +pane has no interactive area. + +Files: `internal/ui/compare/compare_test.go`, +`internal/ui/compare/transform.go`, and `internal/ui/compare/swipe.go`. + +## Red / green + +1. Add `TestCompareSwipeUnlinkedCanvasRoutesPointerByReveal` using actual + canvas hover, drag, and wheel dispatch at the default divider and after + moving it to 75%. +2. Observe the current implementation report `Unlinked: Right` while the + pointer is over the visible left photo. +3. Implement reveal-aligned pane input bounds. +4. Verify Left/Right status, pane-local gestures, and subsequent transform keys + affect only the revealed target. Retain the last target after leaving a + photo region. + +## Acceptance + +`go test ./internal/ui/compare -run '^TestCompareSwipeUnlinkedCanvasRoutesPointerByReveal$' -count=1` + +## Constraints + +- Do not change renderer viewports, reveal clips, image transforms, tile + planning, shaders, caches, or divider behavior. +- Do not add an assembled-viewer duplicate of this regression. +- Do not add exported APIs or user-visible strings. + +## Comments + +- Red: the permanent canvas test reported `Unlinked: Right` while the pointer + was at x=200 in the visible left reveal. +- Green: reveal-aligned pane input bounds passed the focused acceptance command, + including divider movement, both extremes, gestures, and transform keys. diff --git a/.scratch/swipe-unlinked-pointer-routing/issues/02-preserve-right-wheel-anchor.md b/.scratch/swipe-unlinked-pointer-routing/issues/02-preserve-right-wheel-anchor.md new file mode 100644 index 0000000..d3933a0 --- /dev/null +++ b/.scratch/swipe-unlinked-pointer-routing/issues/02-preserve-right-wheel-anchor.md @@ -0,0 +1,42 @@ +# 02 - Preserve the right Swipe wheel anchor + +Status: resolved +Blocked by: 01 + +## Contract + +After Ticket 01 makes the right pane input start at the divider, preserve the +full-viewport image point beneath an unmodified wheel gesture. Copy each +non-nil `fyne.ScrollEvent`, add the input widget's reveal offset to the copied +event position, and forward that viewport-relative event. Never mutate the +event supplied by the caller. + +Files: `internal/ui/compare/compare_test.go` and +`internal/ui/compare/input.go`. + +## Red / green + +1. Add `TestCompareSwipeUnlinkedRightWheelPreservesViewportAnchor` through the + overlay's pane input seam after Ticket 01 is green. +2. Observe the right photo zoom around the reveal-local coordinate instead of + the full-viewport cursor position. +3. Add the scroll-coordinate translation and observe the point beneath the + cursor remain fixed. +4. Verify the original event is unchanged and nil events remain inert. + +## Acceptance + +`go test ./internal/ui/compare -run '^TestCompareSwipeUnlinkedRightWheelPreservesViewportAnchor$' -count=1` + +## Constraints + +- Preserve left-pane, side-by-side, linked-wheel, and Shift+wheel behavior. +- Do not expose pane internals or add a second scroll path. +- Do not mutate caller-owned input events. + +## Comments + +- Red: with reveal-local x=100 forwarded unchanged, the normalized point under + full-viewport x=500 moved from `0.625` to `0.5774` during wheel zoom. +- Green: translating a copied event by the input origin preserved the anchor; + the original event remained unchanged and nil stayed inert. diff --git a/.scratch/swipe-unlinked-pointer-routing/issues/03-document-review-and-verify.md b/.scratch/swipe-unlinked-pointer-routing/issues/03-document-review-and-verify.md new file mode 100644 index 0000000..a1d1618 --- /dev/null +++ b/.scratch/swipe-unlinked-pointer-routing/issues/03-document-review-and-verify.md @@ -0,0 +1,62 @@ +# 03 - Document, review, and verify the Swipe routing fix + +Status: resolved +Blocked by: 01, 02 + +## Contract + +Record the approved terminology and implementation invariant, review the two +vertical TDD slices, negatively verify their guards, and run the final gate +once. + +Add **Linked comparison** and **Unlinked comparison** to `CONTEXT.md` and mark +locked/unlocked comparison as avoided terminology. Update `ARCHITECTURE.md` to +state that Swipe input bounds mirror the reveal while wheel coordinates are +translated back into the full viewport. Add the bugfix to `todos.md` and +normalize its existing locking/unlocking wording to linking/unlinking. + +Complete the local spec and ticket comments with observed evidence, record the +Standard-route plan and cost ledger, and move the completed plan to +`finished_refactorings/` after the final gate. + +## Verification + +1. Run `go test ./internal/ui/compare -count=1`. +2. Run + `go test ./internal/ui -run 'Compare(LinkToggle|SwipePointer)' -count=1`. +3. Temporarily restore full-width pane inputs and confirm Ticket 01 fails for + the original Right-over-left symptom; restore the fix. +4. Temporarily remove scroll-coordinate translation and confirm Ticket 02 + fails for lost cursor anchoring; restore the fix. +5. Rerun both focused ticket commands on the restored tree. +6. Run `make verify` once and record its actual result. + +## Acceptance + +- Every spec acceptance command passes on the final tree. +- `rg -n 'Linked comparison|Unlinked comparison' CONTEXT.md` finds both + canonical terms. +- `rg -n 'reveal|revealed' ARCHITECTURE.md todos.md` finds the architecture and + release-note records. +- `make verify` passes. +- No diagnostic files or debug instrumentation remain. + +## Constraints + +- Leave the already-correct manuals and translations unchanged. +- Do not create an ADR or claim a manual native UI smoke test. +- Do not commit; provide the suggested commit message at handoff. + +## Comments + +- `go test ./internal/ui/compare -count=1` passed, as did the assembled + `Compare(LinkToggle|SwipePointer)` selection and both focused guards. +- Deliberately restoring full-width inputs reproduced `Unlinked: Right` over + the left reveal. Deliberately removing coordinate translation reproduced the + wheel-anchor drift from `0.625` to `0.5774`. Both fixes were restored and + both guards passed again. +- `CONTEXT.md`, `ARCHITECTURE.md`, and `todos.md` now record the approved terms, + invariant, and bugfix. Manuals, translations, and ADRs were left unchanged. +- `make verify` passed: formatting, embedded TUF-root check, vet, build, and the + complete Linux/amd64 race suite were green (`internal/ui` 676.609s; + `internal/ui/compare` 28.486s). diff --git a/.scratch/swipe-unlinked-pointer-routing/spec.md b/.scratch/swipe-unlinked-pointer-routing/spec.md new file mode 100644 index 0000000..123a7cd --- /dev/null +++ b/.scratch/swipe-unlinked-pointer-routing/spec.md @@ -0,0 +1,84 @@ +# Spec: unlinked swipe pointer routing + +Status: complete + +## Problem + +In Swipe comparison, both scrollable pane inputs occupy the full viewport. +Fyne treats each scrollable as a new hit-test clip, so the topmost right pane +captures pointer input even over the visible left photo. While the panes are +unlinked, hover therefore reports Right and the user can interact only with +the right photo. + +## Decisions + +- Use **Linked comparison** and **Unlinked comparison** as the canonical terms; + avoid locked/unlocked comparison. +- In Swipe + Unlinked comparison, hover, drag, wheel, cursor, and + last-hovered transform-key targeting follow the currently revealed photo. +- Pane hit regions track the movable divider. At the 0% and 100% extremes, + the fully hidden pane has no interactive area. +- Leaving a revealed photo for the divider, toolbar, or window edge retains + the last target for `0`, `1`, `+`, and `-`. +- Moving the divider across a stationary cursor changes the target on the + next pointer event; divider movement alone does not replace the last target. +- The divider remains the exclusive drag target within its own hit area. +- Full-viewport photo rendering and reveal clipping remain unchanged. +- A right-pane wheel event is translated from its reveal-local coordinates + into full-viewport coordinates so zoom remains anchored beneath the cursor. +- Regression tests use `compare.Feature.Overlay()` in a real Fyne test window. + The assembled-viewer suite is regression coverage, not a duplicate test + seam. + +## Acceptance criteria + +1. Actual canvas hover over either revealed Swipe region reports the matching + Left or Right target at the default divider and after moving the divider. + Verify: + `go test ./internal/ui/compare -run '^TestCompareSwipeUnlinkedCanvasRoutesPointerByReveal$' -count=1` +2. Canvas drag, wheel input, and subsequent transform keys affect only the + targeted photo while unlinked. + Verify: the canvas-routing command above. +3. Wheel zoom over the right reveal preserves the full-viewport image point + beneath the cursor and does not mutate the supplied event. + Verify: + `go test ./internal/ui/compare -run '^TestCompareSwipeUnlinkedRightWheelPreservesViewportAnchor$' -count=1` +4. Linked comparison, side-by-side input, divider precedence, layout + transitions, and command isolation retain their existing behavior. + Verify: `go test ./internal/ui/compare -count=1` + Verify: + `go test ./internal/ui -run 'Compare(LinkToggle|SwipePointer)' -count=1` +5. `CONTEXT.md` records the canonical comparison terms, `ARCHITECTURE.md` + records the reveal-aligned input invariant, and `todos.md` records the + bugfix using linking/unlinking terminology. + Verify: `rg -n 'Linked comparison|Unlinked comparison' CONTEXT.md` + Verify: `rg -n 'reveal|revealed' ARCHITECTURE.md todos.md` +6. Formatting, vet, build, and the complete race suite pass. + Verify: `make verify` + +## Non-goals + +- Changing image render geometry, reveal clipping, tile planning, caches, or + GPU shaders. +- Changing linked-camera behavior, photo transforms, divider semantics, Swap, + or comparison lifecycle behavior. +- Adding exported APIs, preferences, localization keys, or new user controls. +- Retargeting from divider movement without a subsequent pointer event. +- Duplicating the regression through the assembled viewer or requiring a + manual native UI smoke test. +- Rewriting the already-correct comparison manuals or creating an ADR. + +## Honest limit + +The deterministic regression uses Fyne's test driver rather than a manual UI +session. The test and native drivers share the diagnosed hit-test walker, and +the reported native symptom matches the test failure, so focused native tests +plus `make verify` are the acceptance boundary. + +## Outcome + +Swipe pane inputs now track their reveal bounds while both photo render +viewports remain full-size. Right-pane wheel events are copied and translated +back into viewport coordinates. Both regression guards were observed failing +against deliberate reversions, all focused acceptance commands passed, and +`make verify` completed successfully. diff --git a/AGENTS.md b/AGENTS.md index ef467c8..3d92396 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ - Every user-visible string is `lang.L("English text")`; add that exact key to every `translations/*.json` bundle. English is an identity map and `main_test.go` enforces locale parity. - No Unicode arrows in anything the app draws — not in `lang.L` keys or catalogue values, not in the manuals, not even inside backticks. The theme font (NotoSans) has no arrow glyphs, the shaper falls back to a 23-glyph symbol subset with no space, `/` or `-`, and the character *after* the arrow is painted as `�`. Write menu paths and cycles as ASCII `->` and keys as `Left` / `Right` / `Up` / `Down`. Guarded by `TestManualHasNoUnicodeArrows` and `TestTranslationsHaveNoUnicodeArrows`. - Report UI-boundary failures with `fyne.LogError`; viewer-independent packages return errors. Mark intentionally ignored errors explicitly (`_ =` or `_, _ =`) so IDE/`errcheck` inspections see intent. +- In concrete functions and methods, name intentionally unused parameters `_` (for example, `func f(_ context.Context)`); Qodana's `GoUnusedParameter` inspection flags unnamed required parameters such as `func f(context.Context)`. - Use `internal/uitest` for synthetic image formats, temp URIs, approximate comparisons, and OS seam stubs. UI tests should build through `newTestUI`/`newTestViewer`, which mirror production startup. - Keep platform-specific behavior in existing build-tag pairs and preserve no-cgo HEIC/AVIF decoding through `gen2brain` WASM; Fyne itself still requires a C/OpenGL toolchain. @@ -48,6 +49,7 @@ - Run focused tests while iterating, e.g. `go test -run TestE2E -v ./internal/ui/...`; the complete suite remains the final check. - Golden screenshots are under `internal/ui/testdata/`. Regenerate only with `make golden` (Docker linux/amd64), inspect `internal/ui/testdata/failed/*.png`, and never commit failed renders. - Tests/golden rendering and Windows/Linux packaging use Docker; macOS packaging is native. `fyne package` may bump `FyneApp.toml`’s build number. +- **Qodana test exclusions:** Whenever adding a `_test.go` file, add its exact repository-relative path to `qodana.yaml` under `exclude` -> `DuplicatedCode` -> `paths`; test-file globs do not work there. - **Reading a Qodana report:** `qodana.sarif.json` is the post-suppression result set and counts one result per duplicate *cluster*; `log/qodana_inspections_summary.csv` counts every finding *before* both source-level suppressions and `qodana.yaml`'s config-level diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7e5f4fe..af37960 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,12 +31,12 @@ The concurrency invariant: see `AGENTS.md` § Concurrency and Fyne. | File(s) | Responsibility | |---------|----------------| -| `run.go` | `Run`: restore startup viewer, start runtime (`favstore.DefaultDir`, position polling), register shutdown and CLI drop, enter the Fyne loop. | -| `build.go` | `buildViewer` composes widgets and `registerFeatures` modules. Overlay tail: copy selection, grid, comparison (including its pointer shield), delete confirm, export prompt, toast. | +| `run.go` | `Run`: restore startup viewer, start runtime (`favstore.DefaultDir`, position polling), register shutdown and CLI drop, enter the Fyne loop. Shutdown cancels an active comparison before the event loop stops. | +| `build.go` | `buildViewer` composes widgets and `registerFeatures` modules. Overlay tail: copy selection, grid, comparison (including its pointer shield), delete confirm, export prompt, toast. Desktop canvases also receive the chained comparison key-down hook for exact physical `Ctrl+L`; ordinary typed-key and shortcut wiring remains separate. | | `startup.go` | `loadStartupState` / `restoreStartupGeometry` / `buildStartupViewer` — the one load→build→restore path shared by `Run` and tests. | | `components.go` | Dropzone, scan, sort, and info-overlay constructors. Toast stays in `toast.go`. | | `features.go` | `registerFeatures` assigns help, EXIF, zoom, copy selection, grid, comparison, deletion, slideshow, settings, then favorites. | -| `shortcuts.go` | `wireGlobalShortcuts` plus per-action shortcut wiring (open, favorites, clipboard, copy selection, comparison, delete, select-all, save, export, wallpaper). `yieldingShortcuts` blocks ordinary commands during comparison and otherwise yields Copy Selection; Open is admitted only far enough to show comparison's refusal. Copy Selection and clipboard bindings also defend their own direct entries. | +| `shortcuts.go` | `wireGlobalShortcuts` plus per-action shortcut wiring (open, favorites, clipboard, copy selection, comparison, delete, select-all, save, export, wallpaper). Comparison registers the native `Cmd/Ctrl+D` plus physical `Ctrl+D` when those differ. `yieldingShortcuts` blocks ordinary commands during comparison and otherwise yields Copy Selection; Open is admitted only far enough to show comparison's refusal. Copy Selection and clipboard bindings also defend their own direct entries. | | `gesture.go` | Position-poller callback fans samples to `winPos` and `spiralDrag`; a recognised spiral calls `help.OpenSpiral`. | | `windowtrack.go` | Main-window size tracker and position poller; `widgetGeometry` / `prefGeometry` translate `preferences.WindowGeometry` ↔ `widgets.Geometry`. | | `windowmenu.go` | Window-menu action handlers (`showViewer`, `showWindowExif`, `showWindowGrid`, `showWindowPictureFrame`, `showWindowHelp` — grid/picture-frame mutual exclusion lives in the first two) plus `refreshMainMenu` / `syncNativeMenuBar` and the Darwin sync entry points. The Checked/Disabled matrix itself lives in `internal/ui/menus`. | @@ -47,7 +47,7 @@ The concurrency invariant: see `AGENTS.md` § Concurrency and Fyne. | `lifecycle.go` | `requestLifecycle` / `requestToken`. Load, scan, sort, vector, and copy-selection encode each own an instance. | | `viewer.go` | Façade: title (`baseTitle` / `gridTitle` / comparison ownership / `applyTitle`), reset/close, merge, Host vocabulary (`CurrentFile`, `ShowImage`, `RemoveFiles`, …). | | `visibility.go` | `dupeFileSet` (adapts the viewer to `dupes.FileSet` by forwarding `appState`'s published `dupes.Snapshot`); `jumpIfHiddenExtra`; `pushHideDuplicates`; the navigation helpers (`nextVisibleIndex` / `firstVisibleIndex` / `lastVisibleIndex` / `randomVisibleOther`) that read `v.dupes` instead of polling the grid overlay. | -| `keys.go` | `handleKeyEvent` / `handleTypedRune`. Return immediately while `Canvas().Overlays().Top()` is set (Fyne dialogs/menus). Comparison owns all main-window typing: Escape closes it, F1 opens Help, `0` / `1` / `+` / `-` reach its linked transform, swipe-mode `Left` / `Right` / `Home` / `End` reach its divider (with Shift read through `Modifiers`), and every other key/rune stops before the still-open grid. Copy Selection: `HandleKey` consumes Escape/copy/navigation; unowned keys `yieldCopySelection` except modifier-only and zoom keys. | +| `keys.go` | `handleKeyEvent` / `handleTypedRune`, plus a chained desktop key-down hook that requests the ready-gated comparison link toggle on exact physical `Ctrl+L` without key-repeat flapping. Return immediately while `Canvas().Overlays().Top()` is set (Fyne dialogs/menus). Comparison owns all main-window typing: Escape closes it, F1 opens Help, `0` / `1` / `+` / `-` reach its shared camera or hovered photo pose, swipe-mode `Left` / `Right` / `Home` / `End` reach its divider, and every other key/rune stops before the still-open grid. Copy Selection: `HandleKey` consumes Escape/copy/navigation; unowned keys `yieldCopySelection` except modifier-only and zoom keys. | | `menu.go` | `buildMainMenu` builds `internal/ui/menus.Menus` and assembles the bar: File, Favorites, Actions, Window, Help. `yieldingMenuCallbacks` enforces comparison isolation and Copy Selection yielding at callback entry. `menuState()` is the one function that builds the `menus.State` snapshot; `syncMenus()` applies it, pushes comparison/file availability into Favorites, and refreshes the native bar only when something actually changed. | | `actionmenu.go` | Comparison-guarded Actions-menu handlers (`setActionsSort`, `toggleActionsHideDuplicates`, `showActionsVariant`, `rotateActionsImage`, …). The Checked/Disabled matrix lives in `internal/ui/menus`. | | `drop.go` | `handleDrop` / `applyScanResult` / `applyScannedFiles` glue over `filescan.Images` / `filescan.Siblings`; scan lifecycle is `viewer.scanOp`. A non-empty drop is refused before any state change while comparison is active. | @@ -71,7 +71,7 @@ The concurrency invariant: see `AGENTS.md` § Concurrency and Fyne. | `session.go` | `restoreSession` glue over `internal/session`. | | `clipboard.go` | Copy-path / copy-image glue over `internal/clipboard`. | | `copyselection.go` | Viewer adapter for `internal/ui/copyselection`: availability, start/cancel, zoom `Geometry` to `View`, animation pause, clipboard worker, `yieldCopySelection`. Command entry yields through `yieldingMenuCallbacks`, `yieldingShortcuts`, `handleKeyEvent`, and `handleDrop`. | -| `compare.go` | Viewer adapter for `internal/ui/compare`: validates exactly two explicit grid selections, resolves ascending host indices to URIs, and loads through the canonical full-image cache/probe/decode path. That path preserves EXIF-corrected pixels, RAW previews, animation decoding/budget policy, encoded-input limits, and the original first frame; the feature deliberately freezes animation. The adapter owns the exact comparison-window title callback and reports failures without mutating the grid or file set. `comparisonActive()` is the composition layer's sole exclusive-mode fact; `refuseOpenDuringComparison()` owns the localized discard policy. | +| `compare.go` | Viewer adapter for `internal/ui/compare`: validates exactly two explicit grid selections, resolves ascending host indices to URIs, unfocuses the covered grid so desktop modifier hooks remain reachable, and loads through the canonical full-image cache/probe/decode path. That path preserves EXIF-corrected pixels, RAW previews, animation decoding/budget policy, encoded-input limits, and the original first frame; the feature deliberately freezes animation. The adapter owns the exact comparison-window title callback and reports failures without mutating the grid or file set. `comparisonActive()` is the composition layer's sole exclusive-mode fact; `refuseOpenDuringComparison()` owns the localized discard policy. | | `animationpause.go` | Serializes animated-frame advancement with Copy Selection's stable source capture. | | `openfiles.go` | Native open-dialog glue over `internal/filepicker`; both dialog entry and chooser execution refuse an active comparison before starting external work. | @@ -82,7 +82,7 @@ The concurrency invariant: see `AGENTS.md` § Concurrency and Fyne. | `internal/ui/zoom/` | Zoom/pan of the displayed image. `Geometry` / `HandleScroll` / `SetOnGeometryChanged` are the presentation seam Copy Selection uses; this package does not import `copyselection`. Window growth is `syncWindowToZoom` in `internal/ui`. | `onChanged`, `modifiers`, `onScaleChanged`. | | `internal/ui/copyselection/` | Transient Copy Selection mode: image-region geometry, overlay, and captured `Source` crop/encode. `HandleKey` reports whether the mode consumed the key. | `Copy`, `Ended`, `Scroll`. | | `internal/ui/grid/` | Overview (G): `GridWrap`, thumb cache, `decodepool`, `uiqueue.go`, search, badges, explicit host-index selection plus its change observer, `marquee.go` (drag rectangle → `Targets()`), browse-duplicates (Shift+D), and `hashengine.go`'s pool-driven hashing pass that feeds `internal/dupes`. `nav.go`: `setHighlight` → `HighlightChanged`. Reads the model; does not own it. | 10-method `Host` including `Modifiers`. | -| `internal/ui/compare/` | Opaque main-window comparison surface: switchable gapless 50/50 and full-viewport swipe layouts driven by one normalized center and linked fit-relative/actual scale; each image has one reveal clip so swipe keeps aligned image coordinates. It retains each raster source's original decoded first frame. Each SVG has a pane-local, device-pixel raster that is regenerated after zoom/layout/window changes, clamped by `imaging.ClampVectorRaster`, and protected by cancellation plus stale-target checks. Concurrent loads and vector workers publish through the feature's `UIQueue`; `Settle` covers workers and downstream queued completions. It also owns wheel/key zoom, drag/Shift+wheel pan, shared no-overscroll clamping, divider input, permanent chrome, ready-gated layout toggle/Swap, the input shield, and the replaceable completion signal. It receives an ordered URI pair and never reads or mutates grid/viewer state. | `Loader` plus `Callbacks` (`Repaint`, `Closed`, `Failed`, `OrderChanged`, `Modifiers`). | +| `internal/ui/compare/` | Opaque main-window comparison surface: switchable gapless 50/50 and full-viewport swipe layouts compose two persistent photo transforms with one shared camera transform; each image has one reveal clip so swipe keeps aligned image coordinates. In Swipe, each pane input mirrors its current reveal even though the render viewport remains full-size, and reveal-local wheel coordinates are translated back into that viewport. The ready-gated top-left Unlink/Link control and physical `Ctrl+L` share `ToggleLink`; its adjacent status reports only the active unlinked target, while layout/Swap/Back stay in a separate top-right card. `ToggleLink` changes only input ownership and never changes rendered geometry. Linked pan/zoom moves the camera, linked `0` frames both current photo poses without rewriting them, and linked `1` returns the camera home. Unlinked pointer input and transform keys target the hovered or last-hovered photo; its `0` / `1` fit or show that photo at decoded-pixel size in the current camera. Photo centers and camera movement stop when an image edge reaches its pane center. Resize and layout preserve both photo poses and the camera; Swap deliberately clears divergence from the last-targeted visible pose before exchanging sources. A private `paneRenderer` scene seam keeps transforms independent from presentation; production owns two stable `canvas.Shader` objects while tests can inject the canvas reference adapter. Each immutable render source retains the canonical decoded frame, a long-edge-1024 overview, and a 64 MiB detail-tile cache. The planner uses physical display density and the actual side-by-side/swipe reveal, skips details when the overview is sufficient, and binds at most seven guttered detail tiles without shuffling stable sampler slots. One cancellable worker per pane generates tiles; publications are coalesced and marshalled through the feature's `UIQueue`. Pan/zoom changes shader geometry and uniforms without repainting the viewer root. Each SVG still gets a pane-local device-pixel raster, clamped by `imaging.ClampVectorRaster`, before entering the same overview/tile path. `Settle` covers load, vector, tile, and causal queued completions with reusable channel-epoch barriers. Fyne's software test painter does not render `canvas.Shader`, so deterministic pixel tests use the reference adapter; native runtime acceptance uses the GL painter. The feature also owns divider input, permanent chrome, ready-gated layout/link/Swap controls, the input shield, and the replaceable completion signal. It receives an ordered URI pair and never reads or mutates grid/viewer state. | `Loader` plus `Callbacks` (`Repaint`, `Closed`, `Failed`, `OrderChanged`, `Modifiers`). | | `internal/ui/deletion/` | Shift+Delete confirm (`widgets.ChoiceCard`) then `trash.Move`. `RequestFiles` is the batch path; `Request` is the one-file wrapper. | 7-method `Host`. | | `internal/ui/slideshow/` | Picture-frame mode (P): full-screen, auto-advance, interval, `winpos.Tracker` capture/restore. | 2-method `Host`. Knows nothing about the grid. | | `internal/ui/exifwin/` | EXIF panel (E): tag list, optional JPEG strip, GPS map (`tiles.go`, `startWarm`). Geometry via `widgets.Singleton`. | 4-method `Host`. | @@ -418,7 +418,7 @@ see `AGENTS.md`. - "How can dragging the window open something?" → `internal/wingesture` + `gesture.go` + `help.OpenSpiral` / `spiral.ShowForGesture`. - "How does copy-image-to-clipboard work?" → `internal/clipboard` + `clipboard.go`. Batch file copy: `copyfiles.go` + `batch.go` `copySelection`. - "How does Copy Selection (image-region copy) work?" → `internal/ui/copyselection` (`Source` / `Encode`, `HandleKey`) + `copyselection.go` (pause, clipboard worker, `yieldCopySelection`) + `menu.go` `yieldingMenuCallbacks` + `shortcuts.go` `yieldingShortcuts` + zoom `Geometry` / `HandleScroll` + overlay order in `build.go`. -- "How does two-image comparison open, preserve raster/vector fidelity, identify/swap sides, switch side-by-side/swipe, route divider/zoom/pan input, isolate commands, load, settle, and return to the grid?" → `internal/ui/compare` (surface/chrome/reveal clips/layout/shared transform/divider/input shield/load and vector lifecycles/UI queue) + `internal/ui/compare.go` (selection, canonical loader, title, active-mode fact, and open refusal) + `features.go` / `keys.go` (modifier, transform-key, and divider-key routing) + `menus` / `menu.go` / `shortcuts.go` (disabled state and guarded entries) + open paths in `drop.go` / `openfiles.go` / `openwith.go` + overlay order in `build.go`. +- "How does two-image comparison open, preserve raster/vector fidelity, render through stable overview/detail shaders, identify/swap sides, switch side-by-side/swipe, toggle photo editing with physical Ctrl+L, route camera/photo/divider input, isolate commands, load, settle, and return to the grid?" -> `internal/ui/compare` (`renderer.go` scene/source seam, `shader.go` stable GPU panes and tile lifecycle, `tile.go` reveal-aware planning/cache/generation, plus surface/chrome/reveal clips/layout/two photo transforms/shared camera/hover target/divider/input shield/load/vector lifecycles/UI queue) + `internal/ui/compare.go` (selection, focus release, canonical loader, title, active-mode fact, and open refusal) + `features.go` / `keys.go` / `shortcuts.go` (physical Ctrl+D, toggle hook, transform-key, divider-key, and command routing) + `favorites` / `menus` / `menu.go` (disabled state and guarded entries) + open paths in `drop.go` / `openfiles.go` / `openwith.go` + overlay order in `build.go`. - "How does the grid overview / thumbnail generation work?" → `imaging/thumbnail.go` + `grid/grid.go` + `grid/thumbs.go` + `grid/hashengine.go` + `grid/nav.go` + `grid/uiqueue.go`. - "What decides the window title?" → `viewer.go` `setTitle` / `applyTitle` / `HighlightChanged` + `load.go` + `grid/nav.go`. - "How do I write a test that needs an image / a viewer?" → `internal/uitest` + `newTestViewer` / `newTestUI` + `dropAndWait` in `harness_test.go`. diff --git a/CONTEXT.md b/CONTEXT.md index 6887c5f..6ed1228 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,3 +18,13 @@ _Avoid_: Crop, grid selection A transient single-image-viewer mode for defining an image-region selection to copy as image data. _Avoid_: Crop mode, screenshot mode + +**Linked comparison**: +A two-photo comparison state where view adjustments affect both photos +together. +_Avoid_: Locked comparison + +**Unlinked comparison**: +A two-photo comparison state where view adjustments affect one targeted photo +without changing the other. +_Avoid_: Unlocked comparison diff --git a/Makefile b/Makefile index 3e511b5..847efa6 100644 --- a/Makefile +++ b/Makefile @@ -13,12 +13,12 @@ RELEASE_BRANCH := main GOIMPORTS_LOCAL := github.com/frathe/picfetch # ubuntu-latest + race + Fyne's software renderer: internal/ui is ~10 minutes. # go test's default 10m per-package timeout is no longer enough. -TEST_TIMEOUT := 20m +TEST_TIMEOUT := 30m TEST_IMAGE := ubuntu:24.04 TEST_CONTAINER_LABEL := io.github.frathe.picfetch.test=true TEST_RACE := -.PHONY: all build build-linux-all run fmt fmt-check vet test update-test-image enter-test-container test-native test-race verify golden tidy clean package-mac package-windows package-windows-debug package-linux package-linux-debug build-all install-tools install-linux-tools security security-govulncheck security-github bump-version release check-tuf-root sync-tuf-root help +.PHONY: all build build-linux-all run fmt fmt-check vet test update-test-image enter-test-container test-native test-race verify golden tidy clean package-mac package-windows package-windows-debug package-linux package-linux-debug build-all install-tools install-linux-tools security security-govulncheck security-github bump-version release check-tuf-root sync-tuf-root sync-qodana-test-exclusions check-qodana-test-exclusions help all: build @@ -44,6 +44,75 @@ check-tuf-root: ## Fail if the embedded GitHub TUF root is expired or has fewer sync-tuf-root: ## Fetch and TUF-verify a newer GitHub root into the embed (needs network) go run ./scripts/synctuf --write +sync-qodana-test-exclusions: ## Synchronize Qodana's duplication exclusions with every *_test.go file + @set -eu; \ + listed=$$(mktemp); \ + entries=$$(mktemp); \ + updated=$$(mktemp); \ + trap 'rm -f "$$listed" "$$entries" "$$updated"' 0 1 2 3 15; \ + git ls-files --cached --others --exclude-standard -- '*_test.go' > "$$listed"; \ + while IFS= read -r test_file; do [ -f "$$test_file" ] && printf '%s\n' "$$test_file"; done < "$$listed" | \ + LC_ALL=C sort -u | \ + awk '{ printf " - \"%s\"\n", $$0 }' > "$$entries"; \ + awk -v entries="$$entries" '\ + function emit_entries(entry) { \ + while ((getline entry < entries) > 0) print entry; \ + close(entries); \ + } \ + { \ + if ($$0 == "exclude:") { in_exclude = 1; print; next } \ + if (in_paths) { \ + if ($$0 ~ /^ - /) { \ + if ($$0 !~ /_test\.go"$$/) print; \ + next; \ + } \ + in_paths = 0; \ + in_duplicate = 0; \ + } \ + if (in_exclude && $$0 == " - name: DuplicatedCode") in_duplicate = 1; \ + if (in_duplicate && $$0 == " paths:") { \ + print; \ + emit_entries(); \ + in_paths = 1; \ + replaced = 1; \ + next; \ + } \ + if (in_exclude && $$0 !~ /^ /) in_exclude = 0; \ + print; \ + } \ + END { \ + if (!replaced) { \ + print "DuplicatedCode exclusion paths block not found in qodana.yaml" > "/dev/stderr"; \ + exit 1; \ + } \ + }' qodana.yaml > "$$updated"; \ + if cmp -s qodana.yaml "$$updated"; then \ + echo "Qodana test exclusions are already synchronized."; \ + else \ + cp "$$updated" qodana.yaml; \ + echo "Updated qodana.yaml test exclusions."; \ + fi; \ + $(MAKE) --no-print-directory check-qodana-test-exclusions + +check-qodana-test-exclusions: ## Fail if qodana.yaml does not exclude every *_test.go from duplication checks + @set -eu; \ + listed=$$(mktemp); \ + test_files=$$(mktemp); \ + excluded_files=$$(mktemp); \ + trap 'rm -f "$$listed" "$$test_files" "$$excluded_files"' 0 1 2 3 15; \ + git ls-files --cached --others --exclude-standard -- '*_test.go' > "$$listed"; \ + while IFS= read -r test_file; do [ -f "$$test_file" ] && printf '%s\n' "$$test_file"; done < "$$listed" | \ + LC_ALL=C sort -u > "$$test_files"; \ + sed -nE 's/^ - "([^"]+_test\.go)"$$/\1/p' qodana.yaml | LC_ALL=C sort -u > "$$excluded_files"; \ + missing=$$(comm -23 "$$test_files" "$$excluded_files"); \ + stale=$$(comm -13 "$$test_files" "$$excluded_files"); \ + if [ -n "$$missing$$stale" ]; then \ + if [ -n "$$missing" ]; then printf 'Missing Qodana test exclusions:\n%s\n' "$$missing"; fi; \ + if [ -n "$$stale" ]; then printf 'Stale Qodana test exclusions:\n%s\n' "$$stale"; fi; \ + echo "Run 'make sync-qodana-test-exclusions' to update qodana.yaml."; \ + exit 1; \ + fi + vet: ## Run go vet go vet ./... @@ -95,7 +164,7 @@ test-native: ## Run tests directly on the current OS/architecture test-race: TEST_RACE := -race test-race: test -verify: fmt-check check-tuf-root ## Run the same checks CI does (goimports, TUF root expiry, vet, build, race tests) +verify: fmt-check check-tuf-root check-qodana-test-exclusions ## Run the same checks CI does (format, TUF root, Qodana exclusions, vet, build, race tests) go vet ./... go build ./... $(MAKE) test-race @@ -124,8 +193,8 @@ golden: ## Regenerate the e2e golden-master screenshots via Docker (linux/amd64, tidy: ## Tidy go.mod / go.sum go mod tidy -security-govulncheck: ## Scan dependencies for known Go vulnerabilities (govulncheck) - govulncheck ./... +security-govulncheck: ## Scan dependencies with the module-pinned govulncheck + go tool govulncheck ./... security-github: ## List open GitHub Dependabot alerts for this repo (needs `gh auth login`) gh api "repos/$$(gh repo view --json nameWithOwner -q .nameWithOwner)/dependabot/alerts" \ @@ -175,10 +244,9 @@ build-linux-all: package-linux ## Alias for package-linux: cross-compile Linux b build-all: package-mac package-windows package-linux ## Build release artifacts for macOS, Windows, and Linux -install-tools: ## Install the fyne, fyne-cross, and govulncheck CLI tools +install-tools: ## Install the fyne and fyne-cross packaging tools go install fyne.io/fyne/v2/cmd/fyne@latest go install github.com/fyne-io/fyne-cross@latest - go install golang.org/x/vuln/cmd/govulncheck@latest install-linux-tools: ## Install apt dev headers needed to build natively on Linux (OpenGL, X11, Wayland; needs sudo) sudo apt-get update diff --git a/README.md b/README.md index c5e0bc1..f72c253 100644 --- a/README.md +++ b/README.md @@ -151,17 +151,18 @@ isn't actually corrupted — to open it anyway: ## Requirements -- Go 1.26.6 or newer (see the `go` directive in [go.mod](go.mod)) +- Go 1.27.1 or newer (see the `go` directive in [go.mod](go.mod)) - A C toolchain for cgo (Fyne's OpenGL bindings require it) — Xcode Command Line Tools on macOS, `gcc` + `libgl1-mesa-dev`/`xorg-dev` on Linux - [Docker](https://www.docker.com/) — used by `make test`/`make verify` so tests and golden comparisons run on Linux/amd64 like CI, and also needed for cross-compilation and `make golden` -- [`govulncheck`](https://go.dev/security/vuln) and the - [GitHub CLI](https://cli.github.com/) (`gh`) — only needed for the - `make security*` targets. `govulncheck` is installed by - `make install-tools`; `gh` must be installed separately (e.g. `brew install - gh`) and authenticated via `gh auth login` +- The [GitHub CLI](https://cli.github.com/) (`gh`) — only needed for + `make security-github` and the combined `make security` target. It must be + installed separately (e.g. `brew install gh`) and authenticated via + `gh auth login`. `make security-govulncheck` runs the repository-pinned + [`govulncheck`](https://go.dev/security/vuln) through Go, with no separate + installation required ## Running @@ -183,7 +184,7 @@ list them. | `make package-windows` | Windows `.exe` files, cross-compiled via `fyne-cross`/Docker, to `bin/picfetch-windows-.exe` | | `make package-linux` | Linux binaries, cross-compiled via `fyne-cross`/Docker, to `bin/picfetch-linux-` | | `make build-all` | Runs `package-mac`, `package-windows`, and `package-linux` | -| `make install-tools` | Installs the `fyne`, `fyne-cross`, and `govulncheck` CLIs used by the package/security targets | +| `make install-tools` | Installs the `fyne` and `fyne-cross` CLIs used by the packaging targets | Packaging is done with the [`fyne`](https://pkg.go.dev/fyne.io/fyne/v2/cmd/fyne) CLI (native OS builds) and [`fyne-cross`](https://github.com/fyne-io/fyne-cross) @@ -218,12 +219,12 @@ packaged build. | `make fmt-check` | Fail if any file differs from that `goimports` (CI format gate) | | `make vet` | `go vet ./...` | | `make update-test-image` | Pull the latest Linux/amd64 Ubuntu image used by Docker tests | -| `make test` | Run `go test -timeout 20m ./...` in Linux/amd64 Docker, matching CI and golden rendering | +| `make test` | Run `go test -timeout 30m ./...` in Linux/amd64 Docker, matching CI and golden rendering | | `make test-native` | Run the same suite directly on the host (goldens can differ outside Linux/amd64) | | `make verify` | The same gate CI runs; its race-test step uses the `make test` Linux/amd64 container | | `make tidy` | `go mod tidy` — tidy go.mod / go.sum | | `make security` | Run all security checks (govulncheck + GitHub Dependabot alerts) | -| `make security-govulncheck` | Scan dependencies for known Go vulnerabilities with `govulncheck` | +| `make security-govulncheck` | Scan dependencies with the repository-pinned `govulncheck` | | `make security-github` | List open GitHub Dependabot alerts via `gh` (needs `gh auth login`) | | `make clean` | Remove `bin/`, `fyne-cross/`, and any stray packaged app/zip | @@ -231,6 +232,9 @@ packaged build. > (`gh`) to be installed and authenticated (`gh auth login`), and it must be run > from a checkout with a GitHub `origin` remote. +`make security-govulncheck` invokes the version declared as a tool dependency +in [go.mod](go.mod), so its first run may download that pinned module. + ### Releasing ```sh diff --git a/assets/trane/trane_lightwall.png b/assets/trane/trane_lightwall.png new file mode 100644 index 0000000..428f1ea Binary files /dev/null and b/assets/trane/trane_lightwall.png differ diff --git a/docs/release-signing.md b/docs/release-signing.md new file mode 100644 index 0000000..0170881 --- /dev/null +++ b/docs/release-signing.md @@ -0,0 +1,67 @@ +# Windows release signing + +PicFetch signs the Windows release executables automatically after the tag +release build passes its test gate. The signing key remains in Certum +SimplySign's cloud service; it is never stored in this repository or uploaded +as a PFX file. + +## One-time GitHub setup + +Create a GitHub Actions environment named release-signing in the PicFetch +repository. Require a maintainer's approval before deployments to that +environment. This is the human release approval: after it, the signing job can +use the protected secrets below. + +Add these environment secrets to release-signing, not ordinary repository +secrets: + +| Secret | Value | +|---|---| +| CERTUM_USERNAME | The SimplySign account username, normally its e-mail address. | +| CERTUM_OTP_URI | The complete otpauth:// TOTP URI for the SimplySign account. | +| CERTUM_CERT_THUMBPRINT | The code-signing certificate's 40-character SHA-1 thumbprint, without spaces. | + +CERTUM_OTP_URI is highly sensitive: it enables unattended generation of the +second-factor code. Anyone who can change the release workflow and obtain this +secret could cause a trusted signature to be made. Keep environment approval +enabled, limit environment access, protect the release branch, and review all +changes to .github/workflows/release.yml. + +The certificate must expose a pinless virtual card in SimplySign. Certum's +additional interactive card-PIN prompt cannot be answered safely by this +unattended workflow. Confirm this account setting before the first test tag. + +Certum does not currently document an official headless SimplySign API. The +workflow therefore pins the third-party setup action to a reviewed commit +rather than a mutable tag. It also downloads Certum SimplySign Desktop 9.4.4.92 +from Certum's own server and checks its Windows Authenticode signature before +the action installs it. + +## Release flow + +1. A v* tag runs the normal reusable CI test gate. +2. The Linux cross-build produces the two unsigned Windows ZIP artifacts. +3. The sign-windows job waits for the protected release-signing environment, + downloads those artifacts, and authenticates SimplySign. +4. SignTool signs each picfetch.exe with SHA-256 and Certum's RFC-3161 + timestamp service, then verifies the embedded signature. +5. The job uploads new signed Windows ZIP artifacts. +6. The final release job publishes macOS, Linux, and only the signed Windows + ZIPs. It does not download the unsigned Windows artifacts. + +The timestamp keeps a valid signature trustworthy after the certificate later +expires, provided it was signed while the certificate was valid. + +## First release + +Before relying on an automatic public release, run the workflow on a test tag +and inspect both Windows ZIPs after download: + +~~~powershell +signtool verify /pa /all /v /tw .\picfetch.exe +~~~ + +Windows should report a successful signature chain and timestamp. If +authentication or certificate discovery fails, the signing job fails before +the GitHub release job can run; it cannot fall back to publishing unsigned +Windows executables. diff --git a/plans/2026-08-31-open-side-by-side-comparison.md b/finished_refactorings/2026-08-31-open-side-by-side-comparison.md similarity index 100% rename from plans/2026-08-31-open-side-by-side-comparison.md rename to finished_refactorings/2026-08-31-open-side-by-side-comparison.md diff --git a/plans/2026-09-01-add-swipe-comparison.md b/finished_refactorings/2026-09-01-add-swipe-comparison.md similarity index 100% rename from plans/2026-09-01-add-swipe-comparison.md rename to finished_refactorings/2026-09-01-add-swipe-comparison.md diff --git a/finished_refactorings/2026-09-01-ctrl-l-comparison-link-toggle.md b/finished_refactorings/2026-09-01-ctrl-l-comparison-link-toggle.md new file mode 100644 index 0000000..3e22e81 --- /dev/null +++ b/finished_refactorings/2026-09-01-ctrl-l-comparison-link-toggle.md @@ -0,0 +1,70 @@ +# Ctrl+L comparison link toggle + +Status: complete + +Route: Standard. This corrects one comparison interaction across +`internal/ui/compare`, viewer input wiring, Favorites shortcut presentation, +the manuals, and architecture notes. It adds no package, dependency, +preference, or external API. + +Deliverable: exact physical `Ctrl+L` toggles retained pane-local views until a +second press relinks from the last-hovered pane through the shared no-blank +clamp. + +## Locked decisions + +| Decision | Contract | +|---|---| +| Shortcut | Physical `Ctrl+L` on every desktop platform, including macOS; extra modifiers do not match. | +| Lifecycle | New comparisons start linked. Control press/release alone has no effect. | +| Target and relink | Local input uses the hovered or last-hovered pane; relinking adopts that pane and applies the shared clamp. | +| Existing transitions | Resize and layout preserve local views. Swap relinks and resets divergence. | +| Feedback | Reuse `Unlinked`, `Unlinked: Left`, and `Unlinked: Right`; add no button or preference. | + +## Tasks + +### Task 1 - Toggle state and pane-local input + +Owner: T0 inline + +Test first through `compare.Feature`: persistent local pointer/key input, +status, relinking/clamping, retained caches, layout/resize, Swap, and vector +rendering. + +Verify: `go test ./internal/ui/compare -count=1` + +### Task 2 - Physical key edge and shortcut cleanup + +Owner: T0 inline + +Test first through the assembled viewer key hook: exact physical `Ctrl+L`, no +Control-release effect, no repeat flapping, unmodified transform/divider keys, +hook chaining, and unchanged Favorites availability. + +Verify: `go test ./internal/ui ./internal/ui/favorites -count=1` + +### Task 3 - Documentation and final gate + +Owner: T0 inline + +Update both manuals, `ARCHITECTURE.md`, and `todos.md`; preserve the completed +hold-to-unlink plan as historical evidence. + +Verify: `go test ./... -run 'Translations|Manual|UnicodeArrows' -count=1` + +## Budget and gate + +Zero spawns; at most three review rounds; one full suite. Negatively verify the +exact-toggle guard before the final `make verify` run. + +## Outcome + +Comparison now starts linked and exact physical `Ctrl+L` persistently toggles +the retained pane-local views. Control release is inert, unmodified comparison +gestures and transform keys target the last-hovered pane while unlinked, and a +second toggle relinks from that pane through the shared clamp. The obsolete +modified-key shortcuts and Favorites accelerator workaround were removed. + +Verification completed with focused comparison/viewer/Favorites tests, manual +and translation guards, Windows-targeted vet, a negative exact-modifier +mutation, and the full Linux/amd64 race-backed `make verify` gate. diff --git a/plans/2026-09-01-identify-and-swap-images.md b/finished_refactorings/2026-09-01-identify-and-swap-images.md similarity index 100% rename from plans/2026-09-01-identify-and-swap-images.md rename to finished_refactorings/2026-09-01-identify-and-swap-images.md diff --git a/plans/2026-09-01-isolate-comparison-commands.md b/finished_refactorings/2026-09-01-isolate-comparison-commands.md similarity index 100% rename from plans/2026-09-01-isolate-comparison-commands.md rename to finished_refactorings/2026-09-01-isolate-comparison-commands.md diff --git a/plans/2026-09-01-link-side-by-side-zoom-and-pan.md b/finished_refactorings/2026-09-01-link-side-by-side-zoom-and-pan.md similarity index 100% rename from plans/2026-09-01-link-side-by-side-zoom-and-pan.md rename to finished_refactorings/2026-09-01-link-side-by-side-zoom-and-pan.md diff --git a/plans/2026-09-01-preserve-comparison-state.md b/finished_refactorings/2026-09-01-preserve-comparison-state.md similarity index 100% rename from plans/2026-09-01-preserve-comparison-state.md rename to finished_refactorings/2026-09-01-preserve-comparison-state.md diff --git a/plans/2026-09-01-preserve-source-fidelity.md b/finished_refactorings/2026-09-01-preserve-source-fidelity.md similarity index 100% rename from plans/2026-09-01-preserve-source-fidelity.md rename to finished_refactorings/2026-09-01-preserve-source-fidelity.md diff --git a/finished_refactorings/2026-09-01-temporary-control-unlink-comparison.md b/finished_refactorings/2026-09-01-temporary-control-unlink-comparison.md new file mode 100644 index 0000000..93973b7 --- /dev/null +++ b/finished_refactorings/2026-09-01-temporary-control-unlink-comparison.md @@ -0,0 +1,85 @@ +# Temporary Control unlink in comparison + +Status: complete + +Route: Standard. This extends `internal/ui/compare`, the assembled viewer input +seam, translations, and both manuals. It adds no package, dependency, +preference, or external API. + +Deliverable: a fresh physical Control hold temporarily restores two retained +pane-local views; release relinks from the hovered pane under the existing +shared no-blank clamp. + +## Locked decisions + +| Decision | Contract | +|---|---| +| Scope | The current comparison layout default is unchanged. Control means the physical Control key on every desktop platform. | +| Target | Pointer gestures and `0`, `1`, `+`, and `-` affect the hovered or last-hovered pane. With no target, local keyboard transforms do nothing. | +| Relink | Release always chooses the hovered pane, even when it was not edited, then applies the shared clamp. | +| Local bounds | Local centers are bounded to normalized `[0,1]`, allowing an image edge to reach pane center. | +| Retention | Pane-local views persist for the comparison session. Linked pan/zoom applies the same normalized delta/scale ratio to both caches; linked `0`/`1` changes their scale modes but retains their centers. | +| Transitions | Resize and layout toggles preserve both local views. Swap relinks, clears divergence, swaps, and suppresses the still-held Control until release. | +| Feedback | Show only `Unlinked`, `Unlinked: Left`, or `Unlinked: Right` in the toolbar while a fresh Control hold is active. | + +## Tasks + +### Task 1 - Pane-local transform state + +Owner: T0 inline + +Test first through `compare.Feature` and its overlay: local pointer/key input, +hover targeting, overscroll, release clamping, retained caches, relayout, Swap, +session reset, status, and vector rendering. + +Verify: `go test ./internal/ui/compare -run 'Compare.*(Control|Unlink|Relink|Local|Cache)' -count=1` + +### Task 2 - Assembled input wiring + +Owner: T0 inline + +Test first through the viewer and production shortcut/key-hook wiring: physical +Control lifecycle, Ctrl+D suppression, modified transform/divider keys, +Ctrl+0/Ctrl+1 favorite preservation, and covered-grid isolation. + +Verify: `go test ./internal/ui -run 'Compare.*(Control|Unlink|Relink|Local|Cache)' -count=1` + +### Task 3 - User documentation and landing + +Owner: T0 inline + +Localize the status strings, update both manuals and `ARCHITECTURE.md`, then +replace the open TODO with a verified release-note entry. Preserve the +historical finished comparison specification. + +Verify: `go test ./... -run 'Translations|Manual|UnicodeArrows' -count=1` + +## Budget and gate + +Zero spawns; at most three review rounds; one full suite. Each behavior guard +must be observed red before implementation and fail once more under a deliberate +mutation before final acceptance. Final gate: Windows vet followed by `make +verify`. + +## Outcome + +Implemented inline with zero spawns. Comparison now owns retained pane-local +transforms, physical-Control lifecycle/status, target-based pointer and keyboard +input, release-time relinking/clamping, cache propagation during linked input, +layout/resize preservation, Swap suppression/reset, and pane-local SVG raster +targets. Viewer wiring chains desktop modifier hooks and registers modified-key +repeat shortcuts; Favorites temporarily releases its digit accelerators while +comparison is active so Windows/Linux Control+0/1 reaches comparison and is +restored afterward. Both manuals, locale catalogs, `ARCHITECTURE.md`, and the +release notes describe the landed behavior; side-by-side remains the default. + +Verification completed 2026-09-01: + +- Focused compare, viewer, Favorites, manual, translation, and Unicode-arrow + tests passed. +- Deliberate mutations were detected by the local-render, cache propagation, + status/relink/suppression, bounds/reset, shortcut/hook, and accelerator guards; + the restored focused set passed. +- `GOOS=windows GOARCH=amd64 go vet ./internal/...` passed. +- `make verify` passed, including the Linux/amd64 race suite (`internal/ui` in + 639.593s). diff --git a/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/01-shortcut-and-repaint-guards.md b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/01-shortcut-and-repaint-guards.md new file mode 100644 index 0000000..5dbbe29 --- /dev/null +++ b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/01-shortcut-and-repaint-guards.md @@ -0,0 +1,25 @@ +# 01 - Pin shortcut parity and repaint-free interaction + +Status: resolved + +## Contract + +Register physical Ctrl+D in addition to the platform-default D shortcut without +duplicating the same chord. Add a sustained pan/zoom regression guard proving +100 interaction events do not invoke the owner repaint callback. + +## Red / green + +1. Add tests for both shortcut chords and the 100-event interaction boundary. +2. Observe failure against current shortcut wiring and repaint calls. +3. Make only the smallest behavioral change needed; retain lifecycle repaints. + +## Comments + +Native baseline confirms the forbidden interaction path ends in +`viewer.ForceRepaint` and full-image Catmull-Rom resampling. + +The shortcut guard failed with no physical-Control registration on macOS. The +interaction guard reported exactly 100 owner repaints for 100 mixed pan/zoom +events in each layout. Both focused tests pass after conditional shortcut +registration and removal of the six interaction-path repaint calls. diff --git a/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/02-render-scene-seam.md b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/02-render-scene-seam.md new file mode 100644 index 0000000..05f19c6 --- /dev/null +++ b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/02-render-scene-seam.md @@ -0,0 +1,22 @@ +# 02 - Introduce the pane scene renderer seam + +Status: resolved + +## Contract + +Keep `compare.New` unchanged and add an unexported constructor/factory for two +pane renderers. Transform application presents immutable `paneScene` values. +The test reference adapter preserves deterministic canvas geometry and pixels. + +## Acceptance + +Feature tests prove initial, transformed, swapped, cleared, and resized scenes, +and prove pane render objects stay stable across interaction. + +## Comments + +The seam test first failed to compile because `paneScene`, `paneRenderer`, and +the private constructor did not exist. After implementation it proved source, +logical/physical geometry, stable objects, Swap, clear, and Settle coverage. +The full compare suite exposed and then pinned source publication before a +viewport exists; the suite is green. diff --git a/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/03-display-ready-sources.md b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/03-display-ready-sources.md new file mode 100644 index 0000000..c03ae85 --- /dev/null +++ b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/03-display-ready-sources.md @@ -0,0 +1,21 @@ +# 03 - Prepare immutable display-ready sources + +Status: resolved + +## Contract + +Convert each decoded or rerasterized frame into an immutable render source with +an aspect-preserving overview whose long edge is at most 1024. Keep spinners +until both overviews are ready and never mutate canonical decoded pixels. + +## Acceptance + +Tests cover dimensions, fidelity, transparency, cancellation/staleness, raster +and vector readiness, and publication through the UI queue. + +## Comments + +The source tests failed first on the absent preparation function, overview, +and per-instance hook. They now prove the 1024-pixel overview bound, alpha and +canonical-frame preservation, cancellation, and spinner/readiness boundary. +The full compare suite passes. diff --git a/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/04-tile-planner-and-cache.md b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/04-tile-planner-and-cache.md new file mode 100644 index 0000000..7b5add1 --- /dev/null +++ b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/04-tile-planner-and-cache.md @@ -0,0 +1,25 @@ +# 04 - Implement the virtual tile planner and bounded cache + +Status: resolved + +## Contract + +Plan visible guttered 1022-pixel-interior tiles from source coordinates and +physical display density. Coarsen until no more than seven visible tiles are +needed, then prefetch nearest neighbors. Cache generated tiles per source with a +64 MiB byte budget. + +## Acceptance + +Table tests cover fit/fill/zoom/pan/edge/HiDPI cases, deterministic slot order, +mixed levels, gutters, cache hits/eviction, and the seven-detail invariant. + +## Comments + +The planner/cache tests failed first on the absent plan, key, generator, and +budget. They now cover fit, HiDPI, zoom, sampler-forced coarsening, deterministic +nearest prefetch, exact level-zero gutters, hits, and 64 MiB eviction. The full +compare suite passes. The shader audit then exposed odd-dimension coarse-edge +gutter drift, prefetch-biased LRU promotion, unnecessary detail work below +overview density, and full-pane planning behind a swipe clip. Each received a +focused regression and now passes. diff --git a/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/05-shader-adapter.md b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/05-shader-adapter.md new file mode 100644 index 0000000..5532a9f --- /dev/null +++ b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/05-shader-adapter.md @@ -0,0 +1,27 @@ +# 05 - Implement the tiled shader adapter + +Status: resolved + +## Contract + +Render one overview and up to seven detail tiles with stable pane-specific +shader names. Update scalar uniforms for movement; update texture identities only +when source/tile bindings change. Supply equivalent desktop GLSL 110 and GLES +GLSL 100 programs, bilinear sampling, transparent bounds, and RGB unpremultiply. + +## Acceptance + +Structural tests lock declarations, sampler count, lookup/body equivalence, +stable identity, slot clearing, finest-match selection, and scene-to-uniform +mapping without requiring a GPU. + +## Comments + +The shader tests failed first on absent GLSL and adapter APIs. Both GLSL +variants now share one byte-identical body after their preambles, declare eight +samplers, select the finest matching detail over the overview, and unpremultiply +alpha. Adapter tests pin stable names/objects, fixed slots, geometry/uniform +mapping, texture reuse, and bounded clear behavior. Independent audit tests +also caught and fixed vertical texture inversion, sampler-slot shuffling, +excess GLES uniforms, raw-coordinate mediump hazards, and extreme-aspect +selection/division guards. diff --git a/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/06-async-tile-lifecycle.md b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/06-async-tile-lifecycle.md new file mode 100644 index 0000000..9410eef --- /dev/null +++ b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/06-async-tile-lifecycle.md @@ -0,0 +1,33 @@ +# 06 - Add asynchronous tile delivery and lifecycle control + +Status: resolved + +## Contract + +Run at most one cancellable tile worker per pane. Check source/view tokens +between bounded generations, publish through `UIQueue`, ignore stale results, +reuse cached tiles across Swap, and make `Settle` wait for load, vector, tile, +and queued follow-on work. + +## Acceptance + +Tests cover supersession, close, reopen, resize, vector replacement, swap/cache +reuse, queue ordering, worker count, and race-safe settlement. + +## Comments + +The initial async tests proved single-generator execution, stale-source +rejection, cache reuse, and settlement. Lifecycle audit then reproduced a +vector-replacement deadlock caused by waiting for obsolete tiles before +draining the UI completion that cancels them. `Settle` now drains causal work +first, reusable channel epochs replace cancellable WaitGroup helper goroutines, +tile publication is coalesced, and focused tests cover active clear/reopen, +stale views, vector replacement, active Swap, shutdown, and race settlement. + +The first native memory run exposed one more lifecycle cost: every same-source +view revision cancelled a tile after its destination buffer could already be +allocated. With a large decoded-image cache raising Go's collection goal, +discarded four-megabyte tile buffers accumulated between collections. A guard +was observed failing with an allocated tile discarded on view change. The +worker now finishes and caches that one immutable tile, then jumps directly to +the latest same-source plan; clear and source replacement still cancel. diff --git a/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/07-production-wiring.md b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/07-production-wiring.md new file mode 100644 index 0000000..5095bf1 --- /dev/null +++ b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/07-production-wiring.md @@ -0,0 +1,25 @@ +# 07 - Wire production rendering and migrate comparison tests + +Status: resolved + +## Contract + +Use tiled shader renderers in production side-by-side and swipe composition. +Migrate package and assembled UI assertions to the scene seam or shader state. +Preserve all existing comparison behavior while removing interaction-path owner +repaints and full-image canvas scaling. + +## Acceptance + +The complete compare package and affected assembled UI suites pass, including +fidelity, vectors, link/unlink, divider, swap, resize, cancellation, and 100-event +interaction guards. + +## Comments + +Production now constructs two tiled shader renderers while package tests retain +the private canvas reference adapter. Comparison and assembled UI assertions +were migrated to scenes or visible shader state. The complete compare package, +recursive comparison integration suite, physical Ctrl+D invocation, shutdown +guard, manual guards, and focused race suite pass. + diff --git a/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/08-profile-docs-and-gate.md b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/08-profile-docs-and-gate.md new file mode 100644 index 0000000..6ea169b --- /dev/null +++ b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/issues/08-profile-docs-and-gate.md @@ -0,0 +1,43 @@ +# 08 - Profile, document, and run the landing gate + +Status: resolved + +## Contract + +Update architecture/manual/TODO records, perform user-driven macOS side-by-side +and swipe samples on an exact unstripped working-tree build, check memory, and +run the native and final repository gates. + +## Acceptance + +The profile meets the plan thresholds; `make test-native`, documentation guards, +and one final `make verify` pass. Record that Windows/Linux runtime GPU behavior +was not exercised. + +## Comments + +The exact unstripped working-tree build was exercised by the user with the same +image pair as the baseline. Physical `Ctrl+D` opened comparison and both modes +were reported visually smooth. Separate 10-second samples measured: + +- side-by-side: 1.0 GiB sample footprint and 95.2% main-thread idle; +- swipe: 1.0 GiB sample footprint, 1.1 GiB process peak, and 95.7% + main-thread idle; +- no Catmull-Rom, `drawNRGBAOver`, or gesture-to-`ForceRepaint` stack in either + sample; +- Go's live heap stable at 267-271 MiB across repeated collections. + +The 1.1 GiB peak is below the 1.2 GiB acceptance ceiling. GPU runtime behavior +on Windows and Linux was not exercised. + +The post-fix native suite passed every package except for the existing +Darwin/arm64 antialiasing variance in the Copy Selection golden. That failure +differed at 629 boundary/text pixels, and its generated PNG was byte-for-byte +identical when the same test ran from the parent revision. The authoritative +Linux/amd64 gate first exposed that the shortcut test used duplicate constant +map keys on platforms where the native shortcut modifier is already Control. +After making the expected modifier set portable, the complete `internal/ui` +Linux/amd64 race and golden package passed in 662.436 seconds. The single +`make verify` invocation had already passed formatting, TUF, vet, build, and +every other race package, so the focused rerun completed coverage of the final +tree without spending a second full-gate run. diff --git a/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/spec.md b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/spec.md new file mode 100644 index 0000000..37f37f0 --- /dev/null +++ b/finished_refactorings/2026-09-01_compare-tiled-gpu-rendering/spec.md @@ -0,0 +1,49 @@ +# Spec: tiled GPU comparison rendering + +Status: approved + +## Problem + +Side-by-side and swipe pan/zoom recursively refresh the viewer canvas. Fyne then +performs smooth full-image resampling and texture recreation on the interactive +path. Native profiling shows that work, rather than transform arithmetic, is the +dominant CPU and memory cost. The running macOS app also lacks the physical +Ctrl+D comparison shortcut because Fyne's default shortcut modifier is Command. + +## User-visible contract + +1. Command+D and physical Ctrl+D both open comparison on macOS; platforms where + the default shortcut is already Control register the command once. +2. Side-by-side and swipe retain their current layout, clipping, divider, + linking, temporary-Control unlink, zoom, pan, swap, vector, and lifecycle + behavior. +3. Pan and zoom remain responsive under sustained input and do not briefly show + blank source regions while detail imagery is prepared. +4. Comparison opens only after both decoded sources have display-ready overview + imagery; the existing spinners communicate that wait. +5. Bilinear filtering is used at every scale. + +## Internal contract + +- A private scene-based pane renderer separates transform policy from Fyne's + canvas implementation. Production uses a tiled shader renderer; tests can use + a reference canvas renderer through the unexported constructor. +- Stable shaders receive scalar viewport/image/display uniforms, one overview, + and up to seven guttered detail textures. Interaction changes uniforms without + refreshing the viewer root. +- Immutable render sources own a bounded 64 MiB tile cache. Tile choice is a + deterministic pure plan based on visible source bounds and physical pixels. +- One cancellable worker per pane generates missing tiles. Tokens and queued UI + completions prevent stale source/view publication. `Settle` observes all work. +- Shader programs exist in equivalent GLSL 110 and GLSL 100 forms and correctly + handle transparent premultiplied source pixels. + +## Acceptance + +- Focused red/green tests for every ticket in `issues/`. +- `go test ./internal/ui/compare -count=1` and affected assembled UI tests pass. +- `make test-native` and documentation guards pass. +- Final `make verify` passes once. +- User-driven native profiles satisfy the thresholds in + `plans/2026-09-02-tiled-gpu-comparison-renderer.md`. + diff --git a/finished_refactorings/2026-09-01_comparison-link-button/issues/01-add-top-left-link-control.md b/finished_refactorings/2026-09-01_comparison-link-button/issues/01-add-top-left-link-control.md new file mode 100644 index 0000000..75fa970 --- /dev/null +++ b/finished_refactorings/2026-09-01_comparison-link-button/issues/01-add-top-left-link-control.md @@ -0,0 +1,35 @@ +# 01 - Add the top-left comparison link control + +Status: resolved + +## Contract + +Add a ready-gated Unlink/Link button and move the existing target-aware +Unlinked status into the same top-left translucent card. Keep the top-right +action card intact. Button and physical `Ctrl+L` must share +`compare.Feature.ToggleLink` and its readiness boundary. + +## Acceptance + +- [x] The top-left card, loading gate, and unchanged top-right card pass the + feature-level comparison test. +- [x] Button and physical `Ctrl+L` pass the assembled-viewer equivalence test. +- [x] Open and Swap restore the linked button/status state. +- [x] English and German strings and manuals describe the completed behavior. +- [x] Focused comparison suites and `make verify` pass. + +## Comments + +- 2026-09-02: Specification approved with `compare.Feature` and assembled + viewer as the confirmed TDD seams. Two mechanical sub-agents are limited to + transcribing one pre-designed red test each; production, strings, review, + fixes, and the final gate remain with the primary agent. +- 2026-09-02: Feature tracer first failed because the comparison overlay had + no Unlink button. The assembled-viewer tracer first failed because pre-ready + `Ctrl+L` entered the unlinked state and no Link button existed. +- 2026-09-02: Negative verification proved both new boundaries: removing the + readiness guard failed the shortcut tracer, and placing the status before + the button failed its geometry assertion. +- 2026-09-02: Focused compare, viewer, translation, and manual tests passed. + `make verify` then passed formatting/TUF checks, vet, build, and the complete + Linux/amd64 race suite. diff --git a/finished_refactorings/2026-09-01_comparison-link-button/spec.md b/finished_refactorings/2026-09-01_comparison-link-button/spec.md new file mode 100644 index 0000000..a47501e --- /dev/null +++ b/finished_refactorings/2026-09-01_comparison-link-button/spec.md @@ -0,0 +1,57 @@ +# Spec: comparison link button + +Status: approved + +## Problem + +Comparison pane linking is discoverable only through physical `Ctrl+L`, while +the resulting Unlinked status sits beside unrelated layout, Swap, and exit +actions at the top right. The comparison surface needs a visible pointer-driven +control whose state and availability exactly match the shortcut. + +## Decisions + +- A separate compact translucent card is anchored at the top left. It contains + the link-action button first and the existing Unlinked status immediately to + its right. +- The existing Swipe/Side by side, Swap, and Back to Grid card remains at the + top right and no longer contains the Unlinked status. +- The button names its next action: Unlink while linked, Link while unlinked. +- The button is visible but disabled until both comparison images are ready. + Physical `Ctrl+L` is also inert until that readiness boundary. +- The button and shortcut call the existing `compare.Feature.ToggleLink` + action. No parallel state or callback is introduced. +- Open, close/failure, and Swap restore linked state, the Unlink label, and a + hidden status. Existing Unlinked, Unlinked: Left, and Unlinked: Right status + behavior is retained. + +## Acceptance criteria + +1. The ready-gated button is in its own compact translucent top-left card while + the existing action card remains at the top right. + Verify: `go test ./internal/ui/compare -run '^TestCompareLinkControl_TopLeftCardAndReadyGate$' -count=1` +2. Button and physical `Ctrl+L` use the same readiness gate and toggle state, + label, and status together. + Verify: `go test ./internal/ui -run 'Compare(LinkControl|LinkToggle)' -count=1` +3. Target-aware status, new-session reset, and Swap reset remain correct. + Verify: `go test ./internal/ui/compare -run 'Compare(LinkControl|LinkToggle|OpenStartsLinked|SwapWhileUnlinked)' -count=1` +4. Link and Unlink are localized, both manuals document the control, and all + manual/translation guards pass. + Verify: `go test . -run '^TestTranslations_' -count=1` + Verify: `go test ./internal/ui/help -run '^TestManual' -count=1` +5. The repository formatting, vet, build, and race gate passes. + Verify: `make verify` + +## Non-goals + +- Changing comparison transform, pan, zoom, relink, or target-selection + semantics. +- Adding a preference, icon-only control, menu action, or exported API. +- Moving or otherwise changing the top-right comparison actions. +- Rewriting completed historical comparison plans. + +## Honest limit + +Placement is guarded with deterministic geometry and widget-state assertions, +not a new screenshot golden. This avoids coupling the control to Fyne's +software painter while still proving its edge, ordering, and card ownership. diff --git a/finished_refactorings/2026-09-02-comparison-link-button.md b/finished_refactorings/2026-09-02-comparison-link-button.md new file mode 100644 index 0000000..5d6ec3a --- /dev/null +++ b/finished_refactorings/2026-09-02-comparison-link-button.md @@ -0,0 +1,98 @@ +# Comparison link button + +Status: complete + +Route: Standard. This adds one discoverable comparison control across the +existing compare feature, assembled-viewer test seam, localization, manuals, +and architecture/release notes. It adds no package, dependency, preference, or +exported API. + +Deliverable: a ready-gated top-left Unlink/Link control shares the exact +`Ctrl+L` action and carries the existing target-aware Unlinked status beside +it, while the existing action toolbar stays at the top right. + +## Locked decisions + +- The button reads Unlink while linked and Link while unlinked. +- Button and physical `Ctrl+L` are inert until both sources are ready. +- The top-left card contains button then status; status remains hidden while + linked. +- Open, failure/close, and Swap reset to linked. Existing transform behavior is + unchanged. +- Tests use `compare.Feature` and the assembled viewer; no golden is added. + +## Tasks + +### Task 1 - Top-left chrome tracer + +Owner: T2 mechanical for the exact red test; T0 for review and green. + +Files: `internal/ui/compare/compare_test.go`, then +`internal/ui/compare/compare.go` and translation bundles. + +Test: the permanent compact/translucent left card, button-first ordering, +loading disablement, readiness enablement, and unchanged right action card. + +Verify: `go test ./internal/ui/compare -run '^TestCompareLinkControl_TopLeftCardAndReadyGate$' -count=1` + +Budget: one spawn, one T0 review round, no full suite. + +### Task 2 - Shared button and shortcut behavior + +Owner: T2 mechanical for the exact red integration test; T0 for review and +green. + +Files: `internal/ui/compare_test.go`, then link-state synchronization in +`internal/ui/compare/compare.go` and its package tests. + +Depends: Task 1. + +Test: loading-time `Ctrl+L` is inert; after readiness `Ctrl+L` reveals Link plus +Unlinked, and tapping Link restores Unlink plus hidden status. Open and Swap +reset the same state. + +Verify: `go test ./internal/ui -run 'Compare(LinkControl|LinkToggle)' -count=1` + +Budget: one spawn, one T0 review round, no full suite. + +### Task 3 - Documentation and landing + +Owner: T0 inline. + +Files: both manuals and their guard, `ARCHITECTURE.md`, `todos.md`, issue/spec, +and this plan. + +Depends: Tasks 1 and 2. + +Contract: document and localize the finished pointer/keyboard workflow, run +focused regressions, negatively verify the new guards, and record evidence. + +Verify: `go test . -run '^TestTranslations_' -count=1`, +`go test ./internal/ui/help -run '^TestManual' -count=1`, then `make verify` +once. + +Budget: zero spawns, at most two T0 review rounds, one full suite. + +Task graph: 1 -> 2 -> 3. Agent failures are fixed by T0 without respawning. + +## Cost ledger + +| Task | Spawns budget/actual | Review rounds | Full suite | Notes | +|---|---:|---:|---|---| +| T1 | 1 / 1 | 2 | no | Red tracer delegated; primary implemented and tightened the layout assertion. | +| T2 | 1 / 1 | 2 | no | Red tracer delegated; primary implemented and negatively verified the ready gate. | +| T3 | 0 / 0 | 2 | yes | Strings, manuals, architecture notes, focused regressions, and `make verify` passed. | + +## Outcome + +- Added a compact top-left card whose disabled-while-loading Unlink/Link button + and adjacent target-aware status share `compare.Feature.ToggleLink` with + physical `Ctrl+L`. +- Preserved the separate top-right layout, Swap, and Back to Grid action card. +- Retained linked resets for Open and Swap and documented the behavior in both + locales. +- Captured two permanent red-to-green tracers. Negative mutations separately + proved the readiness guard and button-before-status ordering. +- Final verification: focused comparison, translation, and manual suites + passed; `make verify` passed formatting/TUF checks, vet, build, and the full + Linux/amd64 race suite on 2026-09-02. diff --git a/finished_refactorings/2026-09-02-fix-unlinked-swipe-pointer-routing.md b/finished_refactorings/2026-09-02-fix-unlinked-swipe-pointer-routing.md new file mode 100644 index 0000000..b14ef6d --- /dev/null +++ b/finished_refactorings/2026-09-02-fix-unlinked-swipe-pointer-routing.md @@ -0,0 +1,78 @@ +# Fix unlinked Swipe pointer routing + +Status: complete + +Route: Standard. The diagnosed bug is confined to comparison input geometry, +but the fix spans the comparison package's behavior tests, implementation, and +standing documentation. It does not change render geometry, reveal clipping, +divider semantics, exported APIs, preferences, translations, or manuals. + +Deliverable: in Swipe layout, an unlinked pointer gesture targets the photo +revealed beneath the pointer, including after divider movement, while right-pane +wheel zoom remains anchored beneath the cursor in the full render viewport. + +## Task graph + +`01 -> 02 -> 03` + +### Task 01 - Route Swipe input by reveal + +Owner: T0 inline +Files: modify `internal/ui/compare/compare_test.go`, +`internal/ui/compare/transform.go`, and `internal/ui/compare/swipe.go` +Depends: none +Contract: add private `layoutPaneInput(index, input)` geometry driven by +`paneVisibleArea`; exercise it during pane and reveal layout without resizing +the renderer viewport. +Test: actual canvas hover, drag, wheel, and transform-key routing follows the +revealed pane at the default divider, after movement, and at both extremes. +Verify: `go test ./internal/ui/compare -run '^TestCompareSwipeUnlinkedCanvasRoutesPointerByReveal$' -count=1` +Budget: 0 spawns; 1 review round; full suite: no + +### Task 02 - Preserve the right wheel anchor + +Owner: T0 inline +Files: modify `internal/ui/compare/compare_test.go` and +`internal/ui/compare/input.go` +Depends: 01 +Contract: copy each non-nil scroll event and translate its reveal-local +position by the pane input origin before forwarding it; never mutate the +caller-owned event. +Test: the normalized right-photo point beneath a reveal-local cursor is stable +through wheel zoom; the event is unchanged and nil is inert. +Verify: `go test ./internal/ui/compare -run '^TestCompareSwipeUnlinkedRightWheelPreservesViewportAnchor$' -count=1` +Budget: 0 spawns; 1 review round; full suite: no + +### Task 03 - Document, review, and verify + +Owner: T0 inline +Files: modify `CONTEXT.md`, `ARCHITECTURE.md`, `todos.md`, the local spec and +tickets, and this plan +Depends: 01, 02 +Contract: record canonical terminology and the reveal/input invariant; run all +acceptance commands, negatively verify both guards, and finish with one +`make verify` invocation. +Test: package and assembled comparison regression suites plus both intentional +guard violations. +Verify: `make verify` +Budget: 0 spawns; 1 review round; full suite: yes, once + +## Delegation gate and cost ledger + +All tasks stay inline: the diagnosis, test seam, and implementation contract +are already hot context (G5), and every task touches files shared with the next. +Rule S has no useful mechanical transform here; Rule W favors applying the +already-specified small change directly. + +| Task | Spawns (budget/actual) | Review rounds | Full suite | Notes | +|---|---:|---:|---:|---| +| 01 | 0 / 0 | 1 | no | guard failed on original Right-over-left symptom, then passed | +| 02 | 0 / 0 | 1 | no | guard failed on anchor drift, then passed | +| 03 | 0 / 0 | 1 | yes | `make verify` passed on its only invocation | + +## Outcome + +The two TDD slices passed their focused and package-level commands, both guards +were negatively verified and restored, and the assembled comparison tests +remained green. `make verify` passed formatting, the offline TUF-root check, +vet, build, and the complete Linux/amd64 race suite. No subagents were used. diff --git a/finished_refactorings/2026-09-02-go-1-27-security-refresh.md b/finished_refactorings/2026-09-02-go-1-27-security-refresh.md new file mode 100644 index 0000000..aa2b7a0 --- /dev/null +++ b/finished_refactorings/2026-09-02-go-1-27-security-refresh.md @@ -0,0 +1,92 @@ +# Go 1.27.1 and security dependency refresh + +Status: complete + +Route: Standard. This changes the module graph, project tooling, contributor +requirements, and release notes without changing application behavior or +package architecture. + +Deliverable: PicFetch requires Go 1.27.1, uses Rekor's maintained OpenPGP +implementation, and runs a repository-pinned govulncheck successfully. + +## Locked decisions + +| Decision | Contract | +|---|---| +| Go baseline | `go.mod` and active setup documentation require Go 1.27.1. | +| OpenPGP remediation | Upgrade Rekor to v1.5.4 and accept only dependency changes required by that patch and module tidying. | +| Scanner | Pin `golang.org/x/vuln` v1.7.0 as a Go tool and invoke it with `go tool govulncheck`. | +| Residual advisory | Keep latest `golang.org/x/crypto` because PicFetch needs unaffected packages; do not suppress GO-2026-5932 or replace Sigstore. | +| Scope | No application code, package layout, architecture record, workflow, or historical plan changes. | + +## Tasks + +### Task 1 - Refresh the module and security tool graph + +Owner: T0 inline + +Files: `go.mod`, `go.sum` + +Upgrade the Go directive and Rekor, add the pinned govulncheck tool dependency, +and run `go mod tidy`. + +Verify: `go mod tidy -diff && go mod verify && go mod why golang.org/x/crypto/openpgp` + +### Task 2 - Use the pinned scanner + +Owner: T0 inline + +Files: `Makefile` + +Run govulncheck through `go tool` and remove its redundant global install. + +Verify: `make security-govulncheck` + +### Task 3 - Align active documentation and land the change + +Owner: T0 inline + +Files: `README.md`, `.github/CONTRIBUTING.md`, `todos.md`, this plan + +Document the Go baseline, pinned scanner workflow, Rekor remediation, and the +accepted module-only advisory. + +Verify: `make verify` + +## Budget and gate + +Zero spawns; at most two review rounds; one full suite. The final security gate +also runs `go tool govulncheck -show verbose ./...` and confirms that +GO-2026-5932 remains module-only with no imported package or reachable symbol. + +## Outcome + +PicFetch now requires Go 1.27.1. Rekor v1.5.4 removes the unmaintained +`x/crypto/openpgp` package from the dependency path, while govulncheck v1.7.0 +is pinned beside goimports and runs through `go tool` without a global binary. + +The scanner reports no reachable or imported vulnerabilities. GO-2026-5932 +remains visible only at module granularity because unaffected packages from the +latest `x/crypto` module are still required and the advisory has no fixed +version. + +## Cost ledger + +| Task | Spawns budget/actual | Review rounds | Full suite | Notes | +|---|---:|---:|---|---| +| T1 | 0 / 0 | 1 | no | Go, Rekor, govulncheck, and the tidied module graph complete. | +| T2 | 0 / 0 | 1 | no | Pinned scanner target and packaging-tool install contract complete. | +| T3 | 0 / 0 | 1 | no | Active setup docs and release notes aligned. | +| gate | - | - | - | yes | `make verify` passed. | + +## Verification record + +- `go mod tidy -diff` produced no diff, `go mod verify` passed, and `go mod + why golang.org/x/crypto/openpgp` reported that the main module does not need + the package. +- `make security-govulncheck` passed with zero reachable vulnerabilities. +- `go tool govulncheck -show verbose ./...` reported zero symbol and package + vulnerabilities and only GO-2026-5932 at module level (`Fixed in: N/A`). +- `make verify` passed formatting, TUF root validation, vet, build, and the + Linux/amd64 race suite (`internal/ui` 686.351s; `internal/ui/compare` + 28.099s). diff --git a/finished_refactorings/2026-09-02-inert-comparison-link-toggle.md b/finished_refactorings/2026-09-02-inert-comparison-link-toggle.md new file mode 100644 index 0000000..c6a8981 --- /dev/null +++ b/finished_refactorings/2026-09-02-inert-comparison-link-toggle.md @@ -0,0 +1,104 @@ +# Inert comparison link toggle + +Status: complete + +Route: Standard. This corrects one comparison interaction across +`internal/ui/compare`, its manuals, release notes, and architecture record. It +adds no package, dependency, preference, user-visible string, or external API. + +Deliverable: locking or unlocking comparison changes only which transform owns +future input; neither transition changes either photo's visible size or +position. + +## Locked decisions + +| Decision | Contract | +|---|---| +| Photo state | Each photo keeps its own position, scale, and fit/actual mode for the comparison session. | +| Camera state | Linked controls operate one shared overhead camera composed over both photo poses. | +| Link toggle | Physical `Ctrl+L` changes input ownership only. Unlock and relock are exact geometry no-ops. | +| Linked resets | `0` frames both current poses with one camera move; `1` returns the camera to its 1x home without rewriting photo poses. | +| Unlinked resets | `0` fits and centers only the target photo in the current camera; `1` shows only that photo at decoded-pixel size. | +| Bounds | A local photo or the shared camera may expose the table, but cannot move a photo completely past its pane center. | +| Existing transitions | Resize and layout preserve photo and camera state. Swap retains its explicit relink/reset behavior before exchanging sources. | + +## Tasks + +### Task 1 - Separate persistent photo poses from the shared camera + +Owner: T0 inline + +Files: `internal/ui/compare/compare.go`, `internal/ui/compare/transform.go`, +`internal/ui/compare/input.go`, `internal/ui/compare/compare_test.go` + +Test first through `compare.Feature`: unlock/relock geometry is exact, unlinked +input changes one photo, linked input moves both through one camera, camera fit +and home preserve divergent poses, movement remains bounded, and resize/layout +round trips retain the composed state. + +Verify: `go test ./internal/ui/compare -count=1` + +### Task 2 - Preserve assembled comparison behavior + +Owner: T0 inline + +Files: existing viewer and Favorites tests only; no production change expected. + +Verify: `go test ./internal/ui -run 'Compare' -count=1 && go test +./internal/ui/favorites -run 'Compare' -count=1`. The complete UI suite remains +part of the Linux/amd64 final gate because native golden rendering is not +authoritative. + +### Task 3 - Documentation and final gate + +Owner: T0 inline + +Files: `internal/ui/help/manual.md`, `internal/ui/help/manual_de.md`, +`internal/ui/help/manual_test.go`, `ARCHITECTURE.md`, `todos.md`, this plan. + +Document the photo/table/camera model and the differing linked/unlinked `0` and +`1` meanings. Preserve the superseded Ctrl+L plan as historical evidence. + +Verify: `go test ./... -run 'Translations|Manual|UnicodeArrows' -count=1` + +## Budget and gate + +Zero spawns; at most three review rounds; one full suite. Negatively verify the +exact relock guard before the final `make verify` run. + +## Outcome + +Comparison now composes two persistent photo transforms with one shared camera. +Unlocking and relocking change only input ownership, so both transitions retain +the exact rendered size and position of each photo. Unlinked input edits one +photo; linked zoom, pan, fit, and home move only the camera over the retained +arrangement. Camera-aware photo and camera bounds keep each image over its pane +center, while resize/layout round trips and the existing Swap reset semantics +remain intact. + +The manuals, their behavior guard, architecture map, and release notes now use +the same photo/table/camera model. + +## Cost ledger + +| Task | Spawns budget/actual | Review rounds | Full suite | Notes | +|---|---:|---:|---|---| +| T1 | 0 / 0 | 2 | no | Photo/camera split, inert toggles, composed controls, bounds, and component guards complete. | +| T2 | 0 / 0 | 1 | no | Assembled comparison and Favorites integration guards passed. | +| T3 | 0 / 0 | 1 | no | Manuals, manual guard, architecture, TODO, and plan records complete. | +| gate | - | - | - | yes | `make verify` passed. | + +## Verification record + +- `go test ./internal/ui/compare -count=1` passed. +- `go test ./internal/ui -run 'Compare' -count=1` and the matching Favorites + command passed. +- `go test ./... -run 'Translations|Manual|UnicodeArrows' -count=1` passed. +- The camera-offset local-bound guard first failed with the target photo wholly + beyond its pane center, then passed after the bound became camera-aware. +- A deliberate relock-time photo-transform overwrite made + `TestCompareLinkToggle_RelockKeepsDivergentPhotoPoses` fail with the expected + geometry change; the restored implementation passed the same exact guard. +- `make verify` passed formatting, TUF root validation, vet, build, and the + Linux/amd64 race suite (`internal/ui` 679.263s; + `internal/ui/compare` 17.186s). diff --git a/finished_refactorings/2026-09-02-tiled-gpu-comparison-renderer.md b/finished_refactorings/2026-09-02-tiled-gpu-comparison-renderer.md new file mode 100644 index 0000000..d9305eb --- /dev/null +++ b/finished_refactorings/2026-09-02-tiled-gpu-comparison-renderer.md @@ -0,0 +1,116 @@ +# Tiled GPU comparison renderer + +Status: complete + +Route: Deep. Profiling found a cross-cutting rendering defect: every comparison +pan and zoom refreshes the viewer root, causing Fyne to discard and rebuild +smoothly scaled image textures on the UI/render thread. The change introduces a +private rendering seam, asynchronous source/tile preparation, bounded caches, +and portable shader contracts. No package, dependency, preference, or exported +API is added. + +Deliverable: side-by-side and swipe comparison pan/zoom by changing stable +shader uniforms. Source imagery is uploaded through a bounded overview plus +visible detail tiles, and both Command+D and physical Ctrl+D open comparison on +macOS. + +## Evidence and locked decisions + +- A 10-second native sample of the exact feature-branch binary attributed the + interaction stall to Fyne Catmull-Rom image scaling (`scaleY_RGBA64Image_Src`, + `scaleX_YCbCr420`, and `drawNRGBAOver`), reached through + `paneInput.Dragged -> panBy -> viewer.ForceRepaint -> Container.Refresh`. +- Comparison owns one renderer per pane. Existing Fyne clip/reveal geometry and + input routing remain authoritative for side-by-side and swipe layouts. +- `compare.New` keeps its public signature. An unexported constructor accepts a + renderer factory so package tests can use a deterministic canvas reference + renderer while production uses shaders. +- Each production renderer has a stable pane-specific shader name, one overview + sampler, and seven detail samplers. Desktop GLSL 110 and GLES GLSL 100 stay + structurally equivalent. Tile coordinates are normalized and use six scalar + values per detail, keeping the fragment-uniform contract below the GLES 2 + minimum while avoiding mediump overflow on raw source coordinates. +- Detail textures are at most 1024 by 1024 pixels: a 1022-pixel interior with a + one-pixel sampling gutter. The overview's long edge is at most 1024 pixels. +- The immutable decoded frame remains canonical. Detail mips are generated once + in cancellable background work and cached per source in a 64 MiB byte cache. +- The overview always covers the source. Missing or stale details therefore + reduce sharpness temporarily but never create blank regions. +- The tile planner selects a power-of-two level from source pixels per physical + display pixel, coarsening until the visible set fits seven samplers, then uses + spare slots for nearest-neighbor prefetch. +- Initial readiness means decoded sources and their overview textures are ready. + Existing spinners remain visible until then. SVG reraster publication follows + the same display-ready-source rule. +- At most one tile worker runs per pane. Source/view tokens reject stale work; + all worker completions enter through the feature's `UIQueue`; `Settle` waits + for load, vector, and tile work. +- Pan, zoom, resize, and tile publication update only pane renderers. Owner + repaint remains for comparison open/close and surrounding chrome lifecycle. +- Bilinear GPU sampling is always used. Shader output unpremultiplies nonzero + RGBA samples to match Fyne's shader blending contract. +- Clearing a pane replaces all fixed texture slots with transparent one-pixel + placeholders. Fyne may retain the most recent fixed GL textures until reuse or + app exit, but the retained set is bounded. +- Real GPU acceptance is macOS-only. Portable tests cover planning, lifecycle, + shader structure, and builds; Windows/Linux runtime GPU behavior is explicitly + unverified in this change. Fyne's software test painter does not implement + `canvas.Shader`; comparison therefore requires the native GL painter and + deterministic software-renderer tests use the private reference adapter. + +Non-goals: the normal single-image viewer, grid thumbnails, a user-configurable +tile budget, changing comparison command ownership, and GPU-specific runtime +verification on Windows or Linux. + +## Bite-sized execution + +| Ticket | Slice | Verification boundary | +|---|---|---| +| 01 | Shortcut parity and interaction repaint guard | UI shortcut tests plus 100 pan/zoom events | +| 02 | Renderer/scene seam | Reference renderer scene and geometry tests | +| 03 | Display-ready immutable sources | Overview/source preparation and readiness tests | +| 04 | Virtual tile planner and bounded cache | Pure planner/cache table tests | +| 05 | Desktop/GLES shader adapter | Structural shader and uniform/texture tests | +| 06 | Async tile delivery and lifecycle | cancellation, stale, settle, and swap tests | +| 07 | Production wiring and test migration | compare and assembled UI suites | +| 08 | Native profile, docs, and landing gate | native sample, memory check, `make verify` once | + +Task graph: 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08. Every behavior +slice is first observed red against absent behavior or an intentional local +mutation, then made green and refactored before the next slice. + +## Native performance acceptance + +Build an unstripped binary from the final working tree and collect separate +10-second samples while the user continuously pans/zooms in side-by-side and +swipe mode with the same source images used for the baseline. + +- No main-thread Catmull-Rom image scaling during steady-state interaction. +- No gesture-to-`viewer.ForceRepaint` stack during steady-state interaction. +- Texture uploads occur for source/mip/tile changes, not every pointer event. +- Input does not build a visible interaction backlog. +- Peak physical footprint is at most 1.2 GiB, versus the measured ~2.0 GiB + baseline. + +Accepted native result, 2026-09-02: the user reported both modes visually +smooth. The side-by-side sample was 95.2% main-thread idle at 1.0 GiB; the swipe +sample was 95.7% main-thread idle at 1.0 GiB, with a 1.1 GiB process peak. Both +were free of Catmull-Rom, `drawNRGBAOver`, and gesture-to-`ForceRepaint` stacks. +The live Go heap remained 267-271 MiB across repeated collections. Physical +`Ctrl+D` was exercised to open the comparison. Windows and Linux GPU runtime +behavior remains unverified as planned. + +## Ownership and cost ledger + +Implementation and integration ownership remains with the primary agent because +renderer state, Fyne lifecycle, test migration, and profiling share hot context. +At the user's request, three read-only subagents independently audited shader, +lifecycle, and integration concerns; their findings were reproduced red and +fixed by the primary agent. Review budget: one local review after each ticket, +the three parallel audits, and one final review. Full verification budget: one +`make verify`, in ticket 08 only. + +| Work | Spawns (budget/actual) | Review rounds | Full suite | Notes | +|---|---:|---:|---:|---| +| Tickets 01-07 | 3 / 3 | per ticket + 3 audits | no | user-requested shader, lifecycle, and integration audits | +| Ticket 08 | 0 / 0 | 1 | yes | one full-gate invocation; one focused Linux UI race rerun completed the portable test fix | diff --git a/go.mod b/go.mod index 6940ef6..14c42de 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/frathe/picfetch -go 1.26.7 +go 1.27.1 require ( fyne.io/fyne/v2 v2.8.0 @@ -25,7 +25,6 @@ require ( github.com/FyshOS/fancyfs v0.0.1 // indirect github.com/anthonynsimon/bild v0.17.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect @@ -41,29 +40,25 @@ require ( github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v0.25.5 // indirect + github.com/go-openapi/analysis v0.26.0 // indirect github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/go-openapi/jsonreference v1.0.0 // indirect - github.com/go-openapi/loads v0.25.0 // indirect + github.com/go-openapi/loads v0.25.1 // indirect github.com/go-openapi/runtime v0.33.0 // indirect github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect github.com/go-openapi/spec v0.22.9 // indirect github.com/go-openapi/strfmt v0.27.0 // indirect - github.com/go-openapi/swag v0.26.1 // indirect - github.com/go-openapi/swag/cmdutils v0.27.0 // indirect - github.com/go-openapi/swag/conv v0.27.3 // indirect - github.com/go-openapi/swag/fileutils v0.27.3 // indirect - github.com/go-openapi/swag/jsonname v0.26.1 // indirect - github.com/go-openapi/swag/jsonutils v0.27.3 // indirect - github.com/go-openapi/swag/loading v0.27.3 // indirect - github.com/go-openapi/swag/mangling v0.27.3 // indirect - github.com/go-openapi/swag/netutils v0.27.0 // indirect - github.com/go-openapi/swag/pools v0.27.3 // indirect - github.com/go-openapi/swag/stringutils v0.27.3 // indirect - github.com/go-openapi/swag/typeutils v0.27.3 // indirect - github.com/go-openapi/swag/yamlutils v0.27.3 // indirect - github.com/go-openapi/validate v0.26.1 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/fileutils v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/go-openapi/validate v0.26.3 // indirect github.com/go-text/render v0.2.1 // indirect github.com/go-text/typesetting v0.3.4 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -88,7 +83,7 @@ require ( github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sigstore/protobuf-specs v0.5.1 // indirect - github.com/sigstore/rekor v1.5.3 // indirect + github.com/sigstore/rekor v1.5.4 // indirect github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect @@ -111,11 +106,15 @@ require ( golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect + golang.org/x/vuln v1.7.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260727163830-6c54dddc4772 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc // indirect - google.golang.org/grpc v1.82.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect k8s.io/klog/v2 v2.140.0 // indirect ) -tool golang.org/x/tools/cmd/goimports +tool ( + golang.org/x/tools/cmd/goimports + golang.org/x/vuln/cmd/govulncheck +) diff --git a/go.sum b/go.sum index 82f9800..d6f7e07 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= @@ -6,12 +8,12 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= -cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= -cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= -cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= -cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= -cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/iam v1.13.0 h1:ufT3FPT5rFFXu6UtLkNoxaOaV5EuA1dsSkmemCSTo6U= +cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0= +cloud.google.com/go/kms v1.33.0 h1:pG0X78m212b2pv9N4fdMoUO69LuZGQ9kSvn8sHBOFAo= +cloud.google.com/go/kms v1.33.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= @@ -24,60 +26,58 @@ fyne.io/x/fyne v0.0.0-20260712112324-6989f2f174fb h1:oa8Pqo2Xis8dWEB5sQRLL4hbwB8 fyne.io/x/fyne v0.0.0-20260712112324-6989f2f174fb/go.mod h1:UzabvSVT4msa76BU2Mw95m8i3yThjwhZyugBZ5Wh0hs= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg= github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A= -github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= -github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/anthonynsimon/bild v0.17.0 h1:kmPJOZpUgvrM2uoS7KMesbDU5+QTvZ92zIBjHM/oa18= github.com/anthonynsimon/bild v0.17.0/go.mod h1:ULwssBwK+8dLFObGrI3fIydKYQ2l454Y0eo+mqPclto= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= -github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= -github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= -github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= -github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= -github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= -github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= -github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI= +github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk= +github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= +github.com/aws/aws-sdk-go-v2/service/kms v1.55.0 h1:uB8ymkVosyourmGXCZHyWhJ4wuKA4xq3ii2dVMPtBZY= +github.com/aws/aws-sdk-go-v2/service/kms v1.55.0/go.mod h1:rK4RITSY/qJw3qVJ7p19fceOWuvrisqqOChFkX05n5I= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -86,14 +86,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= -github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= -github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= @@ -121,8 +123,8 @@ github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/gen2brain/avif v0.6.0 h1:/8WSgcU+IEF0jhKYsUZ/mzlziFuTeJFpIKBj2siTQps= github.com/gen2brain/avif v0.6.0/go.mod h1:QgrYqdVE9y40PCfArK9VakcMIpYeDYpZmCSLkW6C1n8= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI= github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g= @@ -134,16 +136,16 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= -github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= +github.com/go-openapi/analysis v0.26.0 h1:1xECln1iMMmQnTjgcknC1vi1hA4KISt6IHpSwnqcuwI= +github.com/go-openapi/analysis v0.26.0/go.mod h1:40gERFi/2dyXA1FaqRRLxkv1IlC6X+GPDNd1xrYAjZE= github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= -github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= -github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= +github.com/go-openapi/loads v0.25.1 h1:toKQdIDLxlqfKLLGUUmUsiTd5/X0Chzvde9EGYQP/Ac= +github.com/go-openapi/loads v0.25.1/go.mod h1:33Hen4tsKXHL45TyYojvfD5fZUFN4O1y4r/XhsRW2zc= github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU= github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw= github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= @@ -153,39 +155,32 @@ github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQ github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= -github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= -github.com/go-openapi/swag/cmdutils v0.27.0 h1:aIKiqhB29AaP+7xm8/CPg3uOpeHx2SUp6TvMpu/a31Y= -github.com/go-openapi/swag/cmdutils v0.27.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU= -github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ= -github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE= -github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= -github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= -github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= -github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc= -github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= -github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0= -github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE= -github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg= -github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= -github.com/go-openapi/swag/netutils v0.27.0 h1:lEUG+hHvPvLggB3A8snFk0IRKNf9uC0YKc+7WYqvAF8= -github.com/go-openapi/swag/netutils v0.27.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= -github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk= -github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= -github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI= -github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= -github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4= -github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs= -github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= +github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= -github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= +github.com/go-openapi/validate v0.26.3 h1:OkfZgLvLDnGP2hrRGD+42WBiPWWkoHomTJ+IVI+KaDc= +github.com/go-openapi/validate v0.26.3/go.mod h1:7DOOa4raU6NRe7A8VQSKbm3VcuUIioREYHFt+er9Sk8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg= @@ -204,24 +199,29 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= -github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= -github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= -github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k= +github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= -github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= @@ -246,8 +246,8 @@ github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= -github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= -github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= @@ -260,8 +260,8 @@ github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wH github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= -github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= -github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -294,9 +294,8 @@ github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIH github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= -github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= -github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= -github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/sassoftware/relic/v8 v8.2.0 h1:9/L4S4I6an/JsPNhmcTpqfiOIsLb/iJaePZD1xR+ulE= +github.com/sassoftware/relic/v8 v8.2.0/go.mod h1:pZy7hLT9WCOKPonV8G/fplvtBLOZd6/kWtKqeHR6nKc= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= @@ -305,22 +304,22 @@ github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= -github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= -github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor v1.5.4 h1:A9zITTrkRwO+8lfq7L/gBR7rDHHcBQJRI+a0JQo4xDM= +github.com/sigstore/rekor v1.5.4/go.mod h1:0bcUHhZPlG+RVW1dI/7sdBp0MiVWVzk9h0tgOH/o0Yc= github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= github.com/sigstore/sigstore v1.10.9 h1:7Dcpt+ibnltHQZ8XhaU0dFmhHaf/T491eJfA9WDex4Y= github.com/sigstore/sigstore v1.10.9/go.mod h1:LYW9+qH7bK8wZmLm6lPxIC5lkHtkJDCgkqjChzTAIBs= github.com/sigstore/sigstore-go v1.3.0 h1:hnIMHREyCNTYFtOE1o7ae3Axa9B5W5EjUSBJICP2NBE= github.com/sigstore/sigstore-go v1.3.0/go.mod h1:AyRQXfpH89py1twjE3kEZxlRersng90GSYqQV9zGJE8= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.9 h1:2NWAU/utJRwzy1OrQurlY3wlDSynyH1rNJS5GgFchrg= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.9/go.mod h1:2shgYiPHTzeb5NNEu6okco+LOojMS5n9p/5Wdp0jhGI= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.9 h1:zcg/EG6JW/8RSx9T2g5j6zqxplaE4dymmhiFflDE6ks= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.9/go.mod h1:e7QngtHib0McuP1C/+Dt9f/7a8ic8FXvPB75OQzWgU4= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.9 h1:DG8R4orWDs3l9trp70fLRTgazedEDoAIo/cHEN7Qo0k= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.9/go.mod h1:d6oIwQ0YiYHMcFO4xqFcAx5njXwIf0dm5KfdytOar8A= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.9 h1:V+spyeSHypvLHSOvPMT+V72dxDlS0LDkDd6lwCsUOXM= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.9/go.mod h1:o7u5bQNLqpDQFaST29dpo6P6ihMV+wicwqfMmMW6H3c= github.com/sigstore/timestamp-authority/v2 v2.1.3 h1:Fc+LjCTfik1lh3YLkaosENfkXa3R2Y1nswiUKutBdFA= github.com/sigstore/timestamp-authority/v2 v2.1.3/go.mod h1:myoFOKJB/u5vNTFwvBBJVkG3NnOBeIJevbfjNeasLjo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -341,8 +340,8 @@ github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= -github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0 h1:3s6YMgMOBZRU8qG6ybpKSF2Sau+y3sMvxR911M59SwA= -github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0/go.mod h1:X8UNvbQu2wanAGa8ixRUU/DWt1V2hUBfvPGy6s9nE2s= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.4.0 h1:xpI5pnEQ0erFCS3JmcEl7Blluo8ZieEPNOURqy9uD70= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.4.0/go.mod h1:wDDAhjfd1t4TjeJCSsmFK7CARPUs/ITi16ZZ8kmAaFQ= github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= github.com/tink-crypto/tink-go/v2 v2.7.0 h1:k7QnUXJ1cRDpvoy/5l1FimZqMAArRff8vjUqzi5N04o= @@ -355,8 +354,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zU github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= -github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= @@ -373,8 +372,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= -go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= +go.step.sm/crypto v0.87.0 h1:+u0pDz1OV9M/SswA6KtcQ3GSrYMMelbL//0W/ceJVYI= +go.step.sm/crypto v0.87.0/go.mod h1:gBr1mpMiKs5804/Yt03uAoOBiw3DFxARZnGUKEUO82M= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -409,25 +408,31 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/vuln v1.7.0 h1:4MQBuhmXbz2uepNJrf3v+aaZLGDqw1JluwYboegA1qg= +golang.org/x/vuln v1.7.0/go.mod h1:Xw7zvU3e1bsCYYBXu+w4wcn2Kgn27f34WBCTw8LL5Us= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= -google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/api v0.290.0 h1:eMw0Xo+IfbbMlKmW7aHvpyQRv9RCXuWx/vs8AD+0x9A= +google.golang.org/api v0.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA= google.golang.org/genproto/googleapis/api v0.0.0-20260727163830-6c54dddc4772 h1:4namukbyF7JY83aWHQwi9J5ugNTnDReLJ9ZcpqOpRB4= google.golang.org/genproto/googleapis/api v0.0.0-20260727163830-6c54dddc4772/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc h1:3TtNq/QbJNrSY1nVdjcikfBw6ujnaNbdrd88wNr1OW4= google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= -software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= -software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/ui/build.go b/internal/ui/build.go index ebcbd45..6eaf4f6 100644 --- a/internal/ui/build.go +++ b/internal/ui/build.go @@ -193,6 +193,9 @@ func buildViewer(application fyne.App, startup startupState) (*viewer, fyne.Wind window.Canvas().SetOnTypedRune(func(r rune) { view.handleTypedRune(r) }) + if desktopCanvas, ok := window.Canvas().(comparisonKeyDownCanvas); ok { + wireComparisonLinkToggleHook(desktopCanvas, view) + } wireGlobalShortcuts(window.Canvas(), view) diff --git a/internal/ui/compare.go b/internal/ui/compare.go index 3d095b3..265ffbd 100644 --- a/internal/ui/compare.go +++ b/internal/ui/compare.go @@ -50,6 +50,7 @@ func (v *viewer) compareSelected() { } sources[i] = v.FileAt(index) } + v.Unfocus() v.compare.Open(sources) } diff --git a/internal/ui/compare/async_tile_test.go b/internal/ui/compare/async_tile_test.go new file mode 100644 index 0000000..570fd40 --- /dev/null +++ b/internal/ui/compare/async_tile_test.go @@ -0,0 +1,466 @@ +package compare + +import ( + "context" + "image" + "sync" + "sync/atomic" + "testing" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/storage" + fynetest "fyne.io/fyne/v2/test" + + "github.com/frathe/picfetch/internal/imaging" + "github.com/frathe/picfetch/internal/uitest" +) + +func asyncTileScene(source *renderSource, x float32) paneScene { + return paneScene{ + source: source, + viewport: fyne.NewSize(400, 400), + imagePosition: fyne.NewPos(x, -824), + imageSize: fyne.NewSize(2048, 2048), + displaySize: image.Pt(2048, 2048), + } +} + +func waitForTileStart(t *testing.T, started <-chan struct{}) { + t.Helper() + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for tile worker") + } +} + +func waitForRenderer(t *testing.T, renderer paneRenderer) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := renderer.Wait(ctx); err != nil { + t.Fatalf("renderer Wait: %v", err) + } +} + +func TestShaderPaneRenderer_RapidScenesUseAtMostOneTileWorker(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + source, err := prepareRenderSource(context.Background(), image.NewRGBA(image.Rect(0, 0, 2048, 2048))) + if err != nil { + t.Fatalf("prepareRenderSource: %v", err) + } + renderer := newShaderPaneRenderer(0).(*shaderPaneRenderer) + queue := &uitest.UIQueue{} + renderer.queueUI = queue.Do + started := make(chan struct{}, 1) + release := make(chan struct{}) + var active atomic.Int64 + var maximum atomic.Int64 + var once sync.Once + renderer.generateTile = func(ctx context.Context, source *renderSource, key tileKey) (*renderTile, error) { + current := active.Add(1) + defer active.Add(-1) + for { + seen := maximum.Load() + if current <= seen || maximum.CompareAndSwap(seen, current) { + break + } + } + once.Do(func() { started <- struct{}{}; <-release }) + return generateRenderTile(ctx, source, key) + } + + renderer.Present(asyncTileScene(source, -824)) + waitForTileStart(t, started) + latest := asyncTileScene(source, -824) + for i := range 100 { + latest = asyncTileScene(source, -float32((i*173)%1600)) + renderer.Present(latest) + } + close(release) + waitForRenderer(t, renderer) + for queue.Drain() { + } + if got := maximum.Load(); got != 1 { + t.Errorf("concurrent tile generators = %d, want exactly 1", got) + } + if got := active.Load(); got != 0 { + t.Errorf("active tile generators after Wait = %d, want 0", got) + } + want := make(map[tileKey]bool) + for _, request := range planTiles(latest).requests { + want[request.key] = true + } + for slot, tile := range renderer.bound { + if tile != nil && !want[tile.key] { + t.Errorf("slot %d retained stale-view tile %+v; latest plan is %+v", slot, tile.key, want) + } + } +} + +func TestShaderPaneRenderer_SameSourceViewChangeDoesNotCancelAllocatedTile(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + source, err := prepareRenderSource(context.Background(), image.NewRGBA(image.Rect(0, 0, 4096, 2048))) + if err != nil { + t.Fatalf("prepare source: %v", err) + } + renderer := newShaderPaneRenderer(0).(*shaderPaneRenderer) + queue := &uitest.UIQueue{} + renderer.queueUI = queue.Do + first := asyncTileScene(source, 0) + latest := asyncTileScene(source, -1600) + latestPlan := planTiles(latest) + if sameTileRequests(planTiles(first).requests, latestPlan.requests) { + t.Fatal("fixture view changes produced identical tile requests") + } + + type allocatedWork struct { + ctx context.Context + key tileKey + tile *renderTile + err error + } + allocated := make(chan allocatedWork, 1) + release := make(chan struct{}) + var once sync.Once + renderer.generateTile = func(ctx context.Context, source *renderSource, key tileKey) (*renderTile, error) { + blocked := false + var tile *renderTile + var generateErr error + once.Do(func() { + blocked = true + tile, generateErr = generateRenderTile(context.Background(), source, key) + allocated <- allocatedWork{ctx: ctx, key: key, tile: tile, err: generateErr} + }) + if blocked { + <-release + return tile, generateErr + } + return generateRenderTile(ctx, source, key) + } + + renderer.Present(first) + var work allocatedWork + select { + case work = <-allocated: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for allocated tile work") + } + if work.err != nil || work.tile == nil { + close(release) + waitForRenderer(t, renderer) + t.Fatalf("allocate first tile: tile=%v err=%v", work.tile, work.err) + } + renderer.Present(latest) + contextErr := work.ctx.Err() + close(release) + waitForRenderer(t, renderer) + for queue.Drain() { + } + if contextErr != nil { + t.Errorf("same-source view change canceled allocated tile work: %v", contextErr) + } + if !source.tiles.Contains(work.key.cacheKey()) { + t.Error("allocated same-source tile was discarded instead of cached") + } + wanted := make(map[tileKey]bool, len(latestPlan.requests)) + for _, request := range latestPlan.requests { + wanted[request.key] = true + if !source.tiles.Contains(request.key.cacheKey()) { + t.Errorf("latest-view tile %+v was not completed", request.key) + } + } + bound := 0 + for slot, tile := range renderer.bound { + if tile == nil { + continue + } + bound++ + if !wanted[tile.key] { + t.Errorf("slot %d retained tile %+v outside latest plan", slot, tile.key) + } + } + if bound == 0 { + t.Error("latest view completed without binding a detail tile") + } +} + +func TestShaderPaneRenderer_CoalescesTilePublicationWhileUIQueueIsHeld(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + source, err := prepareRenderSource(context.Background(), image.NewRGBA(image.Rect(0, 0, 4096, 4096))) + if err != nil { + t.Fatalf("prepare source: %v", err) + } + scene := paneScene{ + source: source, + viewport: fyne.NewSize(1000, 1000), + imageSize: fyne.NewSize(4096, 4096), + displaySize: image.Pt(4096, 4096), + } + if got := len(planTiles(scene).requests); got <= 1 { + t.Fatalf("publication fixture requests %d tiles, want several", got) + } + renderer := newShaderPaneRenderer(0).(*shaderPaneRenderer) + queue := &uitest.UIQueue{} + renderer.queueUI = queue.Do + renderer.Present(scene) + waitForRenderer(t, renderer) + if got := queue.Len(); got != 1 { + t.Errorf("queued tile publications = %d, want one coalesced callback", got) + } + for queue.Drain() { + } +} + +func TestShaderPaneRenderer_ClearCancelsActiveTileGeneration(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + source, err := prepareRenderSource(context.Background(), image.NewRGBA(image.Rect(0, 0, 2048, 2048))) + if err != nil { + t.Fatalf("prepare source: %v", err) + } + renderer := newShaderPaneRenderer(0).(*shaderPaneRenderer) + started := make(chan struct{}) + canceled := make(chan struct{}) + var once sync.Once + renderer.generateTile = func(ctx context.Context, _ *renderSource, _ tileKey) (*renderTile, error) { + once.Do(func() { close(started) }) + <-ctx.Done() + close(canceled) + return nil, ctx.Err() + } + + renderer.Present(asyncTileScene(source, -824)) + waitForTileStart(t, started) + renderer.Present(paneScene{}) + select { + case <-canceled: + case <-time.After(time.Second): + t.Fatal("clearing the pane did not cancel active tile generation") + } + waitForRenderer(t, renderer) + if renderer.shader.Visible() { + t.Fatal("cleared shader remained visible") + } +} + +func TestShaderPaneRenderer_StaleSourceCannotBindAndLatestSourceCompletes(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + oldSource, err := prepareRenderSource(context.Background(), image.NewRGBA(image.Rect(0, 0, 2048, 2048))) + if err != nil { + t.Fatalf("prepare old source: %v", err) + } + newSource, err := prepareRenderSource(context.Background(), image.NewRGBA(image.Rect(0, 0, 2048, 2048))) + if err != nil { + t.Fatalf("prepare new source: %v", err) + } + renderer := newShaderPaneRenderer(0).(*shaderPaneRenderer) + queue := &uitest.UIQueue{} + renderer.queueUI = queue.Do + oldStarted := make(chan struct{}, 1) + releaseOld := make(chan struct{}) + var oldTile *renderTile + var once sync.Once + renderer.generateTile = func(ctx context.Context, source *renderSource, key tileKey) (*renderTile, error) { + if source == oldSource { + once.Do(func() { oldStarted <- struct{}{}; <-releaseOld }) + tile, generateErr := generateRenderTile(context.Background(), source, key) + oldTile = tile + return tile, generateErr + } + return generateRenderTile(ctx, source, key) + } + + renderer.Present(asyncTileScene(oldSource, -824)) + waitForTileStart(t, oldStarted) + renderer.Present(paneScene{}) + if renderer.shader.Visible() { + t.Fatal("renderer remained visible between old and reopened sources") + } + renderer.Present(asyncTileScene(newSource, -824)) + close(releaseOld) + waitForRenderer(t, renderer) + for queue.Drain() { + } + if renderer.shader.Textures["overview"] != newSource.overview { + t.Fatal("stale worker replaced the latest overview") + } + for slot, tile := range renderer.bound { + if tile == oldTile && oldTile != nil { + t.Errorf("stale old-source tile bound in slot %d", slot) + } + } + if newSource.tiles.Len() == 0 { + t.Fatal("latest source produced no cached detail tiles") + } +} + +func TestShaderPaneRenderer_ClearCancelsWorkAndCachedSourceIsReusable(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + source, err := prepareRenderSource(context.Background(), image.NewRGBA(image.Rect(0, 0, 2048, 2048))) + if err != nil { + t.Fatalf("prepare source: %v", err) + } + renderer := newShaderPaneRenderer(0).(*shaderPaneRenderer) + queue := &uitest.UIQueue{} + renderer.queueUI = queue.Do + var generated atomic.Int64 + renderer.generateTile = func(ctx context.Context, source *renderSource, key tileKey) (*renderTile, error) { + generated.Add(1) + return generateRenderTile(ctx, source, key) + } + scene := asyncTileScene(source, -824) + renderer.Present(scene) + waitForRenderer(t, renderer) + for queue.Drain() { + } + firstCount := generated.Load() + if firstCount == 0 { + t.Fatal("initial scene generated no detail tiles") + } + + renderer.Present(paneScene{}) + waitForRenderer(t, renderer) + if renderer.shader.Visible() { + t.Fatal("clear left shader visible") + } + renderer.Present(scene) + waitForRenderer(t, renderer) + for queue.Drain() { + } + if got := generated.Load(); got != firstCount { + t.Errorf("reopening cached source generated %d tiles total, want unchanged %d", got, firstCount) + } + if renderer.shader.Textures["overview"] != source.overview { + t.Fatal("reopened source did not restore its overview") + } +} + +func TestCompareSettle_DrainsShaderTilesAndSwapReusesSourceCaches(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + var renderers [2]*shaderPaneRenderer + feature := newFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return &imaging.LoadedImage{Frames: []image.Image{image.NewRGBA(image.Rect(0, 0, 2048, 2048))}}, nil + }, Callbacks{}, func(index int) paneRenderer { + renderer := newShaderPaneRenderer(index).(*shaderPaneRenderer) + renderers[index] = renderer + return renderer + }) + var generated atomic.Int64 + for _, renderer := range renderers { + renderer.generateTile = func(ctx context.Context, source *renderSource, key tileKey) (*renderTile, error) { + generated.Add(1) + return generateRenderTile(ctx, source, key) + } + } + queue := &uitest.UIQueue{} + feature.SetUIQueue(queue) + feature.vectorPixels = func(fyne.CanvasObject, fyne.Size) (int, int) { return 2048, 2048 } + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := feature.Settle(ctx); err != nil { + t.Fatalf("Settle after Open: %v", err) + } + if queue.Len() != 0 { + t.Fatalf("UI queue after Settle = %d, want empty", queue.Len()) + } + firstCount := generated.Load() + if firstCount == 0 { + t.Fatal("shader feature generated no tiles") + } + + feature.swapSides() + if err := feature.Settle(ctx); err != nil { + t.Fatalf("Settle after Swap: %v", err) + } + if got := generated.Load(); got != firstCount { + t.Errorf("Swap generated %d tiles total, want cached count %d", got, firstCount) + } +} + +func TestCompareSwap_DuringTileWorkReusesCompletedSourceCache(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + var renderers [2]*shaderPaneRenderer + feature := newFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + width := 2048 + if uri.Name() == "right.png" { + width = 3072 + } + return &imaging.LoadedImage{Frames: []image.Image{image.NewRGBA(image.Rect(0, 0, width, 2048))}}, nil + }, Callbacks{}, func(index int) paneRenderer { + renderer := newShaderPaneRenderer(index).(*shaderPaneRenderer) + renderers[index] = renderer + return renderer + }) + feature.vectorPixels = func(fyne.CanvasObject, fyne.Size) (int, int) { return 3072, 2048 } + queue := &uitest.UIQueue{} + feature.SetUIQueue(queue) + + leftStarted := make(chan struct{}) + var leftOnce sync.Once + renderers[0].generateTile = func(ctx context.Context, source *renderSource, key tileKey) (*renderTile, error) { + if source.frame.Bounds().Dx() == 2048 { + leftOnce.Do(func() { close(leftStarted) }) + <-ctx.Done() + return nil, ctx.Err() + } + return generateRenderTile(ctx, source, key) + } + var rightSourceGenerations atomic.Int64 + renderers[1].generateTile = func(ctx context.Context, source *renderSource, key tileKey) (*renderTile, error) { + if source.frame.Bounds().Dx() == 3072 { + rightSourceGenerations.Add(1) + } + return generateRenderTile(ctx, source, key) + } + + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + feature.workers.Wait() + if !queue.Drain() { + t.Fatal("comparison load did not queue a UI completion") + } + waitForTileStart(t, leftStarted) + waitForRenderer(t, renderers[1]) + beforeSwap := rightSourceGenerations.Load() + if beforeSwap == 0 { + t.Fatal("right source cache was not prepared before Swap") + } + + feature.swapSides() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := feature.Settle(ctx); err != nil { + t.Fatalf("Settle after active Swap: %v", err) + } + if got := rightSourceGenerations.Load(); got != beforeSwap { + t.Errorf("completed right source regenerated %d tiles across Swap, want cached count %d", got, beforeSwap) + } + if renderers[0].shader.Textures["overview"] != feature.renderSources[0].overview { + t.Fatal("left renderer did not publish the swapped source") + } +} diff --git a/internal/ui/compare/compare.go b/internal/ui/compare/compare.go index 4780230..d9babc0 100644 --- a/internal/ui/compare/compare.go +++ b/internal/ui/compare/compare.go @@ -44,18 +44,16 @@ type Callbacks struct { } type pane struct { - root *fyne.Container - image *canvas.Image - input *paneInput - spinner *widget.ProgressBarInfinite + root *fyne.Container + renderer paneRenderer + scene paneScene + input *paneInput + spinner *widget.ProgressBarInfinite } -func newPaneImage() *canvas.Image { - img := canvas.NewImageFromImage(nil) - img.FillMode = canvas.ImageFillContain - img.ScaleMode = canvas.ImageScaleSmooth - img.Hide() - return img +func (p *pane) present(scene paneScene) { + p.scene = scene + p.renderer.Present(scene) } func newPaneSpinner() *widget.ProgressBarInfinite { @@ -73,31 +71,38 @@ type Feature struct { overlay *fyne.Container content *fyne.Container + toolbar *fyne.Container panes [2]pane reveals [2]paneReveal divider *swipeDivider + linkToggle *widget.Button layoutToggle *widget.Button swap *widget.Button + unlinkStatus *widget.Label badges [2]*widget.Label - sources [2]fyne.URI - identities [2]string - loaded [2]*imaging.LoadedImage - rendered [2]image.Image - vectors [2]vectorRasterState + sources [2]fyne.URI + identities [2]string + loaded [2]*imaging.LoadedImage + rendered [2]image.Image + renderSources [2]*renderSource + vectors [2]vectorRasterState active bool ready bool - transform linkedTransform - viewports [2]fyne.Size - layoutMode comparisonLayout - dividerAt float32 + photoTransforms [2]photoTransform + camera cameraTransform + viewports [2]fyne.Size + layoutMode comparisonLayout + dividerAt float32 + unlinked bool + hoveredPane int lifecycle requestLifecycle done completion.Signal - workers sync.WaitGroup - uiPending sync.WaitGroup + workers workTracker + uiPending workTracker uiCount atomic.Int64 ui UIQueue @@ -105,25 +110,41 @@ type Feature struct { vectorAfter func(time.Duration) <-chan time.Time vectorRasterize func(vector *imaging.Vector, width, height int) (image.Image, error) vectorPixels func(fyne.CanvasObject, fyne.Size) (int, int) + prepareSource func(context.Context, image.Image) (*renderSource, error) } // New constructs one initially hidden comparison surface. func New(loader Loader, callbacks Callbacks) *Feature { + return newFeature(loader, callbacks, newShaderPaneRenderer) +} + +func newFeature(loader Loader, callbacks Callbacks, rendererFactory paneRendererFactory) *Feature { f := &Feature{ - loader: loader, - callbacks: callbacks, - transform: defaultLinkedTransform(), - dividerAt: defaultDivider, - layoutMode: sideBySide, - vectorDebounce: defaultCompareVectorDebounce, - vectorAfter: time.After, + loader: loader, + callbacks: callbacks, + photoTransforms: [2]photoTransform{defaultPhotoTransform(), defaultPhotoTransform()}, + camera: defaultCameraTransform(), + dividerAt: defaultDivider, + layoutMode: sideBySide, + hoveredPane: -1, + vectorDebounce: defaultCompareVectorDebounce, + vectorAfter: time.After, vectorRasterize: func(vector *imaging.Vector, width, height int) (image.Image, error) { return vector.RasterAt(width, height) }, - vectorPixels: displayPixelSize, - ui: fyneQueue{}, + vectorPixels: displayPixelSize, + prepareSource: prepareRenderSource, + ui: fyneQueue{}, + } + f.panes = [2]pane{ + newPane(f, 0, rendererFactory(0)), + newPane(f, 1, rendererFactory(1)), + } + for i := range f.panes { + if renderer, ok := f.panes[i].renderer.(paneRendererQueue); ok { + renderer.setQueueUI(f.queueUI) + } } - f.panes = [2]pane{newPane(f, 0), newPane(f, 1)} f.reveals = [2]paneReveal{newPaneReveal(f.panes[0].root), newPaneReveal(f.panes[1].root)} f.divider = newSwipeDivider(f) f.divider.Hide() @@ -138,16 +159,21 @@ func New(loader Loader, callbacks Callbacks) *Feature { f.layoutToggle.Disable() f.swap = widget.NewButton(lang.L("Swap"), f.swapSides) f.swap.Disable() + f.linkToggle = widget.NewButton(lang.L("Unlink"), f.ToggleLink) + f.linkToggle.Disable() + f.unlinkStatus = widget.NewLabel("") + f.unlinkStatus.Hide() back := widget.NewButton(lang.L("Back to Grid"), f.Close) - toolbarCard := newChromeCard(container.NewHBox(f.layoutToggle, f.swap, back)) - toolbar := container.NewHBox(layout.NewSpacer(), toolbarCard) + linkCard := newChromeCard(container.NewHBox(f.linkToggle, f.unlinkStatus)) + actionCard := newChromeCard(container.NewHBox(f.layoutToggle, f.swap, back)) + f.toolbar = container.NewHBox(linkCard, layout.NewSpacer(), actionCard) var badgeCards [2]*fyne.Container for i := range f.badges { f.badges[i] = widget.NewLabel("") badgeCards[i] = newChromeCard(f.badges[i]) } badges := container.NewHBox(badgeCards[0], layout.NewSpacer(), badgeCards[1]) - chrome := container.NewBorder(toolbar, badges, nil, nil) + chrome := container.NewBorder(f.toolbar, badges, nil, nil) backdrop := canvas.NewRectangle(theme.Color(theme.ColorNameBackground)) // The comparison surface sits in the main window's root stack even while // hidden. Keep its minimum size at zero so merely registering the feature @@ -221,7 +247,8 @@ func (f *Feature) Overlay() fyne.CanvasObject { return f.overlay } // Visible reports whether a comparison session owns the main-window surface. func (f *Feature) Visible() bool { return f.active } -// Ready reports whether both panes hold a decoded first frame. +// Ready reports whether both panes hold a decoded first frame and a +// display-ready overview. func (f *Feature) Ready() bool { return f.ready } // Done returns the replaceable completion signal for the latest Open call. @@ -236,8 +263,14 @@ func (f *Feature) Open(sources [2]fyne.URI) { f.active = true f.ready = false - f.transform = defaultLinkedTransform() + initialTransform := defaultPhotoTransform() + f.photoTransforms = [2]photoTransform{initialTransform, initialTransform} + f.camera = defaultCameraTransform() + f.unlinked = false + f.hoveredPane = -1 + f.syncLinkControls() f.resetLayout() + f.linkToggle.Disable() f.layoutToggle.Disable() f.swap.Disable() f.sources = sources @@ -248,8 +281,9 @@ func (f *Feature) Open(sources [2]fyne.URI) { f.notifyOrderChanged() for i := range f.panes { f.loaded[i] = nil - f.panes[i].image.Image = nil - f.panes[i].image.Hide() + f.rendered[i] = nil + f.renderSources[i] = nil + f.panes[i].present(paneScene{}) f.panes[i].spinner.Show() } f.overlay.Show() @@ -283,65 +317,141 @@ func (f *Feature) Settle(ctx context.Context) error { // Every worker queues its UI completion before marking itself done. // A drained load completion can start vector work, so repeat until // both worker sets and the queue are empty at the same boundary. - if err := waitWithContext(ctx, f.workers.Wait); err != nil { + if err := f.workers.WaitContext(ctx); err != nil { return err } for i := range f.vectors { - if err := waitWithContext(ctx, f.vectors[i].pending.Wait); err != nil { + if err := f.vectors[i].pending.WaitContext(ctx); err != nil { return err } } - if f.ui.Drain() { + // A queued vector completion replaces its render source and cancels + // obsolete tile work. Apply every upstream completion before waiting + // on renderers or that cancellation dependency can deadlock Settle. + if advanced, err := f.settleUI(ctx); err != nil { + return err + } else if advanced { continue } - if f.uiCount.Load() == 0 { - return nil + for i := range f.panes { + if err := f.panes[i].renderer.Wait(ctx); err != nil { + return err + } } - if err := waitWithContext(ctx, f.uiPending.Wait); err != nil { + if advanced, err := f.settleUI(ctx); err != nil { return err + } else if !advanced { + return nil } } } -func waitWithContext(ctx context.Context, wait func()) error { - done := make(chan struct{}) - go func() { - wait() - close(done) - }() - select { - case <-done: - return nil - case <-ctx.Done(): - return ctx.Err() +func (f *Feature) settleUI(ctx context.Context) (bool, error) { + if f.ui.Drain() { + return true, nil + } + if f.uiCount.Load() == 0 { + return false, nil } + if err := f.uiPending.WaitContext(ctx); err != nil { + return false, err + } + return true, nil } func (f *Feature) hide() { f.clearVectorRasters() f.active = false f.ready = false + f.unlinked = false + f.hoveredPane = -1 + f.syncLinkControls() + f.linkToggle.Disable() f.layoutToggle.Disable() f.swap.Disable() f.sources = [2]fyne.URI{} f.identities = [2]string{} f.loaded = [2]*imaging.LoadedImage{} f.rendered = [2]image.Image{} + f.renderSources = [2]*renderSource{} for i := range f.panes { f.badges[i].SetText("") - f.panes[i].image.Image = nil - f.panes[i].image.Hide() + f.panes[i].present(paneScene{}) f.panes[i].spinner.Hide() } f.overlay.Hide() f.repaint() } +func (f *Feature) currentModifiers() fyne.KeyModifier { + if f.callbacks.Modifiers == nil { + return 0 + } + return f.callbacks.Modifiers() +} + +// ToggleLink switches a ready comparison between photo-local and shared-camera +// input. Loading or inactive comparisons ignore it. The visible photo poses and +// camera stay unchanged in either direction. +func (f *Feature) ToggleLink() { + if !f.active || !f.ready { + return + } + f.unlinked = !f.unlinked + f.syncLinkControls() + f.repaint() +} + +func (f *Feature) setHoveredPane(index int) { + if index < 0 || index >= len(f.panes) { + return + } + if f.hoveredPane == index { + return + } + f.hoveredPane = index + f.syncLinkControls() +} + +func (f *Feature) syncLinkControls() { + if f.linkToggle != nil { + label := lang.L("Unlink") + if f.unlinked { + label = lang.L("Link") + } + f.linkToggle.SetText(label) + } + + if f.unlinkStatus == nil || !f.active || !f.unlinked { + if f.unlinkStatus != nil { + f.unlinkStatus.Hide() + } + if f.toolbar != nil { + f.toolbar.Refresh() + } + return + } + + text := lang.L("Unlinked") + switch f.hoveredPane { + case 0: + text = lang.L("Unlinked: Left") + case 1: + text = lang.L("Unlinked: Right") + } + f.unlinkStatus.SetText(text) + f.unlinkStatus.Show() + if f.toolbar != nil { + f.toolbar.Refresh() + } +} + type loadResult struct { - index int - uri fyne.URI - loaded *imaging.LoadedImage - err error + index int + uri fyne.URI + loaded *imaging.LoadedImage + prepared *renderSource + err error } func (f *Feature) load(token requestToken, sources [2]fyne.URI, done func()) { @@ -349,7 +459,14 @@ func (f *Feature) load(token requestToken, sources [2]fyne.URI, done func()) { for i, uri := range sources { go func() { loaded, err := f.loadOne(token.context(), uri) - results <- loadResult{index: i, uri: uri, loaded: loaded, err: err} + if err == nil { + err = validateLoaded(loaded) + } + var prepared *renderSource + if err == nil { + prepared, err = f.prepareSource(token.context(), loaded.Frames[0]) + } + results <- loadResult{index: i, uri: uri, loaded: loaded, prepared: prepared, err: err} }() } @@ -378,24 +495,17 @@ func (f *Feature) load(token requestToken, sources [2]fyne.URI, done func()) { return } - for _, result := range loaded { - if err := validateLoaded(result.loaded); err != nil { - f.fail(result.uri, err) - return - } - } - for i, result := range loaded { f.loaded[i] = result.loaded f.rendered[i] = result.loaded.Frames[0] + f.renderSources[i] = result.prepared f.vectors[i].setRaster(result.loaded.Frames[0]) - f.panes[i].image.Image = f.rendered[i] + f.panes[i].present(paneScene{source: f.renderSources[i]}) f.panes[i].spinner.Hide() - f.panes[i].image.Show() - f.panes[i].image.Refresh() } f.ready = true f.applyTransform() + f.linkToggle.Enable() f.layoutToggle.Enable() f.swap.Enable() f.repaint() @@ -406,16 +516,27 @@ func (f *Feature) swapSides() { if !f.active || !f.ready { return } + reference := 0 + if f.hoveredPane >= 0 && f.hoveredPane < len(f.photoTransforms) { + reference = f.hoveredPane + } + if f.unlinked || f.photoTransforms[0] != f.photoTransforms[1] { + commonTransform := f.visiblePhotoTransform(reference) + f.photoTransforms = [2]photoTransform{commonTransform, commonTransform} + f.camera = defaultCameraTransform() + } + f.unlinked = false + f.syncLinkControls() f.sources[0], f.sources[1] = f.sources[1], f.sources[0] f.identities[0], f.identities[1] = f.identities[1], f.identities[0] f.clearVectorRequests() f.loaded[0], f.loaded[1] = f.loaded[1], f.loaded[0] f.rendered[0], f.rendered[1] = f.rendered[1], f.rendered[0] + f.renderSources[0], f.renderSources[1] = f.renderSources[1], f.renderSources[0] for i := range f.panes { f.badges[i].SetText(f.identities[i]) f.vectors[i].setRaster(f.rendered[i]) - f.panes[i].image.Image = f.rendered[i] - f.panes[i].image.Refresh() + f.panes[i].present(paneScene{source: f.renderSources[i]}) } f.applyTransform() f.notifyOrderChanged() diff --git a/internal/ui/compare/compare_test.go b/internal/ui/compare/compare_test.go index 425ad01..71f1ce5 100644 --- a/internal/ui/compare/compare_test.go +++ b/internal/ui/compare/compare_test.go @@ -1,4 +1,4 @@ -package compare_test +package compare import ( "context" @@ -7,6 +7,7 @@ import ( "image/color" "image/draw" "slices" + "strings" "sync/atomic" "testing" "time" @@ -21,12 +22,15 @@ import ( "fyne.io/fyne/v2/widget" "github.com/frathe/picfetch/internal/imaging" - "github.com/frathe/picfetch/internal/ui/compare" "github.com/frathe/picfetch/internal/uitest" ) const waitTimeout = 5 * time.Second +func newReferenceFeature(loader Loader, callbacks Callbacks) *Feature { + return newFeature(loader, callbacks, newCanvasPaneRenderer) +} + func walk(root fyne.CanvasObject, visit func(fyne.CanvasObject)) { visit(root) switch c := root.(type) { @@ -89,7 +93,7 @@ func buttonCard(t *testing.T, root fyne.CanvasObject, text string) (*fyne.Contai return card, background } -func waitForDone(t *testing.T, feature *compare.Feature) { +func waitForDone(t *testing.T, feature *Feature) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), waitTimeout) defer cancel() @@ -221,6 +225,20 @@ func paneCursorable(t *testing.T, pane renderedPane) desktop.Cursorable { return found } +func paneHoverable(t *testing.T, pane renderedPane) desktop.Hoverable { + t.Helper() + var found desktop.Hoverable + walk(pane.root, func(object fyne.CanvasObject) { + if hoverable, ok := object.(desktop.Hoverable); ok { + found = hoverable + } + }) + if found == nil { + t.Fatal("comparison pane has no hoverable view") + } + return found +} + func horizontalResizeTarget(t *testing.T, root fyne.CanvasObject) (fyne.CanvasObject, fyne.Draggable) { t.Helper() var object fyne.CanvasObject @@ -258,24 +276,15 @@ func approxPosition(a, b fyne.Position) bool { return uitest.ApproxEqual(a.X, b.X) && uitest.ApproxEqual(a.Y, b.Y) } -func assertPaneCoversOrCenters(t *testing.T, pane renderedPane) { +func assertPaneOverlapsCenter(t *testing.T, pane renderedPane) { t.Helper() - assertAxis := func(name string, position, imageSize, viewportSize float32) { - t.Helper() - if imageSize <= viewportSize+0.5 { - want := (viewportSize - imageSize) / 2 - if !uitest.ApproxEqual(position, want) { - t.Errorf("%s non-overflowing image position = %.3f, want centered %.3f", name, position, want) - } - return - } - if position > 0.01 || position+imageSize < viewportSize-0.01 { - t.Errorf("%s overflowing image span = %.3f..%.3f, want to cover 0..%.3f", - name, position, position+imageSize, viewportSize) - } + position, size, viewport := pane.image.Position(), pane.image.Size(), pane.root.Size() + center := fyne.NewPos(viewport.Width/2, viewport.Height/2) + if position.X > center.X+0.01 || position.X+size.Width < center.X-0.01 || + position.Y > center.Y+0.01 || position.Y+size.Height < center.Y-0.01 { + t.Errorf("image span %v..%v does not overlap pane center %v", position, + fyne.NewPos(position.X+size.Width, position.Y+size.Height), center) } - assertAxis("horizontal", pane.image.Position().X, pane.image.Size().Width, pane.root.Size().Width) - assertAxis("vertical", pane.image.Position().Y, pane.image.Size().Height, pane.root.Size().Height) } func labelTexts(root fyne.CanvasObject) []string { @@ -338,7 +347,7 @@ func TestCompareOverlay_ClosedSurfaceDoesNotChangeHostMinimumSize(t *testing.T) app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(nil, compare.Callbacks{}) + feature := newReferenceFeature(nil, Callbacks{}) if got := feature.Overlay().MinSize(); got != (fyne.Size{}) { t.Fatalf("closed comparison overlay MinSize = %v, want zero so it cannot resize the host stack", got) } @@ -368,7 +377,7 @@ func TestCompareOverlay_OpensImmediatelyAndBackExitsWhileLoading(t *testing.T) { <-ctx.Done() return nil, ctx.Err() } - feature := compare.New(loader, compare.Callbacks{}) + feature := newReferenceFeature(loader, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(640, 480)) feature.Open([2]fyne.URI{ storage.NewFileURI("left.png"), @@ -413,11 +422,11 @@ func TestCompareToolbar_SwapIsPermanentAndReadyGated(t *testing.T) { started := make(chan string, 2) release := make(chan struct{}) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { started <- uri.Name() <-release return loadedImage(32, 24), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Open([2]fyne.URI{ storage.NewFileURI("left.png"), storage.NewFileURI("right.png"), @@ -444,17 +453,95 @@ func TestCompareToolbar_SwapIsPermanentAndReadyGated(t *testing.T) { } } +func TestCompareLinkControl_TopLeftCardAndReadyGate(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + started := make(chan string, 2) + release := make(chan struct{}) + t.Cleanup(func() { + select { + case <-release: + default: + close(release) + } + }) + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + started <- uri.Name() + <-release + return loadedImage(32, 24), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(640, 480)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitStarted(t, started) + waitStarted(t, started) + + unlink := comparisonButton(t, feature.Overlay(), "Unlink") + if !unlink.Visible() { + t.Fatal("Unlink is not a permanent comparison control") + } + if !unlink.Disabled() { + t.Error("Unlink is enabled before both images are ready") + } + + linkCard, linkBackground := buttonCard(t, feature.Overlay(), "Unlink") + swapCard, _ := buttonCard(t, feature.Overlay(), "Swap") + if linkCard == swapCard { + t.Fatal("Unlink and Swap share one toolbar card, want separate cards") + } + if _, _, _, alpha := linkBackground.FillColor.RGBA(); alpha == 0 || alpha == 0xffff { + t.Errorf("link card background alpha = %#x, want partial translucency", alpha) + } + if linkCard.Size().Width >= feature.Overlay().Size().Width/2 || linkCard.Size().Height >= feature.Overlay().Size().Height/2 { + t.Errorf("link card size = %v in %v overlay, want compact", linkCard.Size(), feature.Overlay().Size()) + } + if left := linkCard.Position().X; left < 0 || left > theme.Padding() { + t.Errorf("link card left gap = %v, want 0..%v", left, theme.Padding()) + } + if top := linkCard.Position().Y; top < 0 || top > theme.Padding() { + t.Errorf("link card top gap = %v, want 0..%v", top, theme.Padding()) + } + + if !containsButton(swapCard, "Back to Grid") { + t.Fatal("Swap card no longer contains Back to Grid") + } + if containsButton(swapCard, "Unlink") { + t.Error("Swap card contains Unlink, want it only in the top-left card") + } + if containsLabel(swapCard, "Unlinked") { + t.Error("Swap card contains the Unlinked label, want it only in the top-left card") + } + if gap := feature.Overlay().Size().Width - swapCard.Position().X - swapCard.Size().Width; gap < 0 || gap > theme.Padding() { + t.Errorf("Swap card right gap = %v, want 0..%v", gap, theme.Padding()) + } + if top := swapCard.Position().Y; top < 0 || top > theme.Padding() { + t.Errorf("Swap card top gap = %v, want 0..%v", top, theme.Padding()) + } + + close(release) + waitForDone(t, feature) + if got := comparisonButton(t, feature.Overlay(), "Unlink"); got != unlink { + t.Fatal("Unlink control was replaced when comparison became ready") + } + if unlink.Disabled() { + t.Error("Unlink stayed disabled after both images became ready") + } +} + func TestCompareSwipeToggle_PermanentReadyGatedAndRelabels(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) started := make(chan string, 2) release := make(chan struct{}) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { started <- uri.Name() <-release return loadedImage(32, 24), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Open([2]fyne.URI{ storage.NewFileURI("left.png"), storage.NewFileURI("right.png"), @@ -493,12 +580,12 @@ func TestCompareSwipeLayout_UsesAlignedFullViewportImagesAndKeepsChrome(t *testi left := solidLoadedImage(800, 400, color.RGBA{R: 255, A: 255}) right := solidLoadedImage(600, 300, color.RGBA{B: 255, A: 255}) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "left.png" { return left, nil } return right, nil - }, compare.Callbacks{}) + }, Callbacks{}) win := test.NewWindow(feature.Overlay()) win.SetPadded(false) win.Resize(fyne.NewSize(800, 400)) @@ -548,13 +635,200 @@ func TestCompareSwipeLayout_UsesAlignedFullViewportImagesAndKeepsChrome(t *testi } } +func TestCompareSwipeUnlinkedCanvasRoutesPointerByReveal(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(1600, 800), nil + }, Callbacks{}) + win := test.NewWindow(feature.Overlay()) + win.SetPadded(false) + win.Resize(fyne.NewSize(800, 400)) + t.Cleanup(win.Close) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) + test.Tap(comparisonButton(t, feature.Overlay(), "Unlink")) + + overlayPosition := app.Driver().AbsolutePositionForObject(feature.Overlay()) + canvasPoint := func(x float32) fyne.Position { + return overlayPosition.Add(fyne.NewPos(x, feature.Overlay().Size().Height/2)) + } + assertTarget := func(want string) { + t.Helper() + if got := labelTexts(feature.Overlay()); !slices.Contains(got, "Unlinked: "+want) { + t.Fatalf("labels after pointing at %s reveal = %v, want Unlinked: %s", want, got, want) + } + } + + test.MoveMouse(win.Canvas(), canvasPoint(200)) + assertTarget("Left") + beforeKey := renderedPanes(feature.Overlay()) + beforeKeySizes := [2]fyne.Size{beforeKey[0].image.Size(), beforeKey[1].image.Size()} + feature.HandleKey(fyne.KeyPlus) + afterKey := renderedPanes(feature.Overlay()) + if afterKey[0].image.Size().Width <= beforeKeySizes[0].Width { + t.Errorf("left image after left-targeted + = %v, want larger than %v", afterKey[0].image.Size(), beforeKeySizes[0]) + } + if afterKey[1].image.Size() != beforeKeySizes[1] { + t.Errorf("right image after left-targeted + = %v, want unchanged %v", afterKey[1].image.Size(), beforeKeySizes[1]) + } + + beforeDrag := renderedPanes(feature.Overlay()) + beforeDragPositions := [2]fyne.Position{beforeDrag[0].image.Position(), beforeDrag[1].image.Position()} + test.Drag(win.Canvas(), canvasPoint(200), 40, 20) + afterDrag := renderedPanes(feature.Overlay()) + if afterDrag[0].image.Position() == beforeDragPositions[0] { + t.Fatal("canvas drag over the left reveal did not pan the left image") + } + if afterDrag[1].image.Position() != beforeDragPositions[1] { + t.Errorf("right image after left-reveal drag = %v, want unchanged %v", afterDrag[1].image.Position(), beforeDragPositions[1]) + } + + test.MoveMouse(win.Canvas(), canvasPoint(700)) + assertTarget("Right") + beforeWheel := renderedPanes(feature.Overlay()) + beforeWheelSizes := [2]fyne.Size{beforeWheel[0].image.Size(), beforeWheel[1].image.Size()} + test.Scroll(win.Canvas(), canvasPoint(700), 0, 10) + afterWheel := renderedPanes(feature.Overlay()) + if afterWheel[0].image.Size() != beforeWheelSizes[0] { + t.Errorf("left image after right-reveal wheel = %v, want unchanged %v", afterWheel[0].image.Size(), beforeWheelSizes[0]) + } + if afterWheel[1].image.Size().Width <= beforeWheelSizes[1].Width { + t.Errorf("right image after right-reveal wheel = %v, want larger than %v", afterWheel[1].image.Size(), beforeWheelSizes[1]) + } + + beforeRightKey := renderedPanes(feature.Overlay()) + beforeRightKeySizes := [2]fyne.Size{beforeRightKey[0].image.Size(), beforeRightKey[1].image.Size()} + feature.HandleKey(fyne.KeyPlus) + afterRightKey := renderedPanes(feature.Overlay()) + if afterRightKey[0].image.Size() != beforeRightKeySizes[0] { + t.Errorf("left image after right-targeted + = %v, want unchanged %v", afterRightKey[0].image.Size(), beforeRightKeySizes[0]) + } + if afterRightKey[1].image.Size().Width <= beforeRightKeySizes[1].Width { + t.Errorf("right image after right-targeted + = %v, want larger than %v", afterRightKey[1].image.Size(), beforeRightKeySizes[1]) + } + + divider, _ := horizontalResizeTarget(t, feature.Overlay()) + dividerPosition := app.Driver().AbsolutePositionForObject(divider) + dividerPoint := dividerPosition.Add(fyne.NewPos(divider.Size().Width/2, divider.Size().Height/2)) + test.Drag(win.Canvas(), dividerPoint, 200, 0) + if center := divider.Position().X + divider.Size().Width/2; !uitest.ApproxEqual(center, 600) { + t.Fatalf("divider center after canvas drag = %.2f, want 600", center) + } + test.MoveMouse(win.Canvas(), canvasPoint(550)) + assertTarget("Left") + beforeMovedKey := renderedPanes(feature.Overlay()) + beforeMovedKeySizes := [2]fyne.Size{beforeMovedKey[0].image.Size(), beforeMovedKey[1].image.Size()} + feature.HandleKey(fyne.KeyMinus) + afterMovedKey := renderedPanes(feature.Overlay()) + if afterMovedKey[0].image.Size().Width >= beforeMovedKeySizes[0].Width { + t.Errorf("left image after moved-reveal - = %v, want smaller than %v", afterMovedKey[0].image.Size(), beforeMovedKeySizes[0]) + } + if afterMovedKey[1].image.Size() != beforeMovedKeySizes[1] { + t.Errorf("right image after moved-reveal - = %v, want unchanged %v", afterMovedKey[1].image.Size(), beforeMovedKeySizes[1]) + } + + feature.HandleKey(fyne.KeyEnd) + test.MoveMouse(win.Canvas(), canvasPoint(700)) + assertTarget("Left") + feature.HandleKey(fyne.KeyHome) + assertTarget("Left") + test.MoveMouse(win.Canvas(), canvasPoint(100)) + assertTarget("Right") + + link := comparisonButton(t, feature.Overlay(), "Link") + linkPosition := app.Driver().AbsolutePositionForObject(link) + test.MoveMouse(win.Canvas(), linkPosition.Add(fyne.NewPos(link.Size().Width/2, link.Size().Height/2))) + assertTarget("Right") +} + +func TestCompareSwipeUnlinkedRightWheelPreservesViewportAnchor(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(1600, 800), nil + }, Callbacks{}) + win := test.NewWindow(feature.Overlay()) + win.SetPadded(false) + win.Resize(fyne.NewSize(800, 400)) + t.Cleanup(win.Close) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) + test.Tap(comparisonButton(t, feature.Overlay(), "Unlink")) + + before := renderedPanes(feature.Overlay()) + if len(before) != 2 { + t.Fatalf("rendered panes = %d, want 2", len(before)) + } + rightScroll := paneScrollable(t, before[1]) + rightInput, ok := rightScroll.(fyne.CanvasObject) + if !ok { + t.Fatalf("right scroll target %T is not a canvas object", rightScroll) + } + if got, want := rightInput.Position(), fyne.NewPos(400, 0); got != want { + t.Fatalf("right Swipe input position = %v, want reveal origin %v", got, want) + } + + localCursor := fyne.NewPos(100, 180) + viewportCursor := localCursor.Add(rightInput.Position()) + anchored := normalizedPoint(before[1], viewportCursor) + wantLeftSize := before[0].image.Size() + wantLeftPosition := before[0].image.Position() + wantRightBeforeSize := before[1].image.Size() + event := &fyne.ScrollEvent{ + Position: localCursor, + AbsolutePosition: fyne.NewPos(713, 257), + Scrolled: fyne.NewDelta(3, 10), + } + wantEventPosition := event.Position + wantEventAbsolutePosition := event.AbsolutePosition + wantEventDelta := event.Scrolled + + rightScroll.Scrolled(event) + after := renderedPanes(feature.Overlay()) + if after[1].image.Size().Width <= wantRightBeforeSize.Width { + t.Errorf("right image after wheel = %v, want larger than %v", after[1].image.Size(), wantRightBeforeSize) + } + if got := normalizedPoint(after[1], viewportCursor); !approxPosition(got, anchored) { + t.Errorf("right normalized point under viewport cursor after wheel = %v, want anchored %v", got, anchored) + } + if after[0].image.Size() != wantLeftSize || after[0].image.Position() != wantLeftPosition { + t.Errorf("left image changed during right wheel: got {%v %v}, want {%v %v}", + after[0].image.Size(), after[0].image.Position(), wantLeftSize, wantLeftPosition) + } + if event.Position != wantEventPosition || event.AbsolutePosition != wantEventAbsolutePosition || event.Scrolled != wantEventDelta { + t.Errorf("wheel handler mutated caller event to {%v %v %v}, want {%v %v %v}", + event.Position, event.AbsolutePosition, event.Scrolled, + wantEventPosition, wantEventAbsolutePosition, wantEventDelta) + } + + wantRightSize := after[1].image.Size() + wantRightPosition := after[1].image.Position() + rightScroll.Scrolled(nil) + afterNil := renderedPanes(feature.Overlay()) + if afterNil[1].image.Size() != wantRightSize || afterNil[1].image.Position() != wantRightPosition { + t.Errorf("nil wheel changed right image to {%v %v}, want {%v %v}", + afterNil[1].image.Size(), afterNil[1].image.Position(), wantRightSize, wantRightPosition) + } +} + func TestCompareSwipePointer_DividerDragChangesRevealWithoutPanning(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { return loadedImage(1600, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) win := test.NewWindow(feature.Overlay()) win.SetPadded(false) win.Resize(fyne.NewSize(800, 400)) @@ -613,13 +887,13 @@ func TestCompareSwipePointer_DividerDragDoesNotRefreshStaticContent(t *testing.T {Image: image.NewRGBA(image.Rect(0, 0, 1600, 800))}, } var repaints atomic.Int64 - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { index := 0 if uri.Name() == "right.png" { index = 1 } return &imaging.LoadedImage{Frames: []image.Image{frames[index]}}, nil - }, compare.Callbacks{Repaint: func() { repaints.Add(1) }}) + }, Callbacks{Repaint: func() { repaints.Add(1) }}) win := test.NewWindow(feature.Overlay()) win.SetPadded(false) win.Resize(fyne.NewSize(800, 400)) @@ -667,14 +941,66 @@ func TestCompareSwipePointer_DividerDragDoesNotRefreshStaticContent(t *testing.T } } +func TestCompareInteraction_PanAndZoomDoNotRepaintOwner(t *testing.T) { + for _, layout := range []struct { + name string + swipe bool + }{ + {name: "side by side"}, + {name: "swipe", swipe: true}, + } { + t.Run(layout.name, func(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + var repaints atomic.Int64 + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(2400, 1600), nil + }, Callbacks{Repaint: func() { repaints.Add(1) }}) + win := test.NewWindow(feature.Overlay()) + win.SetPadded(false) + win.Resize(fyne.NewSize(800, 400)) + t.Cleanup(win.Close) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + if layout.swipe { + test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) + } + feature.HandleKey(fyne.KeyPlus) + panes := renderedPanes(feature.Overlay()) + if len(panes) != 2 { + t.Fatalf("rendered panes = %d, want 2", len(panes)) + } + draggable := paneDraggable(t, panes[0]) + scrollable := paneScrollable(t, panes[0]) + repaints.Store(0) + + for range 50 { + draggable.Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(1, 1)}) + scrollable.Scrolled(&fyne.ScrollEvent{ + Position: fyne.NewPos(200, 200), + Scrolled: fyne.NewDelta(0, 0.25), + }) + } + + if got := repaints.Load(); got != 0 { + t.Errorf("owner repaints during 100 pan/zoom events = %d, want 0", got) + } + }) + } +} + func TestCompareDividerKeys_StepClampAndNoopSideBySide(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) var modifiers fyne.KeyModifier - feature := compare.New(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { return loadedImage(800, 400), nil - }, compare.Callbacks{Modifiers: func() fyne.KeyModifier { return modifiers }}) + }, Callbacks{Modifiers: func() fyne.KeyModifier { return modifiers }}) win := test.NewWindow(feature.Overlay()) win.SetPadded(false) win.Resize(fyne.NewSize(800, 400)) @@ -718,13 +1044,39 @@ func TestCompareDividerKeys_StepClampAndNoopSideBySide(t *testing.T) { assertDividerCenter(800) } -func TestCompareLayoutToggle_PreservesLinkedTransformAndDivider(t *testing.T) { +func TestCompareLinkToggle_DividerDragDoesNotRelinkPanes(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + win := test.NewWindow(feature.Overlay()) + win.SetPadded(false) + win.Resize(fyne.NewSize(800, 400)) + t.Cleanup(win.Close) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + feature.ToggleLink() + test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) + + _, divider := horizontalResizeTarget(t, feature.Overlay()) + divider.Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 0)}) + if got := labelTexts(feature.Overlay()); !slices.Contains(got, "Unlinked") { + t.Errorf("labels after unlinked divider drag = %v, want Unlinked", got) + } +} + +func TestCompareLayoutToggle_PreservesCameraAcrossRoundTripsAndDivider(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { return loadedImage(1600, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) win := test.NewWindow(feature.Overlay()) win.SetPadded(false) win.Resize(fyne.NewSize(800, 400)) @@ -745,39 +1097,44 @@ func TestCompareLayoutToggle_PreservesLinkedTransformAndDivider(t *testing.T) { test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) inSwipe := renderedPanes(feature.Overlay()) - if center := normalizedCenter(inSwipe[0]); !approxPosition(center, wantCenter) { - t.Errorf("normalized center after entering swipe = %v, want retained %v", center, wantCenter) - } + wantSwipeCenter := normalizedCenter(inSwipe[0]) if multiplier := inSwipe[0].image.Size().Width / 800; !uitest.ApproxEqual(multiplier, wantMultiplier) { t.Errorf("fit-relative multiplier after entering swipe = %.4f, want retained %.4f", multiplier, wantMultiplier) } feature.HandleKey(fyne.KeyRight) test.Tap(comparisonButton(t, feature.Overlay(), "Side by side")) + backSideBySide := renderedPanes(feature.Overlay()) + if center := normalizedCenter(backSideBySide[0]); !approxPosition(center, wantCenter) { + t.Errorf("side-by-side center after layout round trip = %v, want %v", center, wantCenter) + } + if multiplier := backSideBySide[0].image.Size().Width / 400; !uitest.ApproxEqual(multiplier, wantMultiplier) { + t.Errorf("side-by-side multiplier after layout round trip = %.4f, want %.4f", multiplier, wantMultiplier) + } test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) divider, _ := horizontalResizeTarget(t, feature.Overlay()) if center := divider.Position().X + divider.Size().Width/2; !uitest.ApproxEqual(center, 440) { t.Errorf("divider center after layout round trip = %.2f, want retained 440", center) } afterRoundTrip := renderedPanes(feature.Overlay()) - if center := normalizedCenter(afterRoundTrip[0]); !approxPosition(center, wantCenter) { - t.Errorf("normalized center after layout round trip = %v, want retained %v", center, wantCenter) + if center := normalizedCenter(afterRoundTrip[0]); !approxPosition(center, wantSwipeCenter) { + t.Errorf("swipe center after layout round trip = %v, want %v", center, wantSwipeCenter) } if multiplier := afterRoundTrip[0].image.Size().Width / 800; !uitest.ApproxEqual(multiplier, wantMultiplier) { t.Errorf("fit-relative multiplier after layout round trip = %.4f, want retained %.4f", multiplier, wantMultiplier) } } -func TestCompareLayoutTransition_PreservesFitStateAndDivider(t *testing.T) { +func TestCompareLayoutTransition_PreservesPhotoAndCameraStateOnRoundTrip(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(1600, 800), nil } return loadedImage(600, 1200), nil - }, compare.Callbacks{}) + }, Callbacks{}) win := test.NewWindow(feature.Overlay()) win.SetPadded(false) win.Resize(fyne.NewSize(800, 400)) @@ -794,21 +1151,21 @@ func TestCompareLayoutTransition_PreservesFitStateAndDivider(t *testing.T) { before := renderedPanes(feature.Overlay()) paneDraggable(t, before[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 20)}) before = renderedPanes(feature.Overlay()) - wantCenter := normalizedCenter(before[0]) + wantSideCenters := [2]fyne.Position{normalizedCenter(before[0]), normalizedCenter(before[1])} wantMultiplier := before[0].image.Size().Width / 400 - if approxPosition(wantCenter, fyne.NewPos(0.5, 0.5)) || wantMultiplier <= 1 { - t.Fatalf("transition setup = {center:%v multiplier:%.4f}, want a panned zoomed view", wantCenter, wantMultiplier) + if approxPosition(wantSideCenters[0], fyne.NewPos(0.5, 0.5)) || wantMultiplier <= 1 { + t.Fatalf("transition setup = {center:%v multiplier:%.4f}, want a panned zoomed view", wantSideCenters[0], wantMultiplier) } - assertState := func(name string, fitted [2]fyne.Size) { + assertState := func(name string, fitted [2]fyne.Size, wantCenters [2]fyne.Position) { t.Helper() panes := renderedPanes(feature.Overlay()) if len(panes) != 2 { t.Fatalf("%s rendered panes = %d, want 2", name, len(panes)) } for i, pane := range panes { - if center := normalizedCenter(pane); !approxPosition(center, wantCenter) { - t.Errorf("%s pane %d center = %v, want retained %v", name, i, center, wantCenter) + if center := normalizedCenter(pane); !approxPosition(center, wantCenters[i]) { + t.Errorf("%s pane %d center = %v, want retained %v", name, i, center, wantCenters[i]) } widthMultiplier := pane.image.Size().Width / fitted[i].Width heightMultiplier := pane.image.Size().Height / fitted[i].Height @@ -820,62 +1177,55 @@ func TestCompareLayoutTransition_PreservesFitStateAndDivider(t *testing.T) { } test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) - assertState("swipe", [2]fyne.Size{fyne.NewSize(800, 400), fyne.NewSize(200, 400)}) + inSwipe := renderedPanes(feature.Overlay()) + wantSwipeCenters := [2]fyne.Position{normalizedCenter(inSwipe[0]), normalizedCenter(inSwipe[1])} + assertState("swipe", [2]fyne.Size{fyne.NewSize(800, 400), fyne.NewSize(200, 400)}, wantSwipeCenters) feature.HandleKey(fyne.KeyRight) test.Tap(comparisonButton(t, feature.Overlay(), "Side by side")) - assertState("side by side", [2]fyne.Size{fyne.NewSize(400, 200), fyne.NewSize(200, 400)}) + assertState("side by side", [2]fyne.Size{fyne.NewSize(400, 200), fyne.NewSize(200, 400)}, wantSideCenters) test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) - assertState("swipe round trip", [2]fyne.Size{fyne.NewSize(800, 400), fyne.NewSize(200, 400)}) + assertState("swipe round trip", [2]fyne.Size{fyne.NewSize(800, 400), fyne.NewSize(200, 400)}, wantSwipeCenters) divider, _ := horizontalResizeTarget(t, feature.Overlay()) if center := divider.Position().X + divider.Size().Width/2; !uitest.ApproxEqual(center, 440) { t.Errorf("divider after layout round trip = %.2f, want retained 440", center) } } -func TestCompareResize_PreservesFitStateAndReclampsCenter(t *testing.T) { +func TestCompareResize_PreservesPhotoAndCameraStateOnRoundTrip(t *testing.T) { for _, tc := range []struct { name string swipe bool - proportionalSize fyne.Size proportionalFitted fyne.Size - clampingSize fyne.Size - clampingFitted fyne.Size - wantClampedCenterX float32 + wideSize fyne.Size wantProportionalDivider float32 - wantClampedDivider float32 + wantWideDivider float32 }{ { name: "side by side", - proportionalSize: fyne.NewSize(1000, 500), proportionalFitted: fyne.NewSize(500, 250), - clampingSize: fyne.NewSize(2400, 400), - clampingFitted: fyne.NewSize(800, 400), - wantClampedCenterX: 0.48, + wideSize: fyne.NewSize(2400, 400), }, { name: "swipe", swipe: true, - proportionalSize: fyne.NewSize(1000, 500), proportionalFitted: fyne.NewSize(1000, 500), - clampingSize: fyne.NewSize(2000, 400), - clampingFitted: fyne.NewSize(800, 400), - wantClampedCenterX: 0.5, + wideSize: fyne.NewSize(2000, 400), wantProportionalDivider: 550, - wantClampedDivider: 1100, + wantWideDivider: 1100, }, } { t.Run(tc.name, func(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "large.png" { return loadedImage(1600, 800), nil } return loadedImage(1200, 600), nil - }, compare.Callbacks{}) + }, Callbacks{}) win := test.NewWindow(feature.Overlay()) win.SetPadded(false) win.Resize(fyne.NewSize(800, 400)) @@ -893,34 +1243,37 @@ func TestCompareResize_PreservesFitStateAndReclampsCenter(t *testing.T) { feature.HandleKey(fyne.KeyPlus) panes := renderedPanes(feature.Overlay()) - paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(10000, 0)}) + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 20)}) panes = renderedPanes(feature.Overlay()) - wantCenter := fyne.NewPos(0.32, 0.5) - if center := normalizedCenter(panes[0]); !approxPosition(center, wantCenter) { - t.Fatalf("initial clamped center = %v, want %v", center, wantCenter) + wantInitial := [2]struct { + size fyne.Size + position fyne.Position + }{ + {size: panes[0].image.Size(), position: panes[0].image.Position()}, + {size: panes[1].image.Size(), position: panes[1].image.Position()}, } - assertResizeState := func(name string, fitted fyne.Size, wantCenter fyne.Position) { + assertRoundTrip := func(name string) { t.Helper() panes := renderedPanes(feature.Overlay()) - if len(panes) != 2 { - t.Fatalf("%s rendered panes = %d, want 2", name, len(panes)) - } for i, pane := range panes { - if center := normalizedCenter(pane); !approxPosition(center, wantCenter) { - t.Errorf("%s pane %d center = %v, want %v", name, i, center, wantCenter) - } - wantSize := fyne.NewSize(fitted.Width*1.5625, fitted.Height*1.5625) - if !uitest.ApproxEqual(pane.image.Size().Width, wantSize.Width) || - !uitest.ApproxEqual(pane.image.Size().Height, wantSize.Height) { - t.Errorf("%s pane %d image size = %v, want fit-relative %v", name, i, pane.image.Size(), wantSize) + if !uitest.ApproxEqual(pane.image.Size().Width, wantInitial[i].size.Width) || + !uitest.ApproxEqual(pane.image.Size().Height, wantInitial[i].size.Height) || + !approxPosition(pane.image.Position(), wantInitial[i].position) { + t.Errorf("%s pane %d geometry = {%v %v}, want {%v %v}", name, i, + pane.image.Size(), pane.image.Position(), wantInitial[i].size, wantInitial[i].position) } - assertPaneCoversOrCenters(t, pane) } } - win.Resize(tc.proportionalSize) - assertResizeState("proportional resize", tc.proportionalFitted, wantCenter) + win.Resize(fyne.NewSize(1000, 500)) + for i, pane := range renderedPanes(feature.Overlay()) { + wantSize := fyne.NewSize(tc.proportionalFitted.Width*1.5625, tc.proportionalFitted.Height*1.5625) + if !uitest.ApproxEqual(pane.image.Size().Width, wantSize.Width) || + !uitest.ApproxEqual(pane.image.Size().Height, wantSize.Height) { + t.Errorf("proportional resize pane %d size = %v, want %v", i, pane.image.Size(), wantSize) + } + } if tc.swipe { divider, _ := horizontalResizeTarget(t, feature.Overlay()) if center := divider.Position().X + divider.Size().Width/2; !uitest.ApproxEqual(center, tc.wantProportionalDivider) { @@ -928,29 +1281,32 @@ func TestCompareResize_PreservesFitStateAndReclampsCenter(t *testing.T) { } } - win.Resize(tc.clampingSize) - assertResizeState("clamping resize", tc.clampingFitted, fyne.NewPos(tc.wantClampedCenterX, 0.5)) + win.Resize(fyne.NewSize(800, 400)) + assertRoundTrip("proportional round trip") + + win.Resize(tc.wideSize) if tc.swipe { divider, _ := horizontalResizeTarget(t, feature.Overlay()) - if center := divider.Position().X + divider.Size().Width/2; !uitest.ApproxEqual(center, tc.wantClampedDivider) { - t.Errorf("clamping resize divider = %.2f, want %.2f", center, tc.wantClampedDivider) + if center := divider.Position().X + divider.Size().Width/2; !uitest.ApproxEqual(center, tc.wantWideDivider) { + t.Errorf("wide resize divider = %.2f, want %.2f", center, tc.wantWideDivider) } } + win.Resize(fyne.NewSize(800, 400)) + assertRoundTrip("wide round trip") }) } } -func TestCompareActualSizeTransition_PreservesOneToOneScale(t *testing.T) { +func TestCompareCameraHome_PreservesDivergentPhotoPosesAcrossTransitions(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - nativeSizes := [2]fyne.Size{fyne.NewSize(1600, 800), fyne.NewSize(1200, 600)} - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "large.png" { return loadedImage(1600, 800), nil } return loadedImage(1200, 600), nil - }, compare.Callbacks{}) + }, Callbacks{}) win := test.NewWindow(feature.Overlay()) win.SetPadded(false) win.Resize(fyne.NewSize(800, 400)) @@ -960,54 +1316,68 @@ func TestCompareActualSizeTransition_PreservesOneToOneScale(t *testing.T) { storage.NewFileURI("small.png"), }) waitForDone(t, feature) - feature.HandleKey(fyne.Key1) - + feature.ToggleLink() panes := renderedPanes(feature.Overlay()) - paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(100, 50)}) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) panes = renderedPanes(feature.Overlay()) - wantCenter := fyne.NewPos(0.4375, 0.4375) - if center := normalizedCenter(panes[0]); !approxPosition(center, wantCenter) { - t.Fatalf("actual-size setup center = %v, want %v", center, wantCenter) - } + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 20)}) + paneHoverable(t, renderedPanes(feature.Overlay())[1]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyMinus) + feature.ToggleLink() - assertActual := func(name string, wantCenter fyne.Position) { - t.Helper() + type geometry struct { + size fyne.Size + position fyne.Position + } + capture := func() [2]geometry { panes := renderedPanes(feature.Overlay()) - if len(panes) != 2 { - t.Fatalf("%s rendered panes = %d, want 2", name, len(panes)) + return [2]geometry{ + {size: panes[0].image.Size(), position: panes[0].image.Position()}, + {size: panes[1].image.Size(), position: panes[1].image.Position()}, } + } + assertGeometry := func(name string, want [2]geometry) { + t.Helper() + panes := renderedPanes(feature.Overlay()) for i, pane := range panes { - if pane.image.Size() != nativeSizes[i] { - t.Errorf("%s pane %d size = %v, want native 1:1 size %v", name, i, pane.image.Size(), nativeSizes[i]) + if !uitest.ApproxEqual(pane.image.Size().Width, want[i].size.Width) || + !uitest.ApproxEqual(pane.image.Size().Height, want[i].size.Height) || + !approxPosition(pane.image.Position(), want[i].position) { + t.Errorf("%s pane %d geometry = {%v %v}, want {%v %v}", name, i, + pane.image.Size(), pane.image.Position(), want[i].size, want[i].position) } - if center := normalizedCenter(pane); !approxPosition(center, wantCenter) { - t.Errorf("%s pane %d center = %v, want closest valid %v", name, i, center, wantCenter) - } - assertPaneCoversOrCenters(t, pane) } } + homeSideBySide := capture() + feature.HandleKey(fyne.KeyPlus) + paneDraggable(t, renderedPanes(feature.Overlay())[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(30, 15)}) + feature.HandleKey(fyne.Key1) + assertGeometry("side-by-side camera home", homeSideBySide) + test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) - assertActual("swipe", wantCenter) + homeSwipe := capture() + feature.HandleKey(fyne.KeyPlus) + paneDraggable(t, renderedPanes(feature.Overlay())[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(-20, 10)}) + feature.HandleKey(fyne.Key1) + assertGeometry("swipe camera home", homeSwipe) win.Resize(fyne.NewSize(1000, 500)) - assertActual("resized swipe", wantCenter) - - win.Resize(fyne.NewSize(2000, 500)) - clampedCenter := fyne.NewPos(0.5, wantCenter.Y) - assertActual("wide swipe", clampedCenter) - - test.Tap(comparisonButton(t, feature.Overlay(), "Side by side")) - assertActual("wide side by side", clampedCenter) + homeResized := capture() + feature.HandleKey(fyne.KeyMinus) + paneDraggable(t, renderedPanes(feature.Overlay())[1]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(15, -10)}) + feature.HandleKey(fyne.Key1) + assertGeometry("resized camera home", homeResized) } func TestCompareDividerReset_NewOpenStartsSideBySideAtHalf(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { return loadedImage(800, 400), nil - }, compare.Callbacks{}) + }, Callbacks{}) win := test.NewWindow(feature.Overlay()) win.SetPadded(false) win.Resize(fyne.NewSize(800, 400)) @@ -1046,9 +1416,9 @@ func TestCompareSessionReset_StartsCanonicalState(t *testing.T) { "next-right.png": loadedImage(200, 800), } var orders [][2]string - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { return images[uri.Name()], nil - }, compare.Callbacks{OrderChanged: func(left, right string) { + }, Callbacks{OrderChanged: func(left, right string) { orders = append(orders, [2]string{left, right}) }}) win := test.NewWindow(feature.Overlay()) @@ -1071,9 +1441,20 @@ func TestCompareSessionReset_StartsCanonicalState(t *testing.T) { panes := renderedPanes(feature.Overlay()) paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 20)}) test.Tap(comparisonButton(t, feature.Overlay(), "Swap")) + feature.ToggleLink() + if got := labelTexts(feature.Overlay()); !slices.ContainsFunc(got, func(text string) bool { + return strings.HasPrefix(text, "Unlinked") + }) { + t.Fatalf("first session labels before close = %v, want Unlinked", got) + } feature.Close() open("next-left.png", "next-right.png") + for _, text := range labelTexts(feature.Overlay()) { + if strings.HasPrefix(text, "Unlinked") { + t.Fatalf("new session labels = %v, want linked reset", labelTexts(feature.Overlay())) + } + } if !containsButton(feature.Overlay(), "Swipe") || containsButton(feature.Overlay(), "Side by side") { t.Fatal("new comparison did not reset to side-by-side layout") } @@ -1109,9 +1490,9 @@ func TestCompareToolbar_CompactTranslucentCardIsAtTopRight(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { return loadedImage(32, 24), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(640, 480)) feature.Open([2]fyne.URI{ storage.NewFileURI("left.png"), @@ -1163,9 +1544,9 @@ func TestCompareIdentity_BadgesUseBasenamesAndShortestDistinguishingSuffix(t *te }, } { t.Run(tc.name, func(t *testing.T) { - feature := compare.New(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { return loadedImage(32, 24), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Open([2]fyne.URI{ storage.NewFileURI(tc.paths[0]), storage.NewFileURI(tc.paths[1]), @@ -1183,9 +1564,9 @@ func TestCompareIdentity_TranslucentBadgesStayAtBottomCorners(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { return loadedImage(32, 24), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(640, 480)) feature.Open([2]fyne.URI{ storage.NewFileURI("/library/left.jpg"), @@ -1219,13 +1600,13 @@ func TestCompareSwap_ExchangesReadyRolesWithoutReload(t *testing.T) { left := loadedImage(101, 51) right := loadedImage(202, 52) var loads atomic.Int32 - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { loads.Add(1) if uri.Name() == "left.png" { return left, nil } return right, nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(801, 500)) feature.Open([2]fyne.URI{ storage.NewFileURI("/library/left.png"), @@ -1276,13 +1657,13 @@ func TestCompareSwapState_PreservesSessionAndExchangesSwipeRoles(t *testing.T) { right := solidLoadedImage(600, 1200, color.RGBA{B: 255, A: 255}) var loads atomic.Int32 var orders [][2]string - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { loads.Add(1) if uri.Name() == "left.png" { return left, nil } return right, nil - }, compare.Callbacks{OrderChanged: func(left, right string) { + }, Callbacks{OrderChanged: func(left, right string) { orders = append(orders, [2]string{left, right}) }}) win := test.NewWindow(feature.Overlay()) @@ -1305,7 +1686,7 @@ func TestCompareSwapState_PreservesSessionAndExchangesSwipeRoles(t *testing.T) { if len(before) != 2 { t.Fatalf("rendered panes before Swap = %d, want 2", len(before)) } - wantCenter := normalizedCenter(before[0]) + beforeCenters := [2]fyne.Position{normalizedCenter(before[0]), normalizedCenter(before[1])} beforePositions := [2]fyne.Position{before[0].image.Position(), before[1].image.Position()} beforeSizes := [2]fyne.Size{before[0].image.Size(), before[1].image.Size()} beforeCapture := win.Canvas().Capture() @@ -1328,8 +1709,8 @@ func TestCompareSwapState_PreservesSessionAndExchangesSwipeRoles(t *testing.T) { t.Errorf("pane %d transformed geometry after Swap = {%v %v}, want prior source geometry {%v %v}", i, pane.image.Position(), pane.image.Size(), beforePositions[from], beforeSizes[from]) } - if center := normalizedCenter(pane); !approxPosition(center, wantCenter) { - t.Errorf("pane %d center after Swap = %v, want retained %v", i, center, wantCenter) + if center := normalizedCenter(pane); !approxPosition(center, beforeCenters[from]) { + t.Errorf("pane %d center after Swap = %v, want prior source center %v", i, center, beforeCenters[from]) } } if !containsButton(feature.Overlay(), "Side by side") { @@ -1363,12 +1744,12 @@ func TestCompareSideBySide_FitsFirstFramesInFixedEqualPanes(t *testing.T) { left := loadedImage(640, 320) right := loadedImage(300, 900) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "left.png" { return left, nil } return right, nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(801, 500)) feature.Open([2]fyne.URI{ storage.NewFileURI("left.png"), @@ -1404,12 +1785,12 @@ func TestCompareVector_RasterizesAtCurrentDisplaySize(t *testing.T) { t.Cleanup(app.Quit) vector := loadedVector(t, 40, 20) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "left.svg" { return vector, nil } return loadedImage(40, 20), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.SetUIQueue(&uitest.UIQueue{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ @@ -1431,17 +1812,57 @@ func TestCompareVector_RasterizesAtCurrentDisplaySize(t *testing.T) { } } +func TestCompareVector_UnlinkedZoomRerasterizesOnlyTheTargetPane(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + vectors := [2]*imaging.LoadedImage{loadedVector(t, 40, 20), loadedVector(t, 40, 20)} + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + if uri.Name() == "left.svg" { + return vectors[0], nil + } + return vectors[1], nil + }, Callbacks{}) + feature.SetUIQueue(&uitest.UIQueue{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.svg"), + storage.NewFileURI("right.svg"), + }) + settle := func() []renderedPane { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), waitTimeout) + defer cancel() + if err := feature.Settle(ctx); err != nil { + t.Fatal("timed out waiting for comparison vector raster") + } + return renderedPanes(feature.Overlay()) + } + panes := settle() + feature.ToggleLink() + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) + panes = settle() + + if got := panes[0].image.Image.Bounds().Size(); got != image.Pt(500, 250) { + t.Errorf("target SVG raster after unlinked + = %v, want 500x250", got) + } + if got := panes[1].image.Image.Bounds().Size(); got != image.Pt(400, 200) { + t.Errorf("other SVG raster after unlinked + = %v, want unchanged 400x200", got) + } +} + func TestCompareVector_RerasterizesAcrossZoomLayoutResizeAndSwap(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) vector := loadedVector(t, 40, 20) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "left.svg" { return vector, nil } return loadedImage(40, 20), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.SetUIQueue(&uitest.UIQueue{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ @@ -1487,12 +1908,12 @@ func TestCompareRasterFidelity_PreservesFullDecodedFramesAcrossTransforms(t *tes left := image.NewRGBA(image.Rect(0, 0, 1600, 900)) right := image.NewRGBA(image.Rect(0, 0, 800, 1200)) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "left.png" { return &imaging.LoadedImage{Frames: []image.Image{left}}, nil } return &imaging.LoadedImage{Frames: []image.Image{right}}, nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 600)) feature.Open([2]fyne.URI{ storage.NewFileURI("left.png"), @@ -1516,8 +1937,8 @@ func TestCompareRasterFidelity_PreservesFullDecodedFramesAcrossTransforms(t *tes feature.HandleKey(fyne.KeyPlus) panes := renderedPanes(feature.Overlay()) paneScrollable(t, panes[0]).Scrolled(&fyne.ScrollEvent{ - PointEvent: fyne.PointEvent{Position: fyne.NewPos(200, 250)}, - Scrolled: fyne.NewDelta(0, 10), + Position: fyne.NewPos(200, 250), + Scrolled: fyne.NewDelta(0, 10), }) paneDraggable(t, panes[1]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(30, -20)}) feature.Overlay().Resize(fyne.NewSize(960, 640)) @@ -1531,12 +1952,12 @@ func TestCompareLinkedTransform_DifferentImagesShareCenterAndFitMultiplier(t *te app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), @@ -1568,41 +1989,50 @@ func TestCompareLinkedTransform_DifferentImagesShareCenterAndFitMultiplier(t *te } } -func TestCompareActualSize_UsesEachImagesOwnPixelDimensionsAndRecenters(t *testing.T) { +func TestCompareCameraHome_ReturnsToStoredPhotoArrangement(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), storage.NewFileURI("tall.png"), }) waitForDone(t, feature) + feature.ToggleLink() + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.Key1) + paneHoverable(t, renderedPanes(feature.Overlay())[1]).MouseIn(&desktop.MouseEvent{}) feature.HandleKey(fyne.KeyPlus) + feature.ToggleLink() + home := renderedPanes(feature.Overlay()) + wantSizes := [2]fyne.Size{home[0].image.Size(), home[1].image.Size()} + wantPositions := [2]fyne.Position{home[0].image.Position(), home[1].image.Position()} + feature.HandleKey(fyne.KeyPlus) + paneDraggable(t, renderedPanes(feature.Overlay())[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(30, 20)}) feature.HandleKey(fyne.Key1) - - panes := renderedPanes(feature.Overlay()) - wantSizes := [2]fyne.Size{fyne.NewSize(800, 400), fyne.NewSize(200, 800)} - wantPositions := [2]fyne.Position{fyne.NewPos(-200, 0), fyne.NewPos(100, -200)} + panes = renderedPanes(feature.Overlay()) for i, pane := range panes { - if pane.image.Size() != wantSizes[i] || pane.image.Position() != wantPositions[i] { - t.Errorf("pane %d at actual size = {%v %v}, want {%v %v}", + if pane.image.Size() != wantSizes[i] || !approxPosition(pane.image.Position(), wantPositions[i]) { + t.Errorf("pane %d at camera home = {%v %v}, want stored {%v %v}", i, pane.image.Position(), pane.image.Size(), wantPositions[i], wantSizes[i]) } } + feature.HandleKey(fyne.KeyPlus) panes = renderedPanes(feature.Overlay()) - wantZoomed := [2]fyne.Size{fyne.NewSize(1000, 500), fyne.NewSize(250, 1000)} for i, pane := range panes { - if pane.image.Size() != wantZoomed[i] { - t.Errorf("pane %d after actual size then + = %v, want absolute-step %v", i, pane.image.Size(), wantZoomed[i]) + want := fyne.NewSize(wantSizes[i].Width*1.25, wantSizes[i].Height*1.25) + if pane.image.Size() != want { + t.Errorf("pane %d after camera home then + = %v, want common 1.25x step %v", i, pane.image.Size(), want) } } } @@ -1611,12 +2041,12 @@ func TestCompareFit_ReturnsBothImagesToCanonicalCenteredFit(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), @@ -1643,12 +2073,12 @@ func TestCompareZoom_MinusAndEqualScaleBothFromTheSharedView(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), @@ -1674,16 +2104,16 @@ func TestCompareZoom_MinusAndEqualScaleBothFromTheSharedView(t *testing.T) { } } -func TestCompareZoom_WheelAnchorsOnePaneAndUpdatesTheSharedView(t *testing.T) { +func TestCompareCameraZoom_WheelAnchorsCorrespondingPointsInBothPanes(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), @@ -1696,12 +2126,12 @@ func TestCompareZoom_WheelAnchorsOnePaneAndUpdatesTheSharedView(t *testing.T) { before := renderedPanes(feature.Overlay()) cursor := fyne.NewPos(220, 180) - anchored := normalizedPoint(before[0], cursor) + anchored := [2]fyne.Position{normalizedPoint(before[0], cursor), normalizedPoint(before[1], cursor)} leftWidth := before[0].image.Size().Width rightHeight := before[1].image.Size().Height paneScrollable(t, before[0]).Scrolled(&fyne.ScrollEvent{ - PointEvent: fyne.PointEvent{Position: cursor}, - Scrolled: fyne.NewDelta(0, 10), + Position: cursor, + Scrolled: fyne.NewDelta(0, 10), }) after := renderedPanes(feature.Overlay()) @@ -1713,24 +2143,23 @@ func TestCompareZoom_WheelAnchorsOnePaneAndUpdatesTheSharedView(t *testing.T) { if !uitest.ApproxEqual(leftFactor, rightFactor) { t.Errorf("wheel multipliers = left %.4f right %.4f, want one shared multiplier", leftFactor, rightFactor) } - if got := normalizedPoint(after[0], cursor); !approxPosition(got, anchored) { - t.Errorf("normalized point under cursor after wheel = %v, want anchored %v", got, anchored) - } - if left, right := normalizedCenter(after[0]), normalizedCenter(after[1]); !approxPosition(left, right) { - t.Errorf("normalized centers after wheel = left %v right %v, want linked", left, right) + for i, pane := range after { + if got := normalizedPoint(pane, cursor); !approxPosition(got, anchored[i]) { + t.Errorf("pane %d normalized point under cursor after camera wheel = %v, want anchored %v", i, got, anchored[i]) + } } } -func TestCompareClamp_WheelUsesBothImagesValidPanRanges(t *testing.T) { +func TestCompareCameraClamp_WheelKeepsBothPhotosOverTheirPaneCenters(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), @@ -1740,30 +2169,27 @@ func TestCompareClamp_WheelUsesBothImagesValidPanRanges(t *testing.T) { panes := renderedPanes(feature.Overlay()) paneScrollable(t, panes[0]).Scrolled(&fyne.ScrollEvent{ - PointEvent: fyne.PointEvent{Position: fyne.NewPos(0, 0)}, - Scrolled: fyne.NewDelta(0, 100), + Position: fyne.NewPos(0, 0), + Scrolled: fyne.NewDelta(0, 100), }) panes = renderedPanes(feature.Overlay()) for _, pane := range panes { - assertPaneCoversOrCenters(t, pane) - } - if left, right := normalizedCenter(panes[0]), normalizedCenter(panes[1]); !approxPosition(left, right) { - t.Errorf("normalized centers after clamp = left %v right %v, want linked", left, right) + assertPaneOverlapsCenter(t, pane) } } -func TestComparePanInputs_DragAndShiftWheelMoveTheSharedCenter(t *testing.T) { +func TestCompareCameraPan_DragAndShiftWheelMoveBothPhotosByTheSamePoints(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) var modifiers fyne.KeyModifier - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{Modifiers: func() fyne.KeyModifier { return modifiers }}) + }, Callbacks{Modifiers: func() fyne.KeyModifier { return modifiers }}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), @@ -1775,51 +2201,905 @@ func TestComparePanInputs_DragAndShiftWheelMoveTheSharedCenter(t *testing.T) { } before := renderedPanes(feature.Overlay()) - leftPosition := before[0].image.Position() + wantAfterDrag := [2]fyne.Position{ + before[0].image.Position().Add(fyne.NewPos(40, 20)), + before[1].image.Position().Add(fyne.NewPos(40, 20)), + } draggable := paneDraggable(t, before[0]) draggable.Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 20)}) draggable.DragEnd() afterDrag := renderedPanes(feature.Overlay()) - if got := afterDrag[0].image.Position(); !approxPosition(got, leftPosition.Add(fyne.NewPos(40, 20))) { - t.Errorf("dragged pane position = %v, want %v", got, leftPosition.Add(fyne.NewPos(40, 20))) - } - if left, right := normalizedCenter(afterDrag[0]), normalizedCenter(afterDrag[1]); !approxPosition(left, right) { - t.Errorf("normalized centers after drag = left %v right %v, want linked", left, right) + for i, pane := range afterDrag { + if got := pane.image.Position(); !approxPosition(got, wantAfterDrag[i]) { + t.Errorf("camera-dragged pane %d position = %v, want %v", i, got, wantAfterDrag[i]) + } } modifiers = fyne.KeyModifierShift - rightPosition := afterDrag[1].image.Position() + wantAfterShiftWheel := [2]fyne.Position{ + afterDrag[0].image.Position().Add(fyne.NewPos(-15, 25)), + afterDrag[1].image.Position().Add(fyne.NewPos(-15, 25)), + } paneScrollable(t, afterDrag[1]).Scrolled(&fyne.ScrollEvent{Scrolled: fyne.NewDelta(-15, 25)}) afterShiftWheel := renderedPanes(feature.Overlay()) - if got := afterShiftWheel[1].image.Position(); !approxPosition(got, rightPosition.Add(fyne.NewPos(-15, 25))) { - t.Errorf("Shift+wheel pane position = %v, want %v", got, rightPosition.Add(fyne.NewPos(-15, 25))) + for i, pane := range afterShiftWheel { + if got := pane.image.Position(); !approxPosition(got, wantAfterShiftWheel[i]) { + t.Errorf("camera Shift+wheel pane %d position = %v, want %v", i, got, wantAfterShiftWheel[i]) + } + } +} + +func TestCompareLinkToggle_DragMovesOnlyGesturePaneWithoutHeldModifier(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + feature.ToggleLink() + + before := renderedPanes(feature.Overlay()) + wantLeft := before[0].image.Position().Add(fyne.NewPos(40, 20)) + wantRight := before[1].image.Position() + paneDraggable(t, before[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 20)}) + + after := renderedPanes(feature.Overlay()) + if got := after[0].image.Position(); !approxPosition(got, wantLeft) { + t.Errorf("unlinked dragged pane position = %v, want %v", got, wantLeft) + } + if got := after[1].image.Position(); !approxPosition(got, wantRight) { + t.Errorf("other pane position after unlinked drag = %v, want unchanged %v", got, wantRight) + } +} + +func TestCompareLinkToggle_WheelZoomsOnlyTheGesturePane(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + feature.ToggleLink() + + before := renderedPanes(feature.Overlay()) + leftWidth := before[0].image.Size().Width + wantRight := before[1].image.Size() + paneScrollable(t, before[0]).Scrolled(&fyne.ScrollEvent{ + Position: fyne.NewPos(200, 200), + Scrolled: fyne.NewDelta(0, 10), + }) + + after := renderedPanes(feature.Overlay()) + if after[0].image.Size().Width <= leftWidth { + t.Errorf("unlinked wheel pane width = %.2f, want greater than %.2f", after[0].image.Size().Width, leftWidth) + } + if got := after[1].image.Size(); got != wantRight { + t.Errorf("other pane size after unlinked wheel = %v, want unchanged %v", got, wantRight) + } +} + +func TestCompareLinkToggle_ShowsUnlinkedStatusAndTracksLastHoveredPane(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + + unlink := comparisonButton(t, feature.Overlay(), "Unlink") + feature.ToggleLink() + if got := labelTexts(feature.Overlay()); !slices.Contains(got, "Unlinked") { + t.Fatalf("labels after first toggle = %v, want Unlinked", got) + } + link := comparisonButton(t, feature.Overlay(), "Link") + if link != unlink { + t.Fatal("link toggle was replaced instead of relabeled in place") + } + linkCard, _ := buttonCard(t, feature.Overlay(), "Link") + statusCard, _ := labelCard(t, feature.Overlay(), "Unlinked") + if statusCard != linkCard { + t.Fatal("Unlinked status is not beside Link in the top-left card") + } + var status *widget.Label + walk(linkCard, func(object fyne.CanvasObject) { + if label, ok := object.(*widget.Label); ok && label.Text == "Unlinked" { + status = label + } + }) + if status == nil { + t.Fatal("top-left link card has no visible Unlinked status") + } + if status.Position().X < link.Position().X+link.Size().Width { + t.Errorf("Unlinked status x = %v, want it after Link ending at %v", status.Position().X, link.Position().X+link.Size().Width) + } + + panes := renderedPanes(feature.Overlay()) + left := paneHoverable(t, panes[0]) + left.MouseIn(&desktop.MouseEvent{}) + if got := labelTexts(feature.Overlay()); !slices.Contains(got, "Unlinked: Left") { + t.Errorf("labels while left pane is targeted = %v, want Unlinked: Left", got) + } + left.MouseOut() + if got := labelTexts(feature.Overlay()); !slices.Contains(got, "Unlinked: Left") { + t.Errorf("labels after leaving left pane = %v, want last target retained", got) + } + + paneHoverable(t, panes[1]).MouseMoved(&desktop.MouseEvent{}) + if got := labelTexts(feature.Overlay()); !slices.Contains(got, "Unlinked: Right") { + t.Errorf("labels while right pane is targeted = %v, want Unlinked: Right", got) + } + + feature.ToggleLink() + if got := comparisonButton(t, feature.Overlay(), "Unlink"); got != unlink { + t.Fatal("relink replaced the comparison link control") + } + for _, text := range labelTexts(feature.Overlay()) { + if strings.HasPrefix(text, "Unlinked") { + t.Fatalf("labels after relink toggle = %v, want no unlink status", labelTexts(feature.Overlay())) + } + } +} + +func TestCompareLinkToggle_TransformKeysRequireAndOnlyAffectLastHoveredPane(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + feature.ToggleLink() + + before := renderedPanes(feature.Overlay()) + wantLeft := before[0].image.Size() + wantRight := before[1].image.Size() + feature.HandleKey(fyne.KeyPlus) + panes := renderedPanes(feature.Overlay()) + if panes[0].image.Size() != wantLeft || panes[1].image.Size() != wantRight { + t.Fatalf("unlinked + without a pane target changed sizes to %v and %v", panes[0].image.Size(), panes[1].image.Size()) + } + + paneHoverable(t, panes[1]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) + panes = renderedPanes(feature.Overlay()) + if panes[0].image.Size() != wantLeft { + t.Errorf("left pane after right-targeted + = %v, want unchanged %v", panes[0].image.Size(), wantLeft) + } + if panes[1].image.Size().Width <= wantRight.Width { + t.Errorf("right pane after right-targeted + = %v, want larger than %v", panes[1].image.Size(), wantRight) + } + + feature.HandleKey(fyne.Key1) + panes = renderedPanes(feature.Overlay()) + if got := panes[1].image.Size(); got != fyne.NewSize(800, 400) { + t.Errorf("right pane after right-targeted 1 = %v, want actual size", got) + } + if got := normalizedCenter(panes[1]); !approxPosition(got, fyne.NewPos(0.5, 0.5)) { + t.Errorf("right pane center after right-targeted 1 = %v, want centered", got) + } + if panes[0].image.Size() != wantLeft { + t.Errorf("left pane after right-targeted 1 = %v, want unchanged %v", panes[0].image.Size(), wantLeft) + } + + feature.HandleKey(fyne.Key0) + panes = renderedPanes(feature.Overlay()) + if panes[1].image.Size() != wantRight || !approxPosition(normalizedCenter(panes[1]), fyne.NewPos(0.5, 0.5)) { + t.Errorf("right pane after right-targeted 0 = {%v %v}, want fitted and centered", panes[1].image.Size(), normalizedCenter(panes[1])) + } +} + +func TestCompareLinkToggle_UnlockKeepsCurrentCameraView(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + panes := renderedPanes(feature.Overlay()) + + feature.ToggleLink() + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) + paneHoverable(t, panes[1]).MouseIn(&desktop.MouseEvent{}) + feature.ToggleLink() + + feature.HandleKey(fyne.KeyPlus) + linked := renderedPanes(feature.Overlay()) + if linked[0].image.Size().Width != 625 || linked[1].image.Size().Width != 500 { + t.Fatalf("linked camera widths after + = %.2f and %.2f, want 625 and 500", linked[0].image.Size().Width, linked[1].image.Size().Width) + } + want := [2]struct { + size fyne.Size + position fyne.Position + }{ + {size: linked[0].image.Size(), position: linked[0].image.Position()}, + {size: linked[1].image.Size(), position: linked[1].image.Position()}, + } + + feature.ToggleLink() + unlocked := renderedPanes(feature.Overlay()) + for i, pane := range unlocked { + if pane.image.Size() != want[i].size || pane.image.Position() != want[i].position { + t.Errorf("pane %d moved while unlocking: got {%v %v}, want {%v %v}", + i, pane.image.Size(), pane.image.Position(), want[i].size, want[i].position) + } + } +} + +func TestCompareLinkToggle_RelockKeepsDivergentPhotoPoses(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + + feature.ToggleLink() + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) + panes = renderedPanes(feature.Overlay()) + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 20)}) + before := renderedPanes(feature.Overlay()) + want := [2]struct { + size fyne.Size + position fyne.Position + }{ + {size: before[0].image.Size(), position: before[0].image.Position()}, + {size: before[1].image.Size(), position: before[1].image.Position()}, + } + + feature.ToggleLink() + locked := renderedPanes(feature.Overlay()) + for i, pane := range locked { + if pane.image.Size() != want[i].size || pane.image.Position() != want[i].position { + t.Errorf("pane %d moved while locking: got {%v %v}, want {%v %v}", + i, pane.image.Size(), pane.image.Position(), want[i].size, want[i].position) + } + } +} + +func TestCompareLockedPan_MovesDivergentPhotoPosesAsOneCamera(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(1600, 800), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + + feature.ToggleLink() + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + for range 3 { + feature.HandleKey(fyne.KeyPlus) + } + panes = renderedPanes(feature.Overlay()) + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 20)}) + paneHoverable(t, renderedPanes(feature.Overlay())[1]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) + feature.ToggleLink() + + before := renderedPanes(feature.Overlay()) + want := [2]struct { + size fyne.Size + position fyne.Position + }{ + {size: before[0].image.Size(), position: before[0].image.Position()}, + {size: before[1].image.Size(), position: before[1].image.Position()}, + } + delta := fyne.NewDelta(20, 10) + paneDraggable(t, before[0]).Dragged(&fyne.DragEvent{Dragged: delta}) + after := renderedPanes(feature.Overlay()) + for i := range after { + if after[i].image.Size() != want[i].size { + t.Errorf("pane %d size after camera pan = %v, want unchanged %v", i, after[i].image.Size(), want[i].size) + } + if got, wantPosition := after[i].image.Position(), want[i].position.Add(delta); got != wantPosition { + t.Errorf("pane %d position after camera pan = %v, want %v", i, got, wantPosition) + } + } +} + +func TestCompareLockedFit_FramesDivergentPhotoPosesWithoutChangingTheirRatio(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(1600, 800), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + + feature.ToggleLink() + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + for range 3 { + feature.HandleKey(fyne.KeyPlus) + } + panes = renderedPanes(feature.Overlay()) + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(80, 40)}) + paneHoverable(t, renderedPanes(feature.Overlay())[1]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) + panes = renderedPanes(feature.Overlay()) + paneDraggable(t, panes[1]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(-40, -20)}) + feature.ToggleLink() + + before := renderedPanes(feature.Overlay()) + wantRatio := before[0].image.Size().Width / before[1].image.Size().Width + feature.HandleKey(fyne.Key0) + after := renderedPanes(feature.Overlay()) + if got := after[0].image.Size().Width / after[1].image.Size().Width; !uitest.ApproxEqual(got, wantRatio) { + t.Errorf("photo width ratio after camera fit = %.4f, want retained %.4f", got, wantRatio) + } + for i, pane := range after { + position, size, viewport := pane.image.Position(), pane.image.Size(), pane.root.Size() + if position.X < -0.01 || position.Y < -0.01 || + position.X+size.Width > viewport.Width+0.01 || position.Y+size.Height > viewport.Height+0.01 { + t.Errorf("pane %d after camera fit spans %v..%v in %v, want fully visible", + i, position, fyne.NewPos(position.X+size.Width, position.Y+size.Height), viewport) + } + } +} + +func TestCompareUnlinkedFit_ResetsOnlyTargetInCurrentCameraView(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + for range 2 { + feature.HandleKey(fyne.KeyPlus) + } + panes := renderedPanes(feature.Overlay()) + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(30, 20)}) + feature.ToggleLink() + panes = renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + wantRightSize := panes[1].image.Size() + wantRightPosition := panes[1].image.Position() + + feature.HandleKey(fyne.Key0) + panes = renderedPanes(feature.Overlay()) + if got, want := panes[0].image.Size(), fyne.NewSize(400, 200); got != want { + t.Errorf("target size after unlinked fit = %v, want %v", got, want) + } + if got, want := panes[0].image.Position(), fyne.NewPos(0, 100); !approxPosition(got, want) { + t.Errorf("target position after unlinked fit = %v, want %v", got, want) + } + if panes[1].image.Size() != wantRightSize || panes[1].image.Position() != wantRightPosition { + t.Errorf("other pane changed during unlinked fit: got {%v %v}, want {%v %v}", + panes[1].image.Size(), panes[1].image.Position(), wantRightSize, wantRightPosition) + } +} + +func TestCompareCameraPan_SurvivesUnlockWithoutChangingGeometry(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(1600, 800), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + + feature.ToggleLink() + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + for range 4 { + feature.HandleKey(fyne.KeyPlus) + } + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(70, 0)}) + paneHoverable(t, panes[1]).MouseIn(&desktop.MouseEvent{}) + for range 2 { + feature.HandleKey(fyne.KeyPlus) + } + feature.ToggleLink() + linkedBefore := renderedPanes(feature.Overlay()) + beforePositions := [2]fyne.Position{linkedBefore[0].image.Position(), linkedBefore[1].image.Position()} + paneDraggable(t, renderedPanes(feature.Overlay())[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(30, 0)}) + linkedAfter := renderedPanes(feature.Overlay()) + want := [2]struct { + size fyne.Size + position fyne.Position + }{ + {size: linkedAfter[0].image.Size(), position: linkedAfter[0].image.Position()}, + {size: linkedAfter[1].image.Size(), position: linkedAfter[1].image.Position()}, + } + for i := range linkedAfter { + if got, expected := linkedAfter[i].image.Position(), beforePositions[i].Add(fyne.NewPos(30, 0)); !approxPosition(got, expected) { + t.Errorf("pane %d camera-pan position = %v, want %v", i, got, expected) + } + } + + feature.ToggleLink() + unlocked := renderedPanes(feature.Overlay()) + for i := range unlocked { + if unlocked[i].image.Size() != want[i].size || !approxPosition(unlocked[i].image.Position(), want[i].position) { + t.Errorf("pane %d changed while unlocking after camera pan: got {%v %v}, want {%v %v}", + i, unlocked[i].image.Size(), unlocked[i].image.Position(), want[i].size, want[i].position) + } + } +} + +func TestCompareCameraWheel_PreservesDivergenceAndSurvivesUnlock(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(1600, 800), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + + feature.ToggleLink() + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + for range 2 { + feature.HandleKey(fyne.KeyPlus) + } + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(40, 0)}) + paneHoverable(t, panes[1]).MouseIn(&desktop.MouseEvent{}) + feature.ToggleLink() + linkedBefore := renderedPanes(feature.Overlay()) + cursor := fyne.NewPos(300, 200) + beforeSizes := [2]fyne.Size{linkedBefore[0].image.Size(), linkedBefore[1].image.Size()} + anchored := [2]fyne.Position{normalizedPoint(linkedBefore[0], cursor), normalizedPoint(linkedBefore[1], cursor)} + paneScrollable(t, linkedBefore[0]).Scrolled(&fyne.ScrollEvent{ + Position: cursor, + Scrolled: fyne.NewDelta(0, 10), + }) + linkedAfter := renderedPanes(feature.Overlay()) + ratio := linkedAfter[0].image.Size().Width / beforeSizes[0].Width + if got := linkedAfter[1].image.Size().Width / beforeSizes[1].Width; !uitest.ApproxEqual(got, ratio) { + t.Errorf("camera wheel ratios = %.4f and %.4f, want equal", ratio, got) + } + for i, pane := range linkedAfter { + if got := normalizedPoint(pane, cursor); !approxPosition(got, anchored[i]) { + t.Errorf("pane %d point under camera-wheel cursor = %v, want %v", i, got, anchored[i]) + } + } + want := [2]struct { + size fyne.Size + position fyne.Position + }{ + {size: linkedAfter[0].image.Size(), position: linkedAfter[0].image.Position()}, + {size: linkedAfter[1].image.Size(), position: linkedAfter[1].image.Position()}, + } + + feature.ToggleLink() + unlocked := renderedPanes(feature.Overlay()) + for i := range unlocked { + if unlocked[i].image.Size() != want[i].size || !approxPosition(unlocked[i].image.Position(), want[i].position) { + t.Errorf("pane %d changed while unlocking after camera wheel: got {%v %v}, want {%v %v}", + i, unlocked[i].image.Size(), unlocked[i].image.Position(), want[i].size, want[i].position) + } + } +} + +func TestCompareSwapWhileUnlinked_LocksAndClearsDivergentPhotoPoses(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + + feature.ToggleLink() + link := comparisonButton(t, feature.Overlay(), "Link") + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) + if panes = renderedPanes(feature.Overlay()); panes[0].image.Size().Width != 500 || panes[1].image.Size().Width != 400 { + t.Fatalf("unlinked setup widths = %.2f and %.2f, want 500 and 400", panes[0].image.Size().Width, panes[1].image.Size().Width) + } + + test.Tap(comparisonButton(t, feature.Overlay(), "Swap")) + if got := comparisonButton(t, feature.Overlay(), "Unlink"); got != link { + t.Fatal("Swap replaced the link control instead of restoring its Unlink state") + } + panes = renderedPanes(feature.Overlay()) + if panes[0].image.Size().Width != 500 || panes[1].image.Size().Width != 500 { + t.Errorf("widths after Swap relink = %.2f and %.2f, want winning 500 shared", panes[0].image.Size().Width, panes[1].image.Size().Width) + } + for _, text := range labelTexts(feature.Overlay()) { + if strings.HasPrefix(text, "Unlinked") { + t.Errorf("labels after Swap while unlinked = %v, want no unlink status", labelTexts(feature.Overlay())) + break + } + } + + beforeWidths := [2]float32{panes[0].image.Size().Width, panes[1].image.Size().Width} + paneScrollable(t, panes[0]).Scrolled(&fyne.ScrollEvent{ + Position: fyne.NewPos(200, 200), + Scrolled: fyne.NewDelta(0, 10), + }) + panes = renderedPanes(feature.Overlay()) + leftRatio := panes[0].image.Size().Width / beforeWidths[0] + rightRatio := panes[1].image.Size().Width / beforeWidths[1] + if leftRatio <= 1 || !uitest.ApproxEqual(leftRatio, rightRatio) { + t.Errorf("wheel ratios after Swap = %.4f and %.4f, want one linked increase", leftRatio, rightRatio) } - if left, right := normalizedCenter(afterShiftWheel[0]), normalizedCenter(afterShiftWheel[1]); !approxPosition(left, right) { - t.Errorf("normalized centers after Shift+wheel = left %v right %v, want linked", left, right) + + feature.ToggleLink() + panes = renderedPanes(feature.Overlay()) + if panes[0].image.Size() != panes[1].image.Size() || !approxPosition(normalizedCenter(panes[0]), normalizedCenter(panes[1])) { + t.Errorf("photo poses after Swap and fresh unlink = {%v %v} and {%v %v}, want reset together", + panes[0].image.Size(), normalizedCenter(panes[0]), panes[1].image.Size(), normalizedCenter(panes[1])) } } -func TestComparePanInputs_CursorReportsSharedPanAvailability(t *testing.T) { +func TestCompareOpenStartsLinkedAndFreshToggleUnlinks(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + modifiers := fyne.KeyModifierControl + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{Modifiers: func() fyne.KeyModifier { return modifiers }}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + unlink := comparisonButton(t, feature.Overlay(), "Unlink") + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + if got := labelTexts(feature.Overlay()); slices.Contains(got, "Unlinked: Left") { + t.Fatalf("labels after opening with Control held = %v, want linked start", got) + } + + feature.HandleKey(fyne.KeyPlus) + panes = renderedPanes(feature.Overlay()) + if panes[0].image.Size().Width != 500 || panes[1].image.Size().Width != 500 { + t.Errorf("first linked + widths = %.2f and %.2f, want 500 and 500", panes[0].image.Size().Width, panes[1].image.Size().Width) + } + + feature.ToggleLink() + if got := comparisonButton(t, feature.Overlay(), "Link"); got != unlink { + t.Fatal("fresh link toggle replaced the Unlink control instead of relabeling it") + } + if got := labelTexts(feature.Overlay()); !slices.Contains(got, "Unlinked: Left") { + t.Fatalf("labels after fresh toggle = %v, want Unlinked: Left", got) + } + beforeRight := renderedPanes(feature.Overlay())[1].image.Size() + feature.HandleKey(fyne.KeyPlus) + panes = renderedPanes(feature.Overlay()) + if panes[0].image.Size().Width <= 500 || panes[1].image.Size() != beforeRight { + t.Errorf("unlinked + sizes = %v and %v, want only left enlarged from 500", panes[0].image.Size(), panes[1].image.Size()) + } +} + +func TestCompareUnlinkedShiftWheel_AllowsOnlyTheTargetToOverscrollToImageEdgeAtPaneCenter(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + var modifiers fyne.KeyModifier + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{Modifiers: func() fyne.KeyModifier { return modifiers }}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + modifiers = fyne.KeyModifierShift + feature.ToggleLink() + + panes := renderedPanes(feature.Overlay()) + if got := paneCursorable(t, panes[0]).Cursor(); got != desktop.PointerCursor { + t.Errorf("fitted pane cursor while unlinked = %v, want pointer because local overscroll is available", got) + } + paneScrollable(t, panes[0]).Scrolled(&fyne.ScrollEvent{Scrolled: fyne.NewDelta(10000, -10000)}) + panes = renderedPanes(feature.Overlay()) + if got := normalizedCenter(panes[0]); !approxPosition(got, fyne.NewPos(0, 1)) { + t.Errorf("target local center after extreme unlinked Shift-wheel = %v, want edge clamp {0 1}", got) + } + if got := normalizedCenter(panes[1]); !approxPosition(got, fyne.NewPos(0.5, 0.5)) { + t.Errorf("other local center after extreme unlinked Shift-wheel = %v, want unchanged", got) + } + if !uitest.ApproxEqual(panes[0].image.Position().X, panes[0].root.Size().Width/2) || + !uitest.ApproxEqual(panes[0].image.Position().Y+panes[0].image.Size().Height, panes[0].root.Size().Height/2) { + t.Errorf("overscrolled target span = {%v %v} in %v, want selected image edges at pane center", + panes[0].image.Position(), panes[0].image.Size(), panes[0].root.Size()) + } +} + +func TestCompareUnlinkedPan_AfterCameraPanStopsAtPaneCenter(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + + panes := renderedPanes(feature.Overlay()) + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(80, 40)}) + feature.ToggleLink() + panes = renderedPanes(feature.Overlay()) + wantOtherSize := panes[1].image.Size() + wantOtherPosition := panes[1].image.Position() + + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(10000, -10000)}) + panes = renderedPanes(feature.Overlay()) + left := panes[0] + if !uitest.ApproxEqual(left.image.Position().X, left.root.Size().Width/2) || + !uitest.ApproxEqual(left.image.Position().Y+left.image.Size().Height, left.root.Size().Height/2) { + t.Errorf("camera-offset target span = {%v %v} in %v, want selected image edges at pane center", + left.image.Position(), left.image.Size(), left.root.Size()) + } + assertPaneOverlapsCenter(t, left) + if panes[1].image.Size() != wantOtherSize || !approxPosition(panes[1].image.Position(), wantOtherPosition) { + t.Errorf("other pane changed during camera-offset local pan: got {%v %v}, want {%v %v}", + panes[1].image.Size(), panes[1].image.Position(), wantOtherSize, wantOtherPosition) + } +} + +func TestCompareLinkToggle_RepeatedTogglesNeverChangeDivergentGeometry(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), storage.NewFileURI("tall.png"), }) waitForDone(t, feature) + test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) + feature.ToggleLink() panes := renderedPanes(feature.Overlay()) - if got := paneCursorable(t, panes[0]).Cursor(); got != desktop.DefaultCursor { - t.Errorf("fitted comparison cursor = %v, want default", got) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(10000, 0)}) + paneHoverable(t, renderedPanes(feature.Overlay())[1]).MouseIn(&desktop.MouseEvent{}) + before := renderedPanes(feature.Overlay()) + want := [2]struct { + size fyne.Size + position fyne.Position + }{ + {size: before[0].image.Size(), position: before[0].image.Position()}, + {size: before[1].image.Size(), position: before[1].image.Position()}, + } + + for toggle := range 4 { + feature.ToggleLink() + panes := renderedPanes(feature.Overlay()) + for i, pane := range panes { + if pane.image.Size() != want[i].size || !approxPosition(pane.image.Position(), want[i].position) { + t.Errorf("toggle %d pane %d geometry = {%v %v}, want unchanged {%v %v}", + toggle+1, i, pane.image.Size(), pane.image.Position(), want[i].size, want[i].position) + } + } + } +} + +func TestCompareUnlinkedLayoutAndResize_PreserveEachLocalCenterModeAndFactor(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return loadedImage(800, 400), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + waitForDone(t, feature) + feature.ToggleLink() + + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.Key1) + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(80, 40)}) + paneHoverable(t, panes[1]).MouseIn(&desktop.MouseEvent{}) + feature.HandleKey(fyne.KeyPlus) + before := renderedPanes(feature.Overlay()) + wantCenters := [2]fyne.Position{normalizedCenter(before[0]), normalizedCenter(before[1])} + + test.Tap(comparisonButton(t, feature.Overlay(), "Swipe")) + inSwipe := renderedPanes(feature.Overlay()) + if got := inSwipe[0].image.Size(); got != fyne.NewSize(800, 400) { + t.Errorf("absolute local size in swipe = %v, want retained 800x400", got) + } + if got := inSwipe[1].image.Size(); got != fyne.NewSize(1000, 500) { + t.Errorf("fit-relative local size in swipe = %v, want recomputed 1000x500", got) + } + for i, pane := range inSwipe { + if got := normalizedCenter(pane); !approxPosition(got, wantCenters[i]) { + t.Errorf("swipe local pane %d center = %v, want retained %v", i, got, wantCenters[i]) + } + } + + feature.Overlay().Resize(fyne.NewSize(1000, 600)) + afterResize := renderedPanes(feature.Overlay()) + if got := afterResize[0].image.Size(); got != fyne.NewSize(800, 400) { + t.Errorf("absolute local size after resize = %v, want retained 800x400", got) + } + if got := afterResize[1].image.Size(); got != fyne.NewSize(1250, 625) { + t.Errorf("fit-relative local size after resize = %v, want recomputed 1250x625", got) + } + for i, pane := range afterResize { + if got := normalizedCenter(pane); !approxPosition(got, wantCenters[i]) { + t.Errorf("resized local pane %d center = %v, want retained %v", i, got, wantCenters[i]) + } + } +} + +func TestCompareCameraFitAndHome_DoNotRewritePhotoPoses(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + if uri.Name() == "wide.png" { + return loadedImage(800, 400), nil + } + return loadedImage(200, 800), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("wide.png"), + storage.NewFileURI("tall.png"), + }) + waitForDone(t, feature) + feature.ToggleLink() + + panes := renderedPanes(feature.Overlay()) + paneHoverable(t, panes[0]).MouseIn(&desktop.MouseEvent{}) + for range 3 { + feature.HandleKey(fyne.KeyPlus) + } + paneDraggable(t, panes[0]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(60, 20)}) + paneHoverable(t, panes[1]).MouseIn(&desktop.MouseEvent{}) + for range 2 { + feature.HandleKey(fyne.KeyPlus) + } + paneDraggable(t, panes[1]).Dragged(&fyne.DragEvent{Dragged: fyne.NewDelta(-40, -20)}) + home := renderedPanes(feature.Overlay()) + wantHome := [2]struct { + size fyne.Size + position fyne.Position + }{ + {size: home[0].image.Size(), position: home[0].image.Position()}, + {size: home[1].image.Size(), position: home[1].image.Position()}, + } + wantRatio := wantHome[0].size.Width / wantHome[1].size.Width + + feature.ToggleLink() + feature.HandleKey(fyne.Key0) + afterFit := renderedPanes(feature.Overlay()) + if got := afterFit[0].image.Size().Width / afterFit[1].image.Size().Width; !uitest.ApproxEqual(got, wantRatio) { + t.Errorf("photo ratio after camera fit = %.4f, want retained %.4f", got, wantRatio) + } + wantFit := [2]struct { + size fyne.Size + position fyne.Position + }{ + {size: afterFit[0].image.Size(), position: afterFit[0].image.Position()}, + {size: afterFit[1].image.Size(), position: afterFit[1].image.Position()}, + } + + feature.ToggleLink() + feature.ToggleLink() + stillFit := renderedPanes(feature.Overlay()) + for i, pane := range stillFit { + if pane.image.Size() != wantFit[i].size || !approxPosition(pane.image.Position(), wantFit[i].position) { + t.Errorf("pane %d camera-fit geometry changed across toggles: got {%v %v}, want {%v %v}", + i, pane.image.Size(), pane.image.Position(), wantFit[i].size, wantFit[i].position) + } + } + + feature.HandleKey(fyne.Key1) + afterHome := renderedPanes(feature.Overlay()) + for i, pane := range afterHome { + if pane.image.Size() != wantHome[i].size || !approxPosition(pane.image.Position(), wantHome[i].position) { + t.Errorf("pane %d after camera home = {%v %v}, want stored photo pose {%v %v}", + i, pane.image.Size(), pane.image.Position(), wantHome[i].size, wantHome[i].position) + } + } +} + +func TestCompareCameraPan_CursorReportsTableMovementAvailability(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + if uri.Name() == "wide.png" { + return loadedImage(800, 400), nil + } + return loadedImage(200, 800), nil + }, Callbacks{}) + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("wide.png"), + storage.NewFileURI("tall.png"), + }) + waitForDone(t, feature) + + panes := renderedPanes(feature.Overlay()) + if got := paneCursorable(t, panes[0]).Cursor(); got != desktop.PointerCursor { + t.Errorf("fitted comparison cursor = %v, want pointer for camera movement", got) } for range 7 { feature.HandleKey(fyne.KeyPlus) @@ -1830,16 +3110,16 @@ func TestComparePanInputs_CursorReportsSharedPanAvailability(t *testing.T) { } } -func TestCompareNoOverscroll_RepeatedExtremePanStopsAtSharedBoundaries(t *testing.T) { +func TestCompareCameraPan_RepeatedExtremeInputKeepsBothPhotosOverPaneCenters(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), @@ -1859,21 +3139,21 @@ func TestCompareNoOverscroll_RepeatedExtremePanStopsAtSharedBoundaries(t *testin paneDraggable(t, panes[i%2]).Dragged(&fyne.DragEvent{Dragged: delta}) panes = renderedPanes(feature.Overlay()) for _, pane := range panes { - assertPaneCoversOrCenters(t, pane) + assertPaneOverlapsCenter(t, pane) } } } -func TestCompareNoDrift_RepeatedInputOnEitherPaneKeepsOneCenter(t *testing.T) { +func TestCompareCameraPan_RepeatedInputOnEitherPaneKeepsOnePointDelta(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), @@ -1890,10 +3170,16 @@ func TestCompareNoDrift_RepeatedInputOnEitherPaneKeepsOneCenter(t *testing.T) { if i%2 != 0 { delta = fyne.NewDelta(-3, 2) } + wantPositions := [2]fyne.Position{ + panes[0].image.Position().Add(delta), + panes[1].image.Position().Add(delta), + } paneDraggable(t, panes[i%2]).Dragged(&fyne.DragEvent{Dragged: delta}) panes = renderedPanes(feature.Overlay()) - if left, right := normalizedCenter(panes[0]), normalizedCenter(panes[1]); !approxPosition(left, right) { - t.Fatalf("iteration %d normalized centers = left %v right %v, want linked", i, left, right) + for pane, want := range wantPositions { + if got := panes[pane].image.Position(); !approxPosition(got, want) { + t.Fatalf("iteration %d pane %d position = %v, want camera delta at %v", i, pane, got, want) + } } } } @@ -1902,12 +3188,12 @@ func TestCompareFitReset_PanThenZeroRestoresCanonicalTransform(t *testing.T) { app := test.NewApp() t.Cleanup(app.Quit) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "wide.png" { return loadedImage(800, 400), nil } return loadedImage(200, 800), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Overlay().Resize(fyne.NewSize(800, 400)) feature.Open([2]fyne.URI{ storage.NewFileURI("wide.png"), @@ -1944,11 +3230,11 @@ func TestCompareLoading_StartsBothSourcesConcurrentlyAndCompletesReady(t *testin started := make(chan string, 2) release := make(chan struct{}) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { started <- uri.Name() <-release return loadedImage(32, 24), nil - }, compare.Callbacks{}) + }, Callbacks{}) feature.Open([2]fyne.URI{ storage.NewFileURI("left.png"), storage.NewFileURI("right.png"), @@ -1975,12 +3261,12 @@ func TestCompareOpen_NotifiesOwnerBeforeLoaderStarts(t *testing.T) { var opened atomic.Bool var loaderStartedEarly atomic.Bool - feature := compare.New(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { if !opened.Load() { loaderStartedEarly.Store(true) } return loadedImage(32, 24), nil - }, compare.Callbacks{Opened: func() { + }, Callbacks{Opened: func() { opened.Store(true) }}) feature.Open([2]fyne.URI{ @@ -2004,12 +3290,12 @@ func TestCompareCancel_BackCancelsBothWorkersWithoutFailure(t *testing.T) { started := make(chan string, 2) cancelled := make(chan string, 2) failures := 0 - feature := compare.New(func(ctx context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(ctx context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { started <- uri.Name() <-ctx.Done() cancelled <- uri.Name() return nil, ctx.Err() - }, compare.Callbacks{Failed: func(fyne.URI, error) { failures++ }}) + }, Callbacks{Failed: func(fyne.URI, error) { failures++ }}) feature.Open([2]fyne.URI{ storage.NewFileURI("left.png"), storage.NewFileURI("right.png"), @@ -2039,14 +3325,14 @@ func TestCompareFailure_ClosesAndCancelsTheOtherSide(t *testing.T) { cancelled := make(chan string, 1) var failedURI fyne.URI var failedErr error - feature := compare.New(func(ctx context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(ctx context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == failingName { return nil, wantErr } <-ctx.Done() cancelled <- uri.Name() return nil, ctx.Err() - }, compare.Callbacks{Failed: func(uri fyne.URI, err error) { + }, Callbacks{Failed: func(uri fyne.URI, err error) { failedURI, failedErr = uri, err }}) feature.Open([2]fyne.URI{ @@ -2085,7 +3371,7 @@ func TestCompareStale_OlderCompletionCannotRepaintANewerSession(t *testing.T) { oldStarted := make(chan string, 2) newLeft := loadedImage(101, 51) newRight := loadedImage(202, 52) - feature := compare.New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { switch uri.Name() { case "old-left.png", "old-right.png": oldStarted <- uri.Name() @@ -2096,7 +3382,7 @@ func TestCompareStale_OlderCompletionCannotRepaintANewerSession(t *testing.T) { default: return newRight, nil } - }, compare.Callbacks{}) + }, Callbacks{}) feature.Open([2]fyne.URI{ storage.NewFileURI("old-left.png"), storage.NewFileURI("old-right.png"), diff --git a/internal/ui/compare/input.go b/internal/ui/compare/input.go index ce2958c..068ba62 100644 --- a/internal/ui/compare/input.go +++ b/internal/ui/compare/input.go @@ -56,8 +56,8 @@ func (*inputShield) MouseOut() {} func (*inputShield) Cursor() desktop.Cursor { return desktop.DefaultCursor } // paneInput is the interactive layer inside one clipped image viewport. It -// forwards pointer intent to the Feature, which owns the single transform -// shared by both panes. +// forwards pointer intent and hover targeting to the Feature, which chooses +// the shared camera or that pane's photo transform. type paneInput struct { widget.BaseWidget @@ -68,6 +68,7 @@ type paneInput struct { var ( _ fyne.Draggable = (*paneInput)(nil) _ fyne.Scrollable = (*paneInput)(nil) + _ desktop.Hoverable = (*paneInput)(nil) _ desktop.Cursorable = (*paneInput)(nil) ) @@ -82,7 +83,12 @@ func (p *paneInput) CreateRenderer() fyne.WidgetRenderer { } func (p *paneInput) Scrolled(ev *fyne.ScrollEvent) { - p.feature.handleScroll(p.index, ev) + if ev == nil { + return + } + viewportEvent := *ev + viewportEvent.Position = viewportEvent.Position.Add(p.Position()) + p.feature.handleScroll(p.index, &viewportEvent) } func (p *paneInput) Dragged(ev *fyne.DragEvent) { @@ -93,8 +99,18 @@ func (p *paneInput) Dragged(ev *fyne.DragEvent) { func (*paneInput) DragEnd() {} +func (p *paneInput) MouseIn(_ *desktop.MouseEvent) { + p.feature.setHoveredPane(p.index) +} + +func (p *paneInput) MouseMoved(_ *desktop.MouseEvent) { + p.feature.setHoveredPane(p.index) +} + +func (*paneInput) MouseOut() {} + func (p *paneInput) Cursor() desktop.Cursor { - if p.feature.canPan() { + if p.feature.canPan(p.index) { return desktop.PointerCursor } return desktop.DefaultCursor diff --git a/internal/ui/compare/renderer.go b/internal/ui/compare/renderer.go new file mode 100644 index 0000000..22c1b6c --- /dev/null +++ b/internal/ui/compare/renderer.go @@ -0,0 +1,133 @@ +package compare + +import ( + "context" + "errors" + "image" + "image/draw" + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + + xdraw "golang.org/x/image/draw" + + "github.com/frathe/picfetch/internal/imaging" +) + +const overviewMaxDimension = 1024 + +// renderSource is the immutable image identity handed to a pane renderer. It +// retains the canonical decoded frame, a bounded overview, and the source's +// byte-budgeted detail-tile cache. +type renderSource struct { + frame image.Image + overview image.Image + tiles *imaging.ByteCache[*renderTile] +} + +func prepareRenderSource(ctx context.Context, frame image.Image) (*renderSource, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if frame == nil { + return nil, errors.New("decoded image has no frame") + } + bounds := frame.Bounds() + width, height := bounds.Dx(), bounds.Dy() + if width <= 0 || height <= 0 { + return nil, errors.New("decoded image has invalid dimensions") + } + if width <= overviewMaxDimension && height <= overviewMaxDimension { + return newPreparedRenderSource(frame, frame), nil + } + + scale := float64(overviewMaxDimension) / float64(max(width, height)) + overviewWidth := max(1, int(math.Round(float64(width)*scale))) + overviewHeight := max(1, int(math.Round(float64(height)*scale))) + overview := image.NewRGBA(image.Rect(0, 0, overviewWidth, overviewHeight)) + xdraw.ApproxBiLinear.Scale(overview, overview.Bounds(), frame, bounds, draw.Src, nil) + if err := ctx.Err(); err != nil { + return nil, err + } + return newPreparedRenderSource(frame, overview), nil +} + +func newPreparedRenderSource(frame, overview image.Image) *renderSource { + return &renderSource{ + frame: frame, + overview: overview, + tiles: imaging.NewByteCache(tileCacheBudgetBytes, func(tile *renderTile) int64 { + if tile == nil || tile.texture == nil { + return 0 + } + return int64(len(tile.texture.Pix)) + }), + } +} + +// paneScene is one complete presentation snapshot. Logical geometry stays in +// Fyne points while panePosition and displaySize record physical pixels for +// fragment lookup, mip selection, and vector raster targets. +type paneScene struct { + source *renderSource + viewport fyne.Size + revealSet bool + revealPosition fyne.Position + revealSize fyne.Size + imagePosition fyne.Position + imageSize fyne.Size + panePosition image.Point + displaySize image.Point +} + +// paneRenderer is the private boundary between comparison transform policy and +// a concrete canvas implementation. Present runs on the UI path; Wait makes +// renderer-owned asynchronous work observable to Feature.Settle. +type paneRenderer interface { + Object() fyne.CanvasObject + Present(paneScene) + Wait(context.Context) error +} + +type paneRendererFactory func(index int) paneRenderer + +type paneRendererQueue interface { + setQueueUI(func(func())) +} + +// canvasPaneRenderer is the deterministic reference adapter used by unit tests +// that exercise comparison geometry and source identity. Production uses the +// tiled shader adapter. +type canvasPaneRenderer struct { + image *canvas.Image +} + +func newCanvasPaneRenderer(_ int) paneRenderer { + img := canvas.NewImageFromImage(nil) + img.FillMode = canvas.ImageFillContain + img.ScaleMode = canvas.ImageScaleSmooth + img.Hide() + return &canvasPaneRenderer{image: img} +} + +func (r *canvasPaneRenderer) Object() fyne.CanvasObject { return r.image } + +func (r *canvasPaneRenderer) Present(scene paneScene) { + if scene.source == nil || scene.source.frame == nil { + r.image.Image = nil + r.image.Hide() + return + } + + changed := r.image.Image != scene.source.frame + r.image.Image = scene.source.frame + r.image.Resize(scene.imageSize) + r.image.Move(scene.imagePosition) + r.image.Show() + if changed { + r.image.Refresh() + } +} + +func (*canvasPaneRenderer) Wait(_ context.Context) error { return nil } diff --git a/internal/ui/compare/renderer_test.go b/internal/ui/compare/renderer_test.go new file mode 100644 index 0000000..679206f --- /dev/null +++ b/internal/ui/compare/renderer_test.go @@ -0,0 +1,195 @@ +package compare + +import ( + "context" + "image" + "testing" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/storage" + fynetest "fyne.io/fyne/v2/test" + + "github.com/frathe/picfetch/internal/imaging" +) + +type recordingPaneRenderer struct { + object *canvas.Rectangle + scenes []paneScene + waits int +} + +func newRecordingPaneRenderer() *recordingPaneRenderer { + return &recordingPaneRenderer{object: canvas.NewRectangle(nil)} +} + +func (r *recordingPaneRenderer) Object() fyne.CanvasObject { return r.object } + +func (r *recordingPaneRenderer) Present(scene paneScene) { + r.scenes = append(r.scenes, scene) +} + +func (r *recordingPaneRenderer) Wait(_ context.Context) error { + r.waits++ + return nil +} + +func (r *recordingPaneRenderer) latest(t *testing.T) paneScene { + t.Helper() + if len(r.scenes) == 0 { + t.Fatal("renderer received no scene") + } + return r.scenes[len(r.scenes)-1] +} + +func TestPaneRendererScene_PresentsStableSourceGeometryAndLifecycle(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + renderers := [2]*recordingPaneRenderer{ + newRecordingPaneRenderer(), + newRecordingPaneRenderer(), + } + objects := [2]fyne.CanvasObject{renderers[0].Object(), renderers[1].Object()} + feature := newFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + size := image.Pt(1200, 600) + if uri.Name() == "right.png" { + size = image.Pt(300, 900) + } + return &imaging.LoadedImage{Frames: []image.Image{image.NewRGBA(image.Rectangle{Max: size})}}, nil + }, Callbacks{}, func(index int) paneRenderer { return renderers[index] }) + pixelAnchors := map[fyne.CanvasObject]bool{ + feature.panes[0].input: true, + feature.panes[1].input: true, + } + feature.vectorPixels = func(object fyne.CanvasObject, _ fyne.Size) (int, int) { + if !pixelAnchors[object] { + t.Errorf("physical-pixel lookup anchor = %T, want a visible pane input", object) + } + return 800, 400 + } + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := feature.Settle(ctx); err != nil { + t.Fatalf("Settle after Open: %v", err) + } + + left := renderers[0].latest(t) + if left.source == nil || left.source.frame.Bounds().Size() != image.Pt(1200, 600) { + t.Fatalf("left scene source = %#v, want 1200x600", left.source) + } + if left.viewport != fyne.NewSize(400, 400) || left.imagePosition != fyne.NewPos(0, 100) || left.imageSize != fyne.NewSize(400, 200) { + t.Errorf("left scene geometry = viewport %v position %v size %v, want 400x400, (0,100), 400x200", + left.viewport, left.imagePosition, left.imageSize) + } + if left.displaySize != image.Pt(800, 400) { + t.Errorf("left physical display size = %v, want 800x400", left.displaySize) + } + + feature.HandleKey(fyne.KeyPlus) + zoomed := renderers[0].latest(t) + if zoomed.imageSize != fyne.NewSize(500, 250) { + t.Errorf("zoomed left scene size = %v, want 500x250", zoomed.imageSize) + } + for i, renderer := range renderers { + if renderer.Object() != objects[i] { + t.Errorf("pane %d renderer object changed across interaction", i) + } + } + + feature.layoutMode = swipe + feature.dividerAt = 0.25 + feature.layoutSwipe(fyne.NewSize(800, 400)) + leftReveal := renderers[0].latest(t) + rightReveal := renderers[1].latest(t) + if !leftReveal.revealSet || leftReveal.revealPosition != (fyne.Position{}) || leftReveal.revealSize != fyne.NewSize(200, 400) { + t.Errorf("left swipe reveal = set %v position %v size %v, want true (0,0) 200x400", + leftReveal.revealSet, leftReveal.revealPosition, leftReveal.revealSize) + } + if !rightReveal.revealSet || rightReveal.revealPosition != fyne.NewPos(200, 0) || rightReveal.revealSize != fyne.NewSize(600, 400) { + t.Errorf("right swipe reveal = set %v position %v size %v, want true (200,0) 600x400", + rightReveal.revealSet, rightReveal.revealPosition, rightReveal.revealSize) + } + + feature.swapSides() + left = renderers[0].latest(t) + if left.source == nil || left.source.frame.Bounds().Size() != image.Pt(300, 900) { + t.Fatalf("left source after Swap = %#v, want former right 300x900", left.source) + } + + feature.Close() + for i, renderer := range renderers { + if scene := renderer.latest(t); scene.source != nil { + t.Errorf("pane %d source after Close = %#v, want nil", i, scene.source) + } + if renderer.waits == 0 { + t.Errorf("pane %d renderer Wait was not included in Settle", i) + } + } +} + +func TestPaneRendererScene_DividerMoveRepublishesRevealWithoutTransform(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + renderers := [2]*recordingPaneRenderer{ + newRecordingPaneRenderer(), + newRecordingPaneRenderer(), + } + feature := newFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return &imaging.LoadedImage{Frames: []image.Image{image.NewRGBA(image.Rect(0, 0, 1600, 800))}}, nil + }, Callbacks{}, func(index int) paneRenderer { return renderers[index] }) + feature.vectorPixels = func(fyne.CanvasObject, fyne.Size) (int, int) { return 800, 400 } + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := feature.Settle(ctx); err != nil { + t.Fatalf("Settle after Open: %v", err) + } + feature.layoutMode = swipe + feature.layoutSwipe(fyne.NewSize(800, 400)) + + before := [2]paneScene{renderers[0].latest(t), renderers[1].latest(t)} + beforeCounts := [2]int{len(renderers[0].scenes), len(renderers[1].scenes)} + feature.HandleKey(fyne.KeyEnd) + + wantReveals := [2]struct { + position fyne.Position + size fyne.Size + }{ + {size: fyne.NewSize(800, 400)}, + {position: fyne.NewPos(800, 0), size: fyne.NewSize(0, 400)}, + } + for i, renderer := range renderers { + if got := len(renderer.scenes); got != beforeCounts[i]+1 { + t.Errorf("pane %d presentations after divider move = %d, want %d", i, got, beforeCounts[i]+1) + } + got := renderer.latest(t) + want := before[i] + want.revealPosition = wantReveals[i].position + want.revealSize = wantReveals[i].size + want.panePosition = displayPixelPosition(feature.panes[i].root) + if got != want { + t.Errorf("pane %d scene after divider move = %+v, want reveal update %+v", i, got, want) + } + } +} + +func TestNew_UsesTiledShaderRenderersForBothPanes(t *testing.T) { + feature := New(nil, Callbacks{}) + for i := range feature.panes { + if _, ok := feature.panes[i].renderer.(*shaderPaneRenderer); !ok { + t.Errorf("pane %d renderer type = %T, want *shaderPaneRenderer", i, feature.panes[i].renderer) + } + } +} diff --git a/internal/ui/compare/shader.go b/internal/ui/compare/shader.go new file mode 100644 index 0000000..a60392d --- /dev/null +++ b/internal/ui/compare/shader.go @@ -0,0 +1,647 @@ +package compare + +import ( + "context" + "errors" + "image" + "slices" + "strconv" + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" +) + +const tileShaderSourceDesktop = `#version 110 +uniform vec2 frame; +uniform vec4 bounds; +uniform float paneX; +uniform float paneY; +uniform sampler2D overview; +uniform sampler2D detail0; +uniform sampler2D detail1; +uniform sampler2D detail2; +uniform sampler2D detail3; +uniform sampler2D detail4; +uniform sampler2D detail5; +uniform sampler2D detail6; +uniform float tile0MinX; +uniform float tile0MinY; +uniform float tile0StepX; +uniform float tile0StepY; +uniform float tile0Width; +uniform float tile0Height; +uniform float tile1MinX; +uniform float tile1MinY; +uniform float tile1StepX; +uniform float tile1StepY; +uniform float tile1Width; +uniform float tile1Height; +uniform float tile2MinX; +uniform float tile2MinY; +uniform float tile2StepX; +uniform float tile2StepY; +uniform float tile2Width; +uniform float tile2Height; +uniform float tile3MinX; +uniform float tile3MinY; +uniform float tile3StepX; +uniform float tile3StepY; +uniform float tile3Width; +uniform float tile3Height; +uniform float tile4MinX; +uniform float tile4MinY; +uniform float tile4StepX; +uniform float tile4StepY; +uniform float tile4Width; +uniform float tile4Height; +uniform float tile5MinX; +uniform float tile5MinY; +uniform float tile5StepX; +uniform float tile5StepY; +uniform float tile5Width; +uniform float tile5Height; +uniform float tile6MinX; +uniform float tile6MinY; +uniform float tile6StepX; +uniform float tile6StepY; +uniform float tile6Width; +uniform float tile6Height; + +bool tileContains(vec2 position, float minX, float minY, float stepX, float stepY, float width, float height) { + vec2 minimum = vec2(minX, minY); + vec2 maximum = minimum + max(vec2(width, height) - vec2(2.0), vec2(0.0)) * vec2(stepX, stepY); + return position.x >= minimum.x && position.x < maximum.x && + position.y >= minimum.y && position.y < maximum.y; +} + +vec2 tileCoordinate(vec2 position, float minX, float minY, float stepX, float stepY, float width, float height) { + vec2 texel = (position - vec2(minX, minY)) / vec2(stepX, stepY) + vec2(1.0); + return texel / vec2(width, height); +} + +void main() { + vec2 pixel = vec2(gl_FragCoord.x - paneX, (frame.y - gl_FragCoord.y) - paneY); + vec4 color = vec4(0.0); + float bestStep = 0.0; + + if (min(tile0StepX, tile0StepY) > 0.0 && (bestStep == 0.0 || max(tile0StepX, tile0StepY) < bestStep) && tileContains(pixel, tile0MinX, tile0MinY, tile0StepX, tile0StepY, tile0Width, tile0Height)) { + color = texture2D(detail0, tileCoordinate(pixel, tile0MinX, tile0MinY, tile0StepX, tile0StepY, tile0Width, tile0Height)); + bestStep = max(tile0StepX, tile0StepY); + } + if (min(tile1StepX, tile1StepY) > 0.0 && (bestStep == 0.0 || max(tile1StepX, tile1StepY) < bestStep) && tileContains(pixel, tile1MinX, tile1MinY, tile1StepX, tile1StepY, tile1Width, tile1Height)) { + color = texture2D(detail1, tileCoordinate(pixel, tile1MinX, tile1MinY, tile1StepX, tile1StepY, tile1Width, tile1Height)); + bestStep = max(tile1StepX, tile1StepY); + } + if (min(tile2StepX, tile2StepY) > 0.0 && (bestStep == 0.0 || max(tile2StepX, tile2StepY) < bestStep) && tileContains(pixel, tile2MinX, tile2MinY, tile2StepX, tile2StepY, tile2Width, tile2Height)) { + color = texture2D(detail2, tileCoordinate(pixel, tile2MinX, tile2MinY, tile2StepX, tile2StepY, tile2Width, tile2Height)); + bestStep = max(tile2StepX, tile2StepY); + } + if (min(tile3StepX, tile3StepY) > 0.0 && (bestStep == 0.0 || max(tile3StepX, tile3StepY) < bestStep) && tileContains(pixel, tile3MinX, tile3MinY, tile3StepX, tile3StepY, tile3Width, tile3Height)) { + color = texture2D(detail3, tileCoordinate(pixel, tile3MinX, tile3MinY, tile3StepX, tile3StepY, tile3Width, tile3Height)); + bestStep = max(tile3StepX, tile3StepY); + } + if (min(tile4StepX, tile4StepY) > 0.0 && (bestStep == 0.0 || max(tile4StepX, tile4StepY) < bestStep) && tileContains(pixel, tile4MinX, tile4MinY, tile4StepX, tile4StepY, tile4Width, tile4Height)) { + color = texture2D(detail4, tileCoordinate(pixel, tile4MinX, tile4MinY, tile4StepX, tile4StepY, tile4Width, tile4Height)); + bestStep = max(tile4StepX, tile4StepY); + } + if (min(tile5StepX, tile5StepY) > 0.0 && (bestStep == 0.0 || max(tile5StepX, tile5StepY) < bestStep) && tileContains(pixel, tile5MinX, tile5MinY, tile5StepX, tile5StepY, tile5Width, tile5Height)) { + color = texture2D(detail5, tileCoordinate(pixel, tile5MinX, tile5MinY, tile5StepX, tile5StepY, tile5Width, tile5Height)); + bestStep = max(tile5StepX, tile5StepY); + } + if (min(tile6StepX, tile6StepY) > 0.0 && (bestStep == 0.0 || max(tile6StepX, tile6StepY) < bestStep) && tileContains(pixel, tile6MinX, tile6MinY, tile6StepX, tile6StepY, tile6Width, tile6Height)) { + color = texture2D(detail6, tileCoordinate(pixel, tile6MinX, tile6MinY, tile6StepX, tile6StepY, tile6Width, tile6Height)); + bestStep = max(tile6StepX, tile6StepY); + } + + if (bestStep == 0.0) { + vec2 objectSize = max(bounds.zw - bounds.xy, vec2(1.0)); + vec2 local = vec2( + (gl_FragCoord.x - bounds.x) / objectSize.x, + ((frame.y - gl_FragCoord.y) - bounds.y) / objectSize.y + ); + color = texture2D(overview, local); + } + + if (color.a <= 0.0) { + discard; + } + color.rgb /= color.a; + gl_FragColor = color; +} +` + +const tileShaderSourceES = `#version 100 + +#ifdef GL_ES +# ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +# else +precision mediump float; +#endif +precision mediump int; +#endif + +uniform vec2 frame; +uniform vec4 bounds; +uniform float paneX; +uniform float paneY; +uniform sampler2D overview; +uniform sampler2D detail0; +uniform sampler2D detail1; +uniform sampler2D detail2; +uniform sampler2D detail3; +uniform sampler2D detail4; +uniform sampler2D detail5; +uniform sampler2D detail6; +uniform float tile0MinX; +uniform float tile0MinY; +uniform float tile0StepX; +uniform float tile0StepY; +uniform float tile0Width; +uniform float tile0Height; +uniform float tile1MinX; +uniform float tile1MinY; +uniform float tile1StepX; +uniform float tile1StepY; +uniform float tile1Width; +uniform float tile1Height; +uniform float tile2MinX; +uniform float tile2MinY; +uniform float tile2StepX; +uniform float tile2StepY; +uniform float tile2Width; +uniform float tile2Height; +uniform float tile3MinX; +uniform float tile3MinY; +uniform float tile3StepX; +uniform float tile3StepY; +uniform float tile3Width; +uniform float tile3Height; +uniform float tile4MinX; +uniform float tile4MinY; +uniform float tile4StepX; +uniform float tile4StepY; +uniform float tile4Width; +uniform float tile4Height; +uniform float tile5MinX; +uniform float tile5MinY; +uniform float tile5StepX; +uniform float tile5StepY; +uniform float tile5Width; +uniform float tile5Height; +uniform float tile6MinX; +uniform float tile6MinY; +uniform float tile6StepX; +uniform float tile6StepY; +uniform float tile6Width; +uniform float tile6Height; + +bool tileContains(vec2 position, float minX, float minY, float stepX, float stepY, float width, float height) { + vec2 minimum = vec2(minX, minY); + vec2 maximum = minimum + max(vec2(width, height) - vec2(2.0), vec2(0.0)) * vec2(stepX, stepY); + return position.x >= minimum.x && position.x < maximum.x && + position.y >= minimum.y && position.y < maximum.y; +} + +vec2 tileCoordinate(vec2 position, float minX, float minY, float stepX, float stepY, float width, float height) { + vec2 texel = (position - vec2(minX, minY)) / vec2(stepX, stepY) + vec2(1.0); + return texel / vec2(width, height); +} + +void main() { + vec2 pixel = vec2(gl_FragCoord.x - paneX, (frame.y - gl_FragCoord.y) - paneY); + vec4 color = vec4(0.0); + float bestStep = 0.0; + + if (min(tile0StepX, tile0StepY) > 0.0 && (bestStep == 0.0 || max(tile0StepX, tile0StepY) < bestStep) && tileContains(pixel, tile0MinX, tile0MinY, tile0StepX, tile0StepY, tile0Width, tile0Height)) { + color = texture2D(detail0, tileCoordinate(pixel, tile0MinX, tile0MinY, tile0StepX, tile0StepY, tile0Width, tile0Height)); + bestStep = max(tile0StepX, tile0StepY); + } + if (min(tile1StepX, tile1StepY) > 0.0 && (bestStep == 0.0 || max(tile1StepX, tile1StepY) < bestStep) && tileContains(pixel, tile1MinX, tile1MinY, tile1StepX, tile1StepY, tile1Width, tile1Height)) { + color = texture2D(detail1, tileCoordinate(pixel, tile1MinX, tile1MinY, tile1StepX, tile1StepY, tile1Width, tile1Height)); + bestStep = max(tile1StepX, tile1StepY); + } + if (min(tile2StepX, tile2StepY) > 0.0 && (bestStep == 0.0 || max(tile2StepX, tile2StepY) < bestStep) && tileContains(pixel, tile2MinX, tile2MinY, tile2StepX, tile2StepY, tile2Width, tile2Height)) { + color = texture2D(detail2, tileCoordinate(pixel, tile2MinX, tile2MinY, tile2StepX, tile2StepY, tile2Width, tile2Height)); + bestStep = max(tile2StepX, tile2StepY); + } + if (min(tile3StepX, tile3StepY) > 0.0 && (bestStep == 0.0 || max(tile3StepX, tile3StepY) < bestStep) && tileContains(pixel, tile3MinX, tile3MinY, tile3StepX, tile3StepY, tile3Width, tile3Height)) { + color = texture2D(detail3, tileCoordinate(pixel, tile3MinX, tile3MinY, tile3StepX, tile3StepY, tile3Width, tile3Height)); + bestStep = max(tile3StepX, tile3StepY); + } + if (min(tile4StepX, tile4StepY) > 0.0 && (bestStep == 0.0 || max(tile4StepX, tile4StepY) < bestStep) && tileContains(pixel, tile4MinX, tile4MinY, tile4StepX, tile4StepY, tile4Width, tile4Height)) { + color = texture2D(detail4, tileCoordinate(pixel, tile4MinX, tile4MinY, tile4StepX, tile4StepY, tile4Width, tile4Height)); + bestStep = max(tile4StepX, tile4StepY); + } + if (min(tile5StepX, tile5StepY) > 0.0 && (bestStep == 0.0 || max(tile5StepX, tile5StepY) < bestStep) && tileContains(pixel, tile5MinX, tile5MinY, tile5StepX, tile5StepY, tile5Width, tile5Height)) { + color = texture2D(detail5, tileCoordinate(pixel, tile5MinX, tile5MinY, tile5StepX, tile5StepY, tile5Width, tile5Height)); + bestStep = max(tile5StepX, tile5StepY); + } + if (min(tile6StepX, tile6StepY) > 0.0 && (bestStep == 0.0 || max(tile6StepX, tile6StepY) < bestStep) && tileContains(pixel, tile6MinX, tile6MinY, tile6StepX, tile6StepY, tile6Width, tile6Height)) { + color = texture2D(detail6, tileCoordinate(pixel, tile6MinX, tile6MinY, tile6StepX, tile6StepY, tile6Width, tile6Height)); + bestStep = max(tile6StepX, tile6StepY); + } + + if (bestStep == 0.0) { + vec2 objectSize = max(bounds.zw - bounds.xy, vec2(1.0)); + vec2 local = vec2( + (gl_FragCoord.x - bounds.x) / objectSize.x, + ((frame.y - gl_FragCoord.y) - bounds.y) / objectSize.y + ); + color = texture2D(overview, local); + } + + if (color.a <= 0.0) { + discard; + } + color.rgb /= color.a; + gl_FragColor = color; +} +` + +const ( + leftShaderPaneName = "picfetch-compare-tiled-v1-left" + rightShaderPaneName = "picfetch-compare-tiled-v1-right" +) + +type shaderPaneRenderer struct { + shader *canvas.Shader + placeholder *image.RGBA + source *renderSource + scene paneScene + bound [detailSamplerCount]*renderTile + + queueUI func(func()) + generateTile func(context.Context, *renderSource, tileKey) (*renderTile, error) + + workerMu sync.Mutex + workerRevision uint64 + workerSource *renderSource + workerRequests []tileRequest + workerContext context.Context + workerCancel context.CancelFunc + workerRunning bool + workerPending workTracker + publicationQueued bool +} + +func newShaderPaneRenderer(index int) paneRenderer { + name := leftShaderPaneName + if index == 1 { + name = rightShaderPaneName + } + placeholder := image.NewRGBA(image.Rect(0, 0, 1, 1)) + shader := canvas.NewShader(name, []byte(tileShaderSourceDesktop), []byte(tileShaderSourceES)) + shader.Textures = make(map[string]image.Image, 1+detailSamplerCount) + shader.Uniforms = make(map[string]float32, 2+detailSamplerCount*6) + shader.Uniforms["paneX"] = 0 + shader.Uniforms["paneY"] = 0 + shader.Textures["overview"] = placeholder + for slot := range detailSamplerCount { + shader.Textures[detailTextureName(slot)] = placeholder + for _, suffix := range []string{"MinX", "MinY", "StepX", "StepY", "Width", "Height"} { + shader.Uniforms[detailUniform(slot, suffix)] = 0 + } + } + shader.Hide() + return &shaderPaneRenderer{ + shader: shader, + placeholder: placeholder, + queueUI: func(apply func()) { fyne.Do(apply) }, + generateTile: generateRenderTile, + } +} + +func (r *shaderPaneRenderer) Object() fyne.CanvasObject { return r.shader } + +func (r *shaderPaneRenderer) Present(scene paneScene) { + if scene.source == nil || scene.source.frame == nil || scene.source.overview == nil { + r.clear() + return + } + + if r.source != scene.source { + r.cancelTileRequest() + r.source = scene.source + r.shader.Textures["overview"] = scene.source.overview + for slot := range detailSamplerCount { + r.clearDetail(slot) + } + } + r.scene = scene + r.shader.Uniforms["paneX"] = float32(scene.panePosition.X) + r.shader.Uniforms["paneY"] = float32(scene.panePosition.Y) + + plan := planTiles(scene) + r.bindAvailable(scene, plan) + + r.shader.Resize(scene.imageSize) + r.shader.Move(scene.imagePosition) + r.shader.Show() + r.shader.Refresh() + r.requestTiles(scene.source, plan.requests) +} + +func (r *shaderPaneRenderer) bindAvailable(scene paneScene, plan tilePlan) { + if scene.source == nil || scene.source.tiles == nil || len(plan.requests) == 0 { + for slot := range detailSamplerCount { + r.clearDetail(slot) + } + return + } + + ready := make(map[tileKey]*renderTile, detailSamplerCount) + allDesiredReady := true + // Touch distant prefetch entries first so the nearest visible tiles finish + // as the cache's most-recently-used entries. + for _, request := range slices.Backward(plan.requests) { + + tile, ok := scene.source.tiles.Get(request.key.cacheKey()) + if !ok { + allDesiredReady = false + continue + } + ready[request.key] = tile + } + + assigned := make(map[tileKey]bool, detailSamplerCount) + for slot, tile := range r.bound { + if tile == nil { + continue + } + r.bindDetail(slot, tile, scene) + if _, wanted := ready[tile.key]; wanted { + assigned[tile.key] = true + continue + } + if allDesiredReady { + r.clearDetail(slot) + } + } + + for _, request := range plan.requests { + tile := ready[request.key] + if tile == nil || assigned[request.key] { + continue + } + slot := r.availableDetailSlot(ready) + if slot < 0 { + break + } + r.bindDetail(slot, tile, scene) + assigned[request.key] = true + } +} + +func (r *shaderPaneRenderer) availableDetailSlot(wanted map[tileKey]*renderTile) int { + for slot, tile := range r.bound { + if tile == nil { + return slot + } + } + for slot, tile := range r.bound { + if _, keep := wanted[tile.key]; !keep { + return slot + } + } + return -1 +} + +func (r *shaderPaneRenderer) bindDetail(slot int, tile *renderTile, scene paneScene) { + if slot < 0 || slot >= detailSamplerCount || tile == nil || tile.texture == nil || + scene.source == nil || scene.source.frame == nil || + scene.imageSize.Width <= 0 || scene.imageSize.Height <= 0 || + scene.displaySize.X <= 0 || scene.displaySize.Y <= 0 { + return + } + sourceSize := scene.source.frame.Bounds().Size() + if sourceSize.X <= 0 || sourceSize.Y <= 0 { + return + } + pointToPixelX := float64(scene.displaySize.X) / float64(scene.imageSize.Width) + pointToPixelY := float64(scene.displaySize.Y) / float64(scene.imageSize.Height) + sourceToPixelX := float64(scene.displaySize.X) / float64(sourceSize.X) + sourceToPixelY := float64(scene.displaySize.Y) / float64(sourceSize.Y) + stepX := float64(tile.scale) * sourceToPixelX + stepY := float64(tile.scale) * sourceToPixelY + minX := float64(scene.imagePosition.X)*pointToPixelX + float64(tile.interior.Min.X)*stepX + minY := float64(scene.imagePosition.Y)*pointToPixelY + float64(tile.interior.Min.Y)*stepY + r.bound[slot] = tile + r.shader.Textures[detailTextureName(slot)] = tile.texture + r.shader.Uniforms[detailUniform(slot, "MinX")] = float32(minX) + r.shader.Uniforms[detailUniform(slot, "MinY")] = float32(minY) + r.shader.Uniforms[detailUniform(slot, "StepX")] = float32(stepX) + r.shader.Uniforms[detailUniform(slot, "StepY")] = float32(stepY) + r.shader.Uniforms[detailUniform(slot, "Width")] = float32(tile.texture.Bounds().Dx()) + r.shader.Uniforms[detailUniform(slot, "Height")] = float32(tile.texture.Bounds().Dy()) +} + +func (r *shaderPaneRenderer) clearDetail(slot int) { + r.bound[slot] = nil + r.shader.Textures[detailTextureName(slot)] = r.placeholder + r.shader.Uniforms[detailUniform(slot, "StepX")] = 0 + r.shader.Uniforms[detailUniform(slot, "StepY")] = 0 +} + +func (r *shaderPaneRenderer) clear() { + r.cancelTileRequest() + r.source = nil + r.scene = paneScene{} + r.shader.Textures["overview"] = r.placeholder + for slot := range detailSamplerCount { + r.clearDetail(slot) + } + r.shader.Hide() + r.shader.Refresh() +} + +func (r *shaderPaneRenderer) setQueueUI(queue func(func())) { + if queue == nil { + r.queueUI = func(apply func()) { fyne.Do(apply) } + return + } + r.queueUI = queue +} + +func (r *shaderPaneRenderer) requestTiles(source *renderSource, requests []tileRequest) { + if source == nil || source.tiles == nil { + r.cancelTileRequest() + return + } + missing := false + for _, request := range requests { + if !source.tiles.Contains(request.key.cacheKey()) { + missing = true + break + } + } + r.workerMu.Lock() + if r.workerRunning && r.workerSource == source { + if sameTileRequests(r.workerRequests, requests) { + r.workerMu.Unlock() + return + } + // A pan or zoom replaces the desired plan, but does not cancel the + // tile whose destination buffer is already allocated. Let that one + // finish, cache it, then move directly to the latest plan. Repeated + // cancellation here otherwise strands one full tile per input event + // until the next GC cycle. + r.workerRevision++ + r.workerRequests = append(r.workerRequests[:0], requests...) + r.workerMu.Unlock() + return + } + if !missing { + r.workerMu.Unlock() + // A worker may have populated the last missing cache entry between + // Present's bind pass and this check. No active same-source worker + // remains whose queued publication needs preserving. + r.cancelTileRequest() + return + } + r.workerRevision++ + if r.workerCancel != nil { + r.workerCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + r.workerContext = ctx + r.workerCancel = cancel + r.workerSource = source + r.workerRequests = append(r.workerRequests[:0], requests...) + if r.workerRunning { + r.workerMu.Unlock() + return + } + r.workerRunning = true + r.workerPending.Add(1) + r.workerMu.Unlock() + go r.runTileWorker() +} + +func (r *shaderPaneRenderer) cancelTileRequest() { + r.workerMu.Lock() + r.workerRevision++ + if r.workerCancel != nil { + r.workerCancel() + } + r.workerContext = nil + r.workerCancel = nil + r.workerSource = nil + r.workerRequests = nil + r.workerMu.Unlock() +} + +func (r *shaderPaneRenderer) runTileWorker() { + defer r.workerPending.Done() + for { + r.workerMu.Lock() + if r.workerSource == nil || len(r.workerRequests) == 0 { + r.workerRunning = false + r.workerMu.Unlock() + return + } + revision := r.workerRevision + source := r.workerSource + requests := append([]tileRequest(nil), r.workerRequests...) + requestContext := r.workerContext + r.workerMu.Unlock() + + for _, request := range requests { + if requestContext.Err() != nil { + break + } + if source.tiles.Contains(request.key.cacheKey()) { + continue + } + tile, err := r.generateTile(requestContext, source, request.key) + if err != nil { + if !errors.Is(err, context.Canceled) { + fyne.LogError("Failed to prepare comparison detail tile", err) + } + continue + } + if !r.cacheGeneratedTile(requestContext, source, tile) { + break + } + r.queueTilePublication() + if !r.latestTileRequest(revision, source) { + break + } + } + + r.workerMu.Lock() + if r.workerRevision != revision { + r.workerMu.Unlock() + continue + } + r.workerRunning = false + r.workerCancel = nil + r.workerMu.Unlock() + return + } +} + +func (r *shaderPaneRenderer) cacheGeneratedTile(ctx context.Context, source *renderSource, tile *renderTile) bool { + r.workerMu.Lock() + defer r.workerMu.Unlock() + if ctx.Err() != nil || r.workerContext != ctx || r.workerSource != source { + return false + } + source.tiles.Add(tile.key.cacheKey(), tile) + return true +} + +func (r *shaderPaneRenderer) queueTilePublication() { + r.workerMu.Lock() + if r.publicationQueued { + r.workerMu.Unlock() + return + } + r.publicationQueued = true + r.workerMu.Unlock() + + // Capture only the renderer. When the UI catches up, one callback binds + // every currently available tile for the latest scene; stale generations + // therefore cannot retain sources or build an interaction backlog. + r.queueUI(func() { + r.workerMu.Lock() + r.publicationQueued = false + r.workerMu.Unlock() + if r.source == nil || r.scene.source != r.source { + return + } + r.bindAvailable(r.scene, planTiles(r.scene)) + r.shader.Refresh() + }) +} + +func (r *shaderPaneRenderer) latestTileRequest(revision uint64, source *renderSource) bool { + r.workerMu.Lock() + defer r.workerMu.Unlock() + return r.workerRevision == revision && r.workerSource == source +} + +func sameTileRequests(a, b []tileRequest) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func (r *shaderPaneRenderer) Wait(ctx context.Context) error { + return r.workerPending.WaitContext(ctx) +} + +func detailTextureName(slot int) string { + return "detail" + strconv.Itoa(slot) +} + +func detailUniform(slot int, suffix string) string { + return "tile" + strconv.Itoa(slot) + suffix +} diff --git a/internal/ui/compare/shader_test.go b/internal/ui/compare/shader_test.go new file mode 100644 index 0000000..2c08dea --- /dev/null +++ b/internal/ui/compare/shader_test.go @@ -0,0 +1,297 @@ +package compare + +import ( + "context" + "image" + "maps" + "strconv" + "strings" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + fynetest "fyne.io/fyne/v2/test" +) + +func TestTileShaderSources_AgreeAndDeclareFixedPortableContract(t *testing.T) { + const marker = "uniform vec2 frame;" + desktop := strings.Index(tileShaderSourceDesktop, marker) + es := strings.Index(tileShaderSourceES, marker) + if desktop < 0 || es < 0 { + t.Fatalf("shader sources must both declare the built-in frame uniform") + } + if tileShaderSourceDesktop[desktop:] != tileShaderSourceES[es:] { + t.Fatal("desktop and GLES tile shader bodies differ below their preambles") + } + + shared := tileShaderSourceDesktop[desktop:] + if got := strings.Count(shared, "uniform sampler2D "); got != 1+detailSamplerCount { + t.Errorf("sampler declarations = %d, want overview plus %d details", got, detailSamplerCount) + } + for _, declaration := range []string{ + "uniform sampler2D overview;", + "texture2D(overview, local)", + "vec2 pixel = vec2(", + "tileContains(pixel, tile0MinX", + "tileCoordinate(pixel, tile0MinX", + "return texel / vec2(width, height);", + "float bestStep = 0.0;", + "min(tile0StepX, tile0StepY) > 0.0", + "bestStep == 0.0", + "color.rgb /= color.a;", + } { + if !strings.Contains(shared, declaration) { + t.Errorf("tile shader missing %q", declaration) + } + } + for _, inverted := range []string{"1.0 - local.y", "1.0 - texel.y / height"} { + if strings.Contains(shared, inverted) { + t.Errorf("tile shader vertically inverts Go image textures with %q", inverted) + } + } + for _, normalizedDetail := range []string{ + "tileContains(local", + "tileCoordinate(local", + "min(maximum.x, 1.0)", + "min(maximum.y, 1.0)", + } { + if strings.Contains(shared, normalizedDetail) { + t.Errorf("tile shader still looks up detail in source-normalized coordinates with %q", normalizedDetail) + } + } + for slot := range detailSamplerCount { + slotName := strconv.Itoa(slot) + for _, declaration := range []string{ + "uniform sampler2D detail" + slotName + ";", + "uniform float tile" + slotName + "StepX;", + "uniform float tile" + slotName + "StepY;", + "uniform float tile" + slotName + "Width;", + "uniform float tile" + slotName + "Height;", + } { + if !strings.Contains(shared, declaration) { + t.Errorf("tile shader missing %q", declaration) + } + } + for _, redundant := range []string{"Active", "MaxX", "MaxY", "Scale"} { + declaration := "uniform float tile" + slotName + redundant + ";" + if strings.Contains(shared, declaration) { + t.Errorf("tile shader wastes portable fragment-uniform budget on %q", declaration) + } + } + } + for _, rawSourceUniform := range []string{"uniform float sourceWidth;", "uniform float sourceHeight;", "1.0e20"} { + if strings.Contains(shared, rawSourceUniform) { + t.Errorf("tile shader uses non-portable raw source coordinate contract %q", rawSourceUniform) + } + } +} + +func TestShaderPaneRenderer_LargeSourceUsesRepresentablePanePixelTileCoordinates(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + frame := image.NewRGBA(image.Rect(0, 0, 32768, 2)) + source := newPreparedRenderSource(frame, image.NewRGBA(image.Rect(0, 0, 1024, 1))) + tile := &renderTile{ + key: tileKey{level: 0, x: 16, y: 0}, + texture: image.NewRGBA(image.Rect(0, 0, tileTextureDimension, 4)), + interior: image.Rect(16*tileInterior, 0, 17*tileInterior, 2), + scale: 1, + } + source.tiles.Add(tile.key.cacheKey(), tile) + scene := paneScene{ + source: source, + viewport: fyne.NewSize(1024, 2), + imagePosition: fyne.NewPos(-16*tileInterior, 0), + imageSize: fyne.NewSize(32768, 2), + displaySize: image.Pt(32768, 2), + } + renderer := newShaderPaneRenderer(0).(*shaderPaneRenderer) + renderer.bindAvailable(scene, tilePlan{requests: []tileRequest{{key: tile.key}}}) + + if got := renderer.shader.Uniforms["tile0MinX"]; got != 0 { + t.Errorf("visible detail tile pane X = %v, want 0", got) + } + if got := renderer.shader.Uniforms["tile0StepX"]; got != 1 { + t.Errorf("large-source detail X step = %v, want one display pixel per texel", got) + } + if got := renderer.shader.Uniforms["tile0StepY"]; got != 1 { + t.Errorf("large-source detail Y step = %v, want one display pixel per texel", got) + } +} + +func TestShaderPaneRenderer_HasStableUniqueNamesAndFixedTextureSlots(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + left := newShaderPaneRenderer(0).(*shaderPaneRenderer) + right := newShaderPaneRenderer(1).(*shaderPaneRenderer) + leftAgain := newShaderPaneRenderer(0).(*shaderPaneRenderer) + if left.shader.Name == right.shader.Name { + t.Fatalf("pane shader names collide at %q", left.shader.Name) + } + if left.shader.Name != leftAgain.shader.Name { + t.Errorf("left shader name changed between instances: %q and %q", left.shader.Name, leftAgain.shader.Name) + } + if len(left.shader.Textures) != 1+detailSamplerCount { + t.Errorf("shader textures = %d, want %d fixed slots", len(left.shader.Textures), 1+detailSamplerCount) + } + if len(left.shader.Uniforms) != 2+detailSamplerCount*6 { + t.Errorf("shader scalar uniforms = %d, want %d", len(left.shader.Uniforms), 2+detailSamplerCount*6) + } +} + +func TestShaderPaneRenderer_PreservesSamplerSlotsWhenRequestPriorityChanges(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + source, err := prepareRenderSource(context.Background(), image.NewRGBA(image.Rect(0, 0, 4096, 1024))) + if err != nil { + t.Fatalf("prepare source: %v", err) + } + keys := []tileKey{ + {level: 0, x: 0, y: 0}, + {level: 0, x: 1, y: 0}, + {level: 0, x: 2, y: 0}, + } + for _, key := range keys { + source.tiles.Add(key.cacheKey(), &renderTile{ + key: key, + texture: image.NewRGBA(image.Rect(0, 0, tileTextureDimension, tileTextureDimension)), + interior: image.Rect(key.x*tileInterior, 0, (key.x+1)*tileInterior, tileInterior), + scale: 1, + }) + } + + renderer := newShaderPaneRenderer(0).(*shaderPaneRenderer) + scene := paneScene{ + source: source, + viewport: fyne.NewSize(4096, 1024), + imageSize: fyne.NewSize(4096, 1024), + displaySize: image.Pt(4096, 1024), + } + renderer.bindAvailable(scene, tilePlan{requests: []tileRequest{ + {key: keys[0]}, {key: keys[1]}, {key: keys[2]}, + }}) + before := renderer.bound + for slot := range len(keys) { + if before[slot] == nil { + t.Fatalf("initial sampler slot %d is unbound", slot) + } + } + renderer.bindAvailable(scene, tilePlan{requests: []tileRequest{ + {key: keys[2]}, {key: keys[1]}, {key: keys[0]}, + }}) + for slot := range len(keys) { + if renderer.bound[slot] != before[slot] { + t.Errorf("request reprioritization replaced sampler slot %d", slot) + } + } +} + +func TestShaderPaneRenderer_MapsSceneAndCachedTilesWithoutReplacingStableTextures(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + frame := image.NewRGBA(image.Rect(0, 0, 1024, 2)) + source := newPreparedRenderSource(frame, image.NewRGBA(image.Rect(0, 0, 512, 1))) + for x := range 2 { + key := tileKey{level: 0, x: x, y: 0} + tile, err := generateRenderTile(context.Background(), source, key) + if err != nil { + t.Fatalf("generate tile %d: %v", x, err) + } + source.tiles.Add(key.cacheKey(), tile) + } + + renderer := newShaderPaneRenderer(0).(*shaderPaneRenderer) + object := renderer.Object() + scene := paneScene{ + source: source, + viewport: fyne.NewSize(512, 200), + imagePosition: fyne.NewPos(-20, 99), + imageSize: fyne.NewSize(1024, 2), + panePosition: image.Pt(17, 23), + displaySize: image.Pt(1024, 2), + } + renderer.Present(scene) + if renderer.Object() != object { + t.Fatal("renderer replaced its canvas object while presenting a scene") + } + if got := renderer.shader.Position(); got != scene.imagePosition { + t.Errorf("shader position = %v, want %v", got, scene.imagePosition) + } + if got := renderer.shader.Size(); got != scene.imageSize { + t.Errorf("shader size = %v, want %v", got, scene.imageSize) + } + if !renderer.shader.Visible() { + t.Fatal("shader stayed hidden after a valid scene") + } + if got := renderer.shader.Textures["overview"]; got != source.overview { + t.Fatal("overview texture is not the prepared source overview") + } + if got := renderer.shader.Uniforms["paneX"]; got != 17 { + t.Errorf("shader pane X = %v, want 17", got) + } + if got := renderer.shader.Uniforms["paneY"]; got != 23 { + t.Errorf("shader pane Y = %v, want 23", got) + } + if got := renderer.shader.Uniforms["tile0MinX"]; got != -20 { + t.Errorf("first detail pane X = %v, want -20", got) + } + if got := renderer.shader.Uniforms["tile0MinY"]; got != 99 { + t.Errorf("first detail pane Y = %v, want 99", got) + } + if got := renderer.shader.Uniforms["tile0StepX"]; got != 1 { + t.Errorf("first detail display X step = %v, want 1", got) + } + if got := renderer.shader.Uniforms["tile0StepY"]; got != 1 { + t.Errorf("first detail display Y step = %v, want 1", got) + } + before := make(map[string]image.Image, len(renderer.shader.Textures)) + maps.Copy(before, renderer.shader.Textures) + renderer.Present(scene) + for name, texture := range before { + if renderer.shader.Textures[name] != texture { + t.Errorf("unchanged scene replaced texture %q", name) + } + } + + moved := scene + moved.imagePosition.X = 30 + renderer.Present(moved) + bound := 0 + for slot, tile := range renderer.bound { + if tile == nil { + continue + } + bound++ + wantX := float32(tile.interior.Min.X*tile.scale) + moved.imagePosition.X + if got := renderer.shader.Uniforms[detailUniform(slot, "MinX")]; got != wantX { + t.Errorf("moved detail slot %d pane X = %v, want %v", slot, got, wantX) + } + } + if bound == 0 { + t.Fatal("geometry update left every cached detail tile unbound") + } + + renderer.Present(paneScene{}) + if renderer.shader.Visible() { + t.Fatal("shader remained visible after clear") + } + for slot := range detailSamplerCount { + if renderer.shader.Uniforms[detailUniform(slot, "StepX")] != 0 || + renderer.shader.Uniforms[detailUniform(slot, "StepY")] != 0 { + t.Errorf("detail slot %d remained active after clear", slot) + } + if renderer.shader.Textures[detailTextureName(slot)] != renderer.placeholder { + t.Errorf("detail slot %d retained an application texture after clear", slot) + } + } + if renderer.shader.Textures["overview"] != renderer.placeholder { + t.Fatal("clear retained the source overview") + } + if _, ok := renderer.Object().(*canvas.Shader); !ok { + t.Fatalf("renderer object type = %T, want *canvas.Shader", renderer.Object()) + } +} diff --git a/internal/ui/compare/source_test.go b/internal/ui/compare/source_test.go new file mode 100644 index 0000000..86c9125 --- /dev/null +++ b/internal/ui/compare/source_test.go @@ -0,0 +1,114 @@ +package compare + +import ( + "bytes" + "context" + "errors" + "image" + "image/color" + "testing" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/storage" + fynetest "fyne.io/fyne/v2/test" + + "github.com/frathe/picfetch/internal/imaging" +) + +func TestPrepareRenderSource_BuildsBoundedOverviewWithoutMutatingFrame(t *testing.T) { + frame := image.NewNRGBA(image.Rect(11, 17, 4107, 1041)) + for y := frame.Bounds().Min.Y; y < frame.Bounds().Max.Y; y++ { + for x := frame.Bounds().Min.X; x < frame.Bounds().Max.X; x++ { + frame.SetNRGBA(x, y, color.NRGBA{ + R: uint8(x % 251), G: uint8(y % 241), B: uint8((x + y) % 239), A: uint8(64 + (x+y)%192), + }) + } + } + before := append([]byte(nil), frame.Pix...) + + source, err := prepareRenderSource(context.Background(), frame) + if err != nil { + t.Fatalf("prepareRenderSource: %v", err) + } + if source.frame != frame { + t.Fatal("prepared source replaced the canonical decoded frame") + } + if got := source.overview.Bounds().Size(); got != image.Pt(1024, 256) { + t.Errorf("overview size = %v, want 1024x256", got) + } + if !bytes.Equal(frame.Pix, before) { + t.Fatal("overview generation mutated the canonical decoded pixels") + } + _, _, _, alpha := source.overview.At(511, 127).RGBA() + if alpha == 0 || alpha == 0xffff { + t.Errorf("overview alpha = %#x, want preserved partial transparency", alpha) + } +} + +func TestPrepareRenderSource_ObservesCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := prepareRenderSource(ctx, image.NewRGBA(image.Rect(0, 0, 2000, 1000))) + if !errors.Is(err, context.Canceled) { + t.Fatalf("prepareRenderSource error = %v, want context.Canceled", err) + } +} + +func TestCompareReady_WaitsForBothPreparedOverviews(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + started := make(chan struct{}, 2) + release := make(chan struct{}) + feature := newReferenceFeature(func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return &imaging.LoadedImage{Frames: []image.Image{image.NewRGBA(image.Rect(0, 0, 1600, 800))}}, nil + }, Callbacks{}) + feature.prepareSource = func(ctx context.Context, frame image.Image) (*renderSource, error) { + started <- struct{}{} + select { + case <-release: + return prepareRenderSource(ctx, frame) + case <-ctx.Done(): + return nil, ctx.Err() + } + } + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.png"), + storage.NewFileURI("right.png"), + }) + for range 2 { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for overview preparation") + } + } + if feature.Ready() { + t.Fatal("comparison became ready before overview preparation completed") + } + for i := range feature.panes { + if !feature.panes[i].spinner.Visible() { + t.Errorf("pane %d spinner hidden while overview preparation is pending", i) + } + } + + close(release) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := feature.Settle(ctx); err != nil { + t.Fatalf("Settle: %v", err) + } + if !feature.Ready() { + t.Fatal("comparison did not become ready after both overviews completed") + } + for i, source := range feature.renderSources { + if source == nil || source.overview == nil { + t.Errorf("pane %d has no display-ready overview", i) + } + if feature.panes[i].spinner.Visible() { + t.Errorf("pane %d spinner remained visible after overview preparation", i) + } + } +} diff --git a/internal/ui/compare/swipe.go b/internal/ui/compare/swipe.go index 1ccc8f6..752b705 100644 --- a/internal/ui/compare/swipe.go +++ b/internal/ui/compare/swipe.go @@ -90,12 +90,31 @@ func (f *Feature) layoutSwipeReveal(size fyne.Size) { f.divider.Resize(fyne.NewSize(thickness, size.Height)) } +func (f *Feature) paneVisibleArea(index int) (fyne.Position, fyne.Size) { + viewport := f.viewports[index] + if f.layoutMode != swipe { + return fyne.Position{}, viewport + } + boundary := min(max(f.dividerAt, 0), 1) * viewport.Width + if index == 0 { + return fyne.Position{}, fyne.NewSize(boundary, viewport.Height) + } + return fyne.NewPos(boundary, 0), fyne.NewSize(viewport.Width-boundary, viewport.Height) +} + +func (f *Feature) layoutPaneInput(index int, input fyne.CanvasObject) { + position, size := f.paneVisibleArea(index) + input.Move(position) + input.Resize(size) +} + func (f *Feature) layoutReveal(index int, clipPosition fyne.Position, clipSize fyne.Size, rootPosition fyne.Position, rootSize fyne.Size) { reveal := f.reveals[index] reveal.clip.Move(clipPosition) reveal.clip.Resize(clipSize) f.panes[index].root.Move(rootPosition) f.panes[index].root.Resize(rootSize) + f.layoutPaneInput(index, f.panes[index].input) } func dividerThickness() float32 { @@ -149,15 +168,16 @@ func (f *Feature) setDivider(position float32) { // on every pointer event. The visible clips and pane offset mark the canvas // dirty as they move, so the static images and linked transform stay cached. f.layoutSwipeReveal(f.content.Size()) + f.applyReveal() } -func (f *Feature) handleDividerKey(name fyne.KeyName) bool { +func (f *Feature) handleDividerKey(name fyne.KeyName, modifiers fyne.KeyModifier) bool { if f.layoutMode != swipe { return false } step := dividerKeyStep - if f.callbacks.Modifiers != nil && f.callbacks.Modifiers()&fyne.KeyModifierShift != 0 { + if modifiers&fyne.KeyModifierShift != 0 { step = dividerFineKeyStep } switch name { diff --git a/internal/ui/compare/tile.go b/internal/ui/compare/tile.go new file mode 100644 index 0000000..46c4edc --- /dev/null +++ b/internal/ui/compare/tile.go @@ -0,0 +1,352 @@ +package compare + +import ( + "context" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "math" + "sort" + "strconv" + + "fyne.io/fyne/v2" + xdraw "golang.org/x/image/draw" +) + +const ( + detailSamplerCount = 7 + tileTextureDimension = 1024 + tileGutter = 1 + tileInterior = tileTextureDimension - 2*tileGutter + tileCacheBudgetBytes = int64(64 << 20) +) + +type tileKey struct { + level int + x int + y int +} + +func (k tileKey) cacheKey() string { + return strconv.Itoa(k.level) + "/" + strconv.Itoa(k.x) + "/" + strconv.Itoa(k.y) +} + +type tileRequest struct { + key tileKey + visible bool +} + +type sourceRect struct { + minX float64 + minY float64 + maxX float64 + maxY float64 +} + +type tilePlan struct { + level int + visible sourceRect + requests []tileRequest +} + +type renderTile struct { + key tileKey + texture *image.RGBA + interior image.Rectangle + scale int +} + +type edgeClampedImage struct { + image.Image + bounds image.Rectangle +} + +func (img edgeClampedImage) Bounds() image.Rectangle { return img.bounds } + +func (img edgeClampedImage) At(x, y int) color.Color { + bounds := img.Image.Bounds() + x = min(max(x, bounds.Min.X), bounds.Max.X-1) + y = min(max(y, bounds.Min.Y), bounds.Max.Y-1) + return img.Image.At(x, y) +} + +func planTiles(scene paneScene) tilePlan { + visible, ok := visibleSource(scene) + if !ok { + return tilePlan{} + } + if overviewCoversDisplay(scene) { + return tilePlan{visible: visible} + } + bounds := scene.source.frame.Bounds() + width, height := bounds.Dx(), bounds.Dy() + maximumLevel := maxMipLevel(width, height) + level := desiredMipLevel(width, height, scene.displaySize, maximumLevel) + + var minX, maxX, minY, maxY int + for { + minX, maxX, minY, maxY = visibleTileRange(visible, width, height, level) + count := int64(maxX-minX+1) * int64(maxY-minY+1) + if count <= detailSamplerCount || level >= maximumLevel { + break + } + level++ + } + + plan := tilePlan{level: level, visible: visible} + centerX := (visible.minX + visible.maxX) / 2 + centerY := (visible.minY + visible.maxY) / 2 + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + plan.requests = append(plan.requests, tileRequest{ + key: tileKey{level: level, x: x, y: y}, + visible: true, + }) + } + } + sortTileRequests(plan.requests, centerX, centerY) + + columns, rows := tileGrid(width, height, level) + seen := make(map[tileKey]bool, detailSamplerCount) + for _, request := range plan.requests { + seen[request.key] = true + } + for radius := 1; len(plan.requests) < detailSamplerCount && radius <= max(columns, rows); radius++ { + left := max(0, minX-radius) + right := min(columns-1, maxX+radius) + top := max(0, minY-radius) + bottom := min(rows-1, maxY+radius) + candidates := make([]tileRequest, 0, 2*(right-left+bottom-top+2)) + for y := top; y <= bottom; y++ { + for x := left; x <= right; x++ { + if x > left && x < right && y > top && y < bottom { + continue + } + key := tileKey{level: level, x: x, y: y} + if seen[key] { + continue + } + seen[key] = true + candidates = append(candidates, tileRequest{key: key}) + } + } + sortTileRequests(candidates, centerX, centerY) + remaining := detailSamplerCount - len(plan.requests) + if len(candidates) > remaining { + candidates = candidates[:remaining] + } + plan.requests = append(plan.requests, candidates...) + } + return plan +} + +func overviewCoversDisplay(scene paneScene) bool { + if scene.source == nil || scene.source.frame == nil || scene.source.overview == nil { + return false + } + frame := scene.source.frame.Bounds().Size() + overview := scene.source.overview.Bounds().Size() + if overview.X >= frame.X && overview.Y >= frame.Y { + return true + } + return scene.displaySize.X <= overview.X && scene.displaySize.Y <= overview.Y +} + +func visibleSource(scene paneScene) (sourceRect, bool) { + if scene.source == nil || scene.source.frame == nil || + scene.viewport.Width <= 0 || scene.viewport.Height <= 0 || + scene.imageSize.Width <= 0 || scene.imageSize.Height <= 0 { + return sourceRect{}, false + } + bounds := scene.source.frame.Bounds() + width, height := bounds.Dx(), bounds.Dy() + if width <= 0 || height <= 0 { + return sourceRect{}, false + } + revealPosition := fyne.Position{} + revealSize := scene.viewport + if scene.revealSet { + revealPosition = scene.revealPosition + revealSize = scene.revealSize + } + left := max(float64(revealPosition.X), float64(scene.imagePosition.X)) + top := max(float64(revealPosition.Y), float64(scene.imagePosition.Y)) + right := min(float64(revealPosition.X+revealSize.Width), float64(scene.imagePosition.X+scene.imageSize.Width)) + bottom := min(float64(revealPosition.Y+revealSize.Height), float64(scene.imagePosition.Y+scene.imageSize.Height)) + left = max(left, 0) + top = max(top, 0) + right = min(right, float64(scene.viewport.Width)) + bottom = min(bottom, float64(scene.viewport.Height)) + if right <= left || bottom <= top { + return sourceRect{}, false + } + positionX, positionY := float64(scene.imagePosition.X), float64(scene.imagePosition.Y) + displayWidth, displayHeight := float64(scene.imageSize.Width), float64(scene.imageSize.Height) + visible := sourceRect{ + minX: (left - positionX) / displayWidth * float64(width), + minY: (top - positionY) / displayHeight * float64(height), + maxX: (right - positionX) / displayWidth * float64(width), + maxY: (bottom - positionY) / displayHeight * float64(height), + } + visible.minX = min(max(visible.minX, 0), float64(width)) + visible.minY = min(max(visible.minY, 0), float64(height)) + visible.maxX = min(max(visible.maxX, 0), float64(width)) + visible.maxY = min(max(visible.maxY, 0), float64(height)) + return visible, visible.maxX > visible.minX && visible.maxY > visible.minY +} + +func desiredMipLevel(width, height int, display image.Point, maximum int) int { + if display.X <= 0 || display.Y <= 0 { + return 0 + } + sourcePerPixel := max(float64(width)/float64(display.X), float64(height)/float64(display.Y)) + level := 0 + for sourcePerPixel >= 2 && level < maximum { + sourcePerPixel /= 2 + level++ + } + return level +} + +func visibleTileRange(visible sourceRect, width, height, level int) (minX, maxX, minY, maxY int) { + columns, rows := tileGrid(width, height, level) + coverage := float64(tileInterior * mipScale(level)) + minX = min(max(int(math.Floor(visible.minX/coverage)), 0), columns-1) + minY = min(max(int(math.Floor(visible.minY/coverage)), 0), rows-1) + maxX = min(max(int(math.Ceil(visible.maxX/coverage))-1, minX), columns-1) + maxY = min(max(int(math.Ceil(visible.maxY/coverage))-1, minY), rows-1) + return minX, maxX, minY, maxY +} + +func sortTileRequests(requests []tileRequest, centerX, centerY float64) { + sort.SliceStable(requests, func(i, j int) bool { + a, b := requests[i].key, requests[j].key + coverage := float64(tileInterior * mipScale(a.level)) + aX, aY := (float64(a.x)+0.5)*coverage, (float64(a.y)+0.5)*coverage + bX, bY := (float64(b.x)+0.5)*coverage, (float64(b.y)+0.5)*coverage + aDistance := (aX-centerX)*(aX-centerX) + (aY-centerY)*(aY-centerY) + bDistance := (bX-centerX)*(bX-centerX) + (bY-centerY)*(bY-centerY) + if aDistance != bDistance { + return aDistance < bDistance + } + if a.y != b.y { + return a.y < b.y + } + return a.x < b.x + }) +} + +func maxMipLevel(width, height int) int { + dimension := max(width, height) + level := 0 + for dimension > 1 { + dimension = (dimension + 1) / 2 + level++ + } + return level +} + +func mipScale(level int) int { + if level <= 0 { + return 1 + } + return 1 << min(level, 30) +} + +func mipDimension(dimension, level int) int { + scale := mipScale(level) + return max(1, (dimension+scale-1)/scale) +} + +func tileGrid(width, height, level int) (columns, rows int) { + mipWidth := mipDimension(width, level) + mipHeight := mipDimension(height, level) + return (mipWidth + tileInterior - 1) / tileInterior, + (mipHeight + tileInterior - 1) / tileInterior +} + +func generateRenderTile(ctx context.Context, source *renderSource, key tileKey) (*renderTile, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if source == nil || source.frame == nil { + return nil, errors.New("cannot generate a tile without a render source") + } + frameBounds := source.frame.Bounds() + width, height := frameBounds.Dx(), frameBounds.Dy() + if key.level < 0 || key.level > maxMipLevel(width, height) { + return nil, fmt.Errorf("invalid tile level %d", key.level) + } + columns, rows := tileGrid(width, height, key.level) + if key.x < 0 || key.x >= columns || key.y < 0 || key.y >= rows { + return nil, fmt.Errorf("invalid tile coordinate (%d,%d) for %dx%d grid", key.x, key.y, columns, rows) + } + + scale := mipScale(key.level) + mipBounds := image.Rect(0, 0, mipDimension(width, key.level), mipDimension(height, key.level)) + interior := image.Rect( + key.x*tileInterior, + key.y*tileInterior, + min((key.x+1)*tileInterior, mipBounds.Max.X), + min((key.y+1)*tileInterior, mipBounds.Max.Y), + ) + texture := image.NewRGBA(image.Rect(0, 0, interior.Dx()+2*tileGutter, interior.Dy()+2*tileGutter)) + sample := interior.Inset(-tileGutter).Intersect(mipBounds) + destinationMin := sample.Min.Sub(interior.Min.Sub(image.Pt(tileGutter, tileGutter))) + destination := image.Rectangle{Min: destinationMin, Max: destinationMin.Add(sample.Size())} + sourceRect := image.Rect( + frameBounds.Min.X+sample.Min.X*scale, + frameBounds.Min.Y+sample.Min.Y*scale, + frameBounds.Min.X+sample.Max.X*scale, + frameBounds.Min.Y+sample.Max.Y*scale, + ) + if key.level == 0 { + draw.Draw(texture, destination, source.frame, sourceRect.Min, draw.Src) + } else { + scaleSource := source.frame + if sourceRect.Intersect(frameBounds) != sourceRect { + scaleSource = edgeClampedImage{ + Image: source.frame, + bounds: image.Rect( + frameBounds.Min.X, + frameBounds.Min.Y, + frameBounds.Min.X+mipBounds.Dx()*scale, + frameBounds.Min.Y+mipBounds.Dy()*scale, + ), + } + } + xdraw.ApproxBiLinear.Scale(texture, destination, scaleSource, sourceRect, draw.Src, nil) + } + if err := ctx.Err(); err != nil { + return nil, err + } + + fillMissingGutters(texture, interior, mipBounds) + return &renderTile{key: key, texture: texture, interior: interior, scale: scale}, nil +} + +func fillMissingGutters(texture *image.RGBA, interior, mipBounds image.Rectangle) { + width, height := texture.Bounds().Dx(), texture.Bounds().Dy() + if interior.Min.X == mipBounds.Min.X { + for y := range height { + texture.SetRGBA(0, y, texture.RGBAAt(tileGutter, y)) + } + } + if interior.Max.X == mipBounds.Max.X { + for y := range height { + texture.SetRGBA(width-1, y, texture.RGBAAt(width-1-tileGutter, y)) + } + } + if interior.Min.Y == mipBounds.Min.Y { + for x := range width { + texture.SetRGBA(x, 0, texture.RGBAAt(x, tileGutter)) + } + } + if interior.Max.Y == mipBounds.Max.Y { + for x := range width { + texture.SetRGBA(x, height-1, texture.RGBAAt(x, height-1-tileGutter)) + } + } +} diff --git a/internal/ui/compare/tile_test.go b/internal/ui/compare/tile_test.go new file mode 100644 index 0000000..50990a9 --- /dev/null +++ b/internal/ui/compare/tile_test.go @@ -0,0 +1,258 @@ +package compare + +import ( + "context" + "image" + "image/color" + "reflect" + "testing" + + "fyne.io/fyne/v2" +) + +func plannerScene(width, height int, viewport fyne.Size, position fyne.Position, display fyne.Size, pixels image.Point) paneScene { + frame := image.NewRGBA(image.Rect(0, 0, width, height)) + overview := image.Image(frame) + if width > overviewMaxDimension || height > overviewMaxDimension { + scale := float64(overviewMaxDimension) / float64(max(width, height)) + overview = image.NewRGBA(image.Rect(0, 0, + max(1, int(float64(width)*scale+0.5)), + max(1, int(float64(height)*scale+0.5)), + )) + } + return paneScene{ + source: &renderSource{frame: frame, overview: overview}, + viewport: viewport, + imagePosition: position, + imageSize: display, + displaySize: pixels, + } +} + +func visibleRequests(plan tilePlan) []tileRequest { + requests := make([]tileRequest, 0, len(plan.requests)) + for _, request := range plan.requests { + if request.visible { + requests = append(requests, request) + } + } + return requests +} + +func TestTilePlanner_SelectsDensityAndCoarsensToSamplerBudget(t *testing.T) { + tests := []struct { + name string + scene paneScene + wantLevel int + wantVisible int + }{ + { + name: "overview already meets display density", + scene: plannerScene(4096, 2048, fyne.NewSize(800, 400), fyne.Position{}, fyne.NewSize(800, 400), image.Pt(800, 400)), + wantLevel: 0, + wantVisible: 0, + }, + { + name: "two-x display retains another mip", + scene: plannerScene(4096, 2048, fyne.NewSize(800, 400), fyne.Position{}, fyne.NewSize(800, 400), image.Pt(1600, 800)), + wantLevel: 1, + wantVisible: 6, + }, + { + name: "sampler budget forces coarser level", + scene: plannerScene(10000, 10000, fyne.NewSize(1000, 1000), fyne.Position{}, fyne.NewSize(1000, 1000), image.Pt(10000, 10000)), + wantLevel: 3, + wantVisible: 4, + }, + { + name: "zoomed viewport keeps level zero", + scene: plannerScene(10000, 10000, fyne.NewSize(1000, 1000), fyne.NewPos(-4500, -4500), fyne.NewSize(10000, 10000), image.Pt(10000, 10000)), + wantLevel: 0, + wantVisible: 4, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + plan := planTiles(tc.scene) + if plan.level != tc.wantLevel { + t.Errorf("level = %d, want %d", plan.level, tc.wantLevel) + } + if got := len(visibleRequests(plan)); got != tc.wantVisible { + t.Errorf("visible requests = %d, want %d (%v)", got, tc.wantVisible, plan.requests) + } + if len(plan.requests) > detailSamplerCount { + t.Errorf("requests = %d, exceeds %d detail samplers", len(plan.requests), detailSamplerCount) + } + }) + } +} + +func TestTilePlanner_SkipsDuplicateDetailsForOriginalSizeOverview(t *testing.T) { + scene := plannerScene( + 800, 400, + fyne.NewSize(400, 200), + fyne.NewPos(-200, -100), + fyne.NewSize(1600, 800), + image.Pt(3200, 1600), + ) + if plan := planTiles(scene); len(plan.requests) != 0 { + t.Fatalf("original-size overview planned duplicate details: %#v", plan.requests) + } +} + +func TestTilePlanner_UsesSwipeRevealInsteadOfHiddenFullPane(t *testing.T) { + scene := plannerScene( + 10000, 10000, + fyne.NewSize(4000, 4000), + fyne.Position{}, + fyne.NewSize(10000, 10000), + image.Pt(10000, 10000), + ) + full := planTiles(scene) + if full.level == 0 { + t.Fatal("full pane fixture did not require a coarser mip") + } + + scene.revealSet = true + scene.revealPosition = fyne.Position{} + scene.revealSize = fyne.NewSize(500, 500) + revealed := planTiles(scene) + if revealed.level != 0 { + t.Errorf("500px swipe reveal level = %d, want sharp level 0", revealed.level) + } + if got := len(visibleRequests(revealed)); got != 1 { + t.Errorf("visible requests through swipe reveal = %d, want 1", got) + } +} + +func TestTilePlanner_PrefetchIsBoundedNearestAndDeterministic(t *testing.T) { + scene := plannerScene( + 10000, 10000, + fyne.NewSize(500, 500), + fyne.NewPos(-4750, -4750), + fyne.NewSize(10000, 10000), + image.Pt(10000, 10000), + ) + first := planTiles(scene) + second := planTiles(scene) + if !reflect.DeepEqual(first, second) { + t.Fatalf("identical scenes produced different plans\nfirst: %#v\nsecond: %#v", first, second) + } + if len(first.requests) != detailSamplerCount { + t.Fatalf("requests = %d, want all %d slots filled", len(first.requests), detailSamplerCount) + } + seen := make(map[tileKey]bool) + for _, request := range first.requests { + if seen[request.key] { + t.Errorf("duplicate request for tile %+v", request.key) + } + seen[request.key] = true + if request.key.level != 0 { + t.Errorf("prefetch changed level to %d, want 0", request.key.level) + } + } + if got := len(visibleRequests(first)); got != 4 { + t.Errorf("visible requests = %d, want 4", got) + } +} + +func TestGenerateRenderTile_LevelZeroHasExactPixelsAndRealGutters(t *testing.T) { + frame := image.NewRGBA(image.Rect(0, 0, 1024, 2)) + for y := range 2 { + for x := range 1024 { + frame.SetRGBA(x, y, color.RGBA{R: uint8(x), G: uint8(x >> 8), B: uint8(y), A: 255}) + } + } + source, err := prepareRenderSource(context.Background(), frame) + if err != nil { + t.Fatalf("prepareRenderSource: %v", err) + } + + left, err := generateRenderTile(context.Background(), source, tileKey{level: 0, x: 0, y: 0}) + if err != nil { + t.Fatalf("generate left tile: %v", err) + } + if got := left.texture.Bounds().Size(); got != image.Pt(1024, 4) { + t.Fatalf("left texture size = %v, want 1024x4", got) + } + assertPixel := func(label string, got color.Color, x, y int) { + t.Helper() + if gotRGBA, want := color.RGBAModel.Convert(got), color.RGBAModel.Convert(frame.At(x, y)); gotRGBA != want { + t.Errorf("%s = %v, want source(%d,%d) %v", label, gotRGBA, x, y, want) + } + } + assertPixel("left outer gutter", left.texture.At(0, 1), 0, 0) + assertPixel("left first interior", left.texture.At(1, 1), 0, 0) + assertPixel("left last interior", left.texture.At(1022, 1), 1021, 0) + assertPixel("left neighboring gutter", left.texture.At(1023, 1), 1022, 0) + + right, err := generateRenderTile(context.Background(), source, tileKey{level: 0, x: 1, y: 0}) + if err != nil { + t.Fatalf("generate right tile: %v", err) + } + if got := right.texture.Bounds().Size(); got != image.Pt(4, 4) { + t.Fatalf("right texture size = %v, want 4x4", got) + } + assertPixel("right neighboring gutter", right.texture.At(0, 1), 1021, 0) + assertPixel("right first interior", right.texture.At(1, 1), 1022, 0) + assertPixel("right last interior", right.texture.At(2, 1), 1023, 0) + assertPixel("right outer gutter", right.texture.At(3, 1), 1023, 0) +} + +func TestGenerateRenderTile_OddCoarseEdgeKeepsNeighborGuttersContinuous(t *testing.T) { + frame := image.NewRGBA(image.Rect(0, 0, 2045, 3)) + for y := range 3 { + for x := range 2045 { + frame.SetRGBA(x, y, color.RGBA{ + R: uint8((x * 37) % 251), + G: uint8((x*x + y*19) % 253), + B: uint8((x*11 + y*47) % 255), + A: 255, + }) + } + } + source, err := prepareRenderSource(context.Background(), frame) + if err != nil { + t.Fatalf("prepareRenderSource: %v", err) + } + + left, err := generateRenderTile(context.Background(), source, tileKey{level: 1, x: 0, y: 0}) + if err != nil { + t.Fatalf("generate left coarse tile: %v", err) + } + right, err := generateRenderTile(context.Background(), source, tileKey{level: 1, x: 1, y: 0}) + if err != nil { + t.Fatalf("generate right coarse tile: %v", err) + } + for y := 1; y < left.texture.Bounds().Dy()-1; y++ { + if got, want := left.texture.RGBAAt(left.texture.Bounds().Dx()-2, y), right.texture.RGBAAt(0, y); got != want { + t.Errorf("left interior/right gutter mismatch at row %d: %v != %v", y, got, want) + } + if got, want := left.texture.RGBAAt(left.texture.Bounds().Dx()-1, y), right.texture.RGBAAt(1, y); got != want { + t.Errorf("left gutter/right interior mismatch at row %d: %v != %v", y, got, want) + } + } +} + +func TestRenderSourceTileCache_IsByteBoundedAndReturnsHits(t *testing.T) { + source, err := prepareRenderSource(context.Background(), image.NewRGBA(image.Rect(0, 0, 1, 1))) + if err != nil { + t.Fatalf("prepareRenderSource: %v", err) + } + first := &renderTile{key: tileKey{}, texture: image.NewRGBA(image.Rect(0, 0, 1024, 1024))} + source.tiles.Add(first.key.cacheKey(), first) + if got, ok := source.tiles.Get(first.key.cacheKey()); !ok || got != first { + t.Fatal("tile cache did not return the exact cached tile") + } + for i := 1; i < 17; i++ { + key := tileKey{x: i} + source.tiles.Add(key.cacheKey(), &renderTile{key: key, texture: image.NewRGBA(image.Rect(0, 0, 1024, 1024))}) + } + if got := source.tiles.Bytes(); got > tileCacheBudgetBytes { + t.Errorf("tile cache bytes = %d, exceeds budget %d", got, tileCacheBudgetBytes) + } + if got := source.tiles.Len(); got != 16 { + t.Errorf("tile cache entries = %d, want 16 full-size textures", got) + } +} diff --git a/internal/ui/compare/transform.go b/internal/ui/compare/transform.go index 061d42d..1576876 100644 --- a/internal/ui/compare/transform.go +++ b/internal/ui/compare/transform.go @@ -1,6 +1,7 @@ package compare import ( + "image" "math" "fyne.io/fyne/v2" @@ -13,7 +14,6 @@ const ( compareZoomStep = float32(1.25) minZoomFactor = float32(0.05) maxZoomFactor = float32(16) - comparePanSlack = float32(0.5) scrollSensitivity = float32(0.01) ) @@ -24,31 +24,42 @@ const ( absoluteScale ) -type linkedTransform struct { +type photoTransform struct { center fyne.Position factor float32 mode scaleMode } -func defaultLinkedTransform() linkedTransform { - return linkedTransform{ +type cameraTransform struct { + zoom float32 + // offset is measured from the pane center as a fraction of its viewport. + // Both comparison panes use the same value, so a camera pan is identical + // in points while their viewports have the same size. + offset fyne.Position +} + +func defaultPhotoTransform() photoTransform { + return photoTransform{ center: fyne.NewPos(0.5, 0.5), factor: 1, mode: fitRelative, } } -func newPane(feature *Feature, index int) pane { - img := newPaneImage() +func defaultCameraTransform() cameraTransform { + return cameraTransform{zoom: 1} +} + +func newPane(feature *Feature, index int, renderer paneRenderer) pane { input := newPaneInput(feature, index) - viewport := container.New(paneImageLayout{feature: feature, index: index}, img, input) + viewport := container.New(paneImageLayout{feature: feature, index: index}, renderer.Object(), input) spinner := newPaneSpinner() return pane{ - root: container.NewStack(viewport, container.NewCenter(spinner)), - image: img, - input: input, - spinner: spinner, + root: container.NewStack(viewport, container.NewCenter(spinner)), + renderer: renderer, + input: input, + spinner: spinner, } } @@ -58,11 +69,10 @@ type paneImageLayout struct { } func (l paneImageLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + l.feature.viewports[l.index] = size if len(objects) > 1 { - objects[1].Move(fyne.NewPos(0, 0)) - objects[1].Resize(size) + l.feature.layoutPaneInput(l.index, objects[1]) } - l.feature.viewports[l.index] = size l.feature.applyTransform() } @@ -71,38 +81,116 @@ func (paneImageLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { return fyne.Si // HandleKey applies comparison-owned transform keys. The caller still owns // Escape and F1, and swallows every unsupported key while comparison is active. func (f *Feature) HandleKey(name fyne.KeyName) { + f.handleKey(name, f.currentModifiers()) +} + +func (f *Feature) handleKey(name fyne.KeyName, modifiers fyne.KeyModifier) { if !f.active || !f.ready { return } - if f.handleDividerKey(name) { + if f.handleDividerKey(name, modifiers) { return } + if f.unlinked { + if f.hoveredPane < 0 || f.hoveredPane >= len(f.photoTransforms) { + return + } + if !f.applyPhotoKey(f.hoveredPane, name) { + return + } + f.applyTransform() + return + } + + if !f.applyCameraKey(name) { + return + } + + f.applyTransform() +} + +func (f *Feature) applyPhotoKey(index int, name fyne.KeyName) bool { + transform := &f.photoTransforms[index] switch name { case fyne.Key0: - f.transform = defaultLinkedTransform() + *transform = photoTransform{factor: 1 / f.camera.zoom, mode: fitRelative} + f.centerPhotoInCurrentCamera(index) + case fyne.Key1: + *transform = photoTransform{factor: 1 / f.camera.zoom, mode: absoluteScale} + f.centerPhotoInCurrentCamera(index) case fyne.KeyPlus, fyne.KeyEqual: - f.transform.factor = min(f.transform.factor*compareZoomStep, maxZoomFactor) + f.zoomPhotoAt(index, transform.factor*compareZoomStep, f.paneCenter(index)) case fyne.KeyMinus: - f.transform.factor = max(f.transform.factor/compareZoomStep, minZoomFactor) - case fyne.Key1: - f.transform = linkedTransform{ - center: fyne.NewPos(0.5, 0.5), - factor: 1, - mode: absoluteScale, - } + f.zoomPhotoAt(index, transform.factor/compareZoomStep, f.paneCenter(index)) default: + return false + } + return true +} + +func (f *Feature) paneCenter(index int) fyne.Position { + return fyne.NewPos(f.viewports[index].Width/2, f.viewports[index].Height/2) +} + +func (f *Feature) centerPhotoInCurrentCamera(index int) { + transform := &f.photoTransforms[index] + native := frameSize(f.loaded[index]) + scale := f.scaleForTransform(index, *transform) * f.camera.zoom + visible := fyne.NewSize(native.Width*scale, native.Height*scale) + offset := f.cameraOffsetFor(index) + transform.center = fyne.NewPos( + 0.5+offset.X/visible.Width, + 0.5+offset.Y/visible.Height, + ) +} + +func (f *Feature) zoomPhotoAt(index int, factor float32, anchor fyne.Position) { + transform := &f.photoTransforms[index] + minimum, maximum := f.photoFactorRange() + factor = min(max(factor, minimum), maximum) + if factor == transform.factor { return } - f.applyTransform() - f.repaint() + native := frameSize(f.loaded[index]) + oldScale := f.scaleForTransform(index, *transform) * f.camera.zoom + updated := *transform + updated.factor = factor + newScale := f.scaleForTransform(index, updated) * f.camera.zoom + oldSize := fyne.NewSize(native.Width*oldScale, native.Height*oldScale) + newSize := fyne.NewSize(native.Width*newScale, native.Height*newScale) + viewportCenter := f.paneCenter(index) + offset := f.cameraOffsetFor(index) + updated.center = fyne.NewPos( + transform.center.X+(anchor.X-viewportCenter.X-offset.X)*(1/oldSize.Width-1/newSize.Width), + transform.center.Y+(anchor.Y-viewportCenter.Y-offset.Y)*(1/oldSize.Height-1/newSize.Height), + ) + updated.center = f.clampPhotoCenter(index, updated, updated.center) + *transform = updated +} + +func (f *Feature) applyCameraKey(name fyne.KeyName) bool { + switch name { + case fyne.Key0: + f.fitCamera() + case fyne.Key1: + f.camera = defaultCameraTransform() + case fyne.KeyPlus, fyne.KeyEqual: + f.zoomCamera(f.camera.zoom*compareZoomStep, fyne.Position{}) + case fyne.KeyMinus: + f.zoomCamera(f.camera.zoom/compareZoomStep, fyne.Position{}) + default: + return false + } + return true } func (f *Feature) handleScroll(index int, ev *fyne.ScrollEvent) { if !f.active || !f.ready || ev == nil || index < 0 || index >= len(f.panes) { return } + f.setHoveredPane(index) if f.callbacks.Modifiers != nil && f.callbacks.Modifiers()&fyne.KeyModifierShift != 0 { f.panBy(index, ev.Scrolled) return @@ -110,118 +198,298 @@ func (f *Feature) handleScroll(index int, ev *fyne.ScrollEvent) { if ev.Scrolled.DY == 0 { return } + if f.unlinked { + transform := &f.photoTransforms[index] + oldFactor := transform.factor + minFactor, maxFactor := f.photoFactorRange() + newFactor := min(max( + oldFactor*float32(math.Exp(float64(ev.Scrolled.DY*scrollSensitivity))), + minFactor, + ), maxFactor) + if newFactor == oldFactor { + return + } - oldFactor := f.transform.factor - newFactor := min(max( - oldFactor*float32(math.Exp(float64(ev.Scrolled.DY*scrollSensitivity))), - minZoomFactor, - ), maxZoomFactor) - if newFactor == oldFactor { + f.zoomPhotoAt(index, newFactor, ev.Position) + f.applyTransform() return } - native := frameSize(f.loaded[index]) - oldScale := f.scaleFor(index, oldFactor) - newScale := f.scaleFor(index, newFactor) - oldScaled := fyne.NewSize(native.Width*oldScale, native.Height*oldScale) - newScaled := fyne.NewSize(native.Width*newScale, native.Height*newScale) + oldZoom := f.camera.zoom + newZoom := oldZoom * float32(math.Exp(float64(ev.Scrolled.DY*scrollSensitivity))) + if newZoom == oldZoom { + return + } viewport := f.viewports[index] - f.transform.center = fyne.NewPos( - f.transform.center.X+(ev.Position.X-viewport.Width/2)*(1/oldScaled.Width-1/newScaled.Width), - f.transform.center.Y+(ev.Position.Y-viewport.Height/2)*(1/oldScaled.Height-1/newScaled.Height), - ) - f.transform.factor = newFactor + f.zoomCamera(newZoom, fyne.NewPos( + ev.Position.X/viewport.Width-0.5, + ev.Position.Y/viewport.Height-0.5, + )) f.applyTransform() - f.repaint() +} + +func (f *Feature) photoFactorRange() (float32, float32) { + zoom := f.camera.zoom + if zoom <= 0 { + zoom = 1 + } + return minZoomFactor / zoom, maxZoomFactor / zoom +} + +func (f *Feature) cameraZoomRange() (float32, float32) { + minimum, maximum := float32(0), float32(math.MaxFloat32) + for _, transform := range f.photoTransforms { + if transform.factor <= 0 { + continue + } + minimum = max(minimum, minZoomFactor/transform.factor) + maximum = min(maximum, maxZoomFactor/transform.factor) + } + if maximum == float32(math.MaxFloat32) { + maximum = maxZoomFactor + } + if minimum > maximum { + minimum = maximum + } + return minimum, maximum +} + +func (f *Feature) zoomCamera(zoom float32, anchor fyne.Position) { + minimum, maximum := f.cameraZoomRange() + zoom = min(max(zoom, minimum), maximum) + if f.camera.zoom <= 0 || zoom == f.camera.zoom { + return + } + ratio := zoom / f.camera.zoom + f.camera.offset = fyne.NewPos( + f.camera.offset.X*ratio+(1-ratio)*anchor.X, + f.camera.offset.Y*ratio+(1-ratio)*anchor.Y, + ) + f.camera.zoom = zoom + f.clampCameraOffset() +} + +func (f *Feature) fitCamera() { + minimum, maximum := f.cameraZoomRange() + minX, maxX, minY, maxY, feasible := f.cameraFitRange(minimum) + if !feasible { + f.camera.zoom = minimum + f.camera.offset = fyne.Position{} + return + } + + low, high := minimum, maximum + for range 48 { + candidate := low + (high-low)/2 + if _, _, _, _, ok := f.cameraFitRange(candidate); ok { + low = candidate + } else { + high = candidate + } + } + f.camera.zoom = low + minX, maxX, minY, maxY, _ = f.cameraFitRange(low) + f.camera.offset = fyne.NewPos( + min(max(float32(0), minX), maxX), + min(max(float32(0), minY), maxY), + ) +} + +func (f *Feature) cameraFitRange(zoom float32) (minX, maxX, minY, maxY float32, feasible bool) { + minX, maxX = -float32(math.MaxFloat32), float32(math.MaxFloat32) + minY, maxY = -float32(math.MaxFloat32), float32(math.MaxFloat32) + for i := range f.panes { + viewport := f.viewports[i] + native := frameSize(f.loaded[i]) + if !validViewport(viewport) || !validViewport(native) { + return 0, 0, 0, 0, false + } + transform := f.photoTransforms[i] + baseScale := f.scaleForTransform(i, transform) + scaled := fyne.NewSize(native.Width*baseScale*zoom, native.Height*baseScale*zoom) + withoutOffset := fyne.NewPos( + viewport.Width/2-transform.center.X*scaled.Width, + viewport.Height/2-transform.center.Y*scaled.Height, + ) + lowX := -withoutOffset.X / viewport.Width + highX := (viewport.Width - withoutOffset.X - scaled.Width) / viewport.Width + lowY := -withoutOffset.Y / viewport.Height + highY := (viewport.Height - withoutOffset.Y - scaled.Height) / viewport.Height + minX, maxX = max(minX, lowX), min(maxX, highX) + minY, maxY = max(minY, lowY), min(maxY, highY) + } + return minX, maxX, minY, maxY, minX <= maxX && minY <= maxY } func (f *Feature) panBy(index int, delta fyne.Delta) { if !f.active || !f.ready || index < 0 || index >= len(f.panes) { return } + f.setHoveredPane(index) + if f.unlinked { + transform := &f.photoTransforms[index] + native := frameSize(f.loaded[index]) + scale := f.scaleForTransform(index, *transform) + scaled := fyne.NewSize(native.Width*scale*f.camera.zoom, native.Height*scale*f.camera.zoom) + if !validViewport(scaled) { + return + } + transform.center = f.clampPhotoCenter(index, *transform, fyne.NewPos( + transform.center.X-delta.DX/scaled.Width, + transform.center.Y-delta.DY/scaled.Height, + )) + f.applyTransform() + return + } + f.camera.offset = fyne.NewPos( + f.camera.offset.X+delta.DX/f.viewports[index].Width, + f.camera.offset.Y+delta.DY/f.viewports[index].Height, + ) + f.clampCameraOffset() + f.applyTransform() +} + +func (f *Feature) clampPhotoCenter(index int, transform photoTransform, center fyne.Position) fyne.Position { native := frameSize(f.loaded[index]) - scale := f.scaleFor(index, f.transform.factor) + scale := f.scaleForTransform(index, transform) * f.camera.zoom scaled := fyne.NewSize(native.Width*scale, native.Height*scale) if !validViewport(scaled) { - return + return center } - f.transform.center = fyne.NewPos( - f.transform.center.X-delta.DX/scaled.Width, - f.transform.center.Y-delta.DY/scaled.Height, + offset := f.cameraOffsetFor(index) + minX, minY := offset.X/scaled.Width, offset.Y/scaled.Height + return fyne.NewPos( + min(max(center.X, minX), 1+minX), + min(max(center.Y, minY), 1+minY), ) - f.applyTransform() - f.repaint() } func (f *Feature) applyTransform() { if !f.ready || !validViewport(f.viewports[0]) || !validViewport(f.viewports[1]) { return } - f.clampCenter() - for i := range f.panes { - f.panes[i].input.Move(fyne.NewPos(0, 0)) - f.panes[i].input.Resize(f.viewports[i]) native := frameSize(f.loaded[i]) if !validViewport(native) { continue } - scale := f.scaleFor(i, f.transform.factor) - scaled := fyne.NewSize(native.Width*scale, native.Height*scale) + transform := f.photoTransforms[i] + scale := f.scaleForTransform(i, transform) + baseSize := fyne.NewSize(native.Width*scale, native.Height*scale) + scaled := fyne.NewSize(baseSize.Width*f.camera.zoom, baseSize.Height*f.camera.zoom) + cameraOffset := f.cameraOffsetFor(i) position := fyne.NewPos( - f.viewports[i].Width/2-f.transform.center.X*scaled.Width, - f.viewports[i].Height/2-f.transform.center.Y*scaled.Height, + f.viewports[i].Width/2-transform.center.X*scaled.Width+cameraOffset.X, + f.viewports[i].Height/2-transform.center.Y*scaled.Height+cameraOffset.Y, ) - f.panes[i].image.Resize(scaled) - f.panes[i].image.Move(position) + revealPosition, revealSize := f.paneVisibleArea(i) + width, height := f.vectorPixels(f.panes[i].input, scaled) + f.panes[i].present(paneScene{ + source: f.renderSources[i], + viewport: f.viewports[i], + revealSet: true, + revealPosition: revealPosition, + revealSize: revealSize, + imagePosition: position, + imageSize: scaled, + panePosition: displayPixelPosition(f.panes[i].root), + displaySize: image.Pt(width, height), + }) f.requestVectorRender(i, scaled) } } -func (f *Feature) clampCenter() { - minX, maxX, minY, maxY := f.sharedPanRange() - f.transform.center = fyne.NewPos( - min(max(f.transform.center.X, minX), maxX), - min(max(f.transform.center.Y, minY), maxY), +func (f *Feature) applyReveal() { + for i := range f.panes { + scene := f.panes[i].scene + if scene.source == nil { + continue + } + scene.revealSet = true + scene.revealPosition, scene.revealSize = f.paneVisibleArea(i) + scene.panePosition = displayPixelPosition(f.panes[i].root) + f.panes[i].present(scene) + } +} + +func (f *Feature) clampCameraOffset() { + minX, maxX, minY, maxY := f.cameraPanRange() + f.camera.offset = fyne.NewPos( + min(max(f.camera.offset.X, minX), maxX), + min(max(f.camera.offset.Y, minY), maxY), + ) +} + +func (f *Feature) cameraOffsetFor(index int) fyne.Position { + return fyne.NewPos( + f.camera.offset.X*f.viewports[index].Width, + f.camera.offset.Y*f.viewports[index].Height, + ) +} + +func (f *Feature) visiblePhotoTransform(index int) photoTransform { + transform := f.photoTransforms[index] + native := frameSize(f.loaded[index]) + baseScale := f.scaleForTransform(index, transform) + visible := fyne.NewSize( + native.Width*baseScale*f.camera.zoom, + native.Height*baseScale*f.camera.zoom, + ) + offset := f.cameraOffsetFor(index) + transform.center = fyne.NewPos( + transform.center.X-offset.X/visible.Width, + transform.center.Y-offset.Y/visible.Height, ) + transform.factor *= f.camera.zoom + return transform } -func (f *Feature) sharedPanRange() (minX, maxX, minY, maxY float32) { - minX, maxX = 0, 1 - minY, maxY = 0, 1 +func (f *Feature) cameraPanRange() (minX, maxX, minY, maxY float32) { + minX, maxX = -float32(math.MaxFloat32), float32(math.MaxFloat32) + minY, maxY = -float32(math.MaxFloat32), float32(math.MaxFloat32) for i := range f.panes { native := frameSize(f.loaded[i]) - scale := f.scaleFor(i, f.transform.factor) + transform := f.photoTransforms[i] + scale := f.scaleForTransform(i, transform) * f.camera.zoom scaled := fyne.NewSize(native.Width*scale, native.Height*scale) - lowX, highX := normalizedPanRange(scaled.Width, f.viewports[i].Width) - lowY, highY := normalizedPanRange(scaled.Height, f.viewports[i].Height) + withoutOffset := fyne.NewPos( + f.viewports[i].Width/2-transform.center.X*scaled.Width, + f.viewports[i].Height/2-transform.center.Y*scaled.Height, + ) + paneCenter := fyne.NewPos(f.viewports[i].Width/2, f.viewports[i].Height/2) + lowX, highX := paneCenter.X-(withoutOffset.X+scaled.Width), paneCenter.X-withoutOffset.X + lowY, highY := paneCenter.Y-(withoutOffset.Y+scaled.Height), paneCenter.Y-withoutOffset.Y + lowX, highX = lowX/f.viewports[i].Width, highX/f.viewports[i].Width + lowY, highY = lowY/f.viewports[i].Height, highY/f.viewports[i].Height minX, maxX = max(minX, lowX), min(maxX, highX) minY, maxY = max(minY, lowY), min(maxY, highY) } + if minX > maxX { + minX, maxX = f.camera.offset.X, f.camera.offset.X + } + if minY > maxY { + minY, maxY = f.camera.offset.Y, f.camera.offset.Y + } return minX, maxX, minY, maxY } -func (f *Feature) canPan() bool { - if !f.active || !f.ready || !validViewport(f.viewports[0]) || !validViewport(f.viewports[1]) { +func (f *Feature) canPan(index int) bool { + if !f.active || !f.ready || index < 0 || index >= len(f.panes) || + !validViewport(f.viewports[0]) || !validViewport(f.viewports[1]) { return false } - minX, maxX, minY, maxY := f.sharedPanRange() - return minX < maxX || minY < maxY -} - -func normalizedPanRange(scaled, viewport float32) (float32, float32) { - if scaled <= viewport+comparePanSlack { - return 0.5, 0.5 + if f.unlinked { + return validViewport(frameSize(f.loaded[index])) } - halfVisible := viewport / (2 * scaled) - return halfVisible, 1 - halfVisible + minX, maxX, minY, maxY := f.cameraPanRange() + return minX < maxX || minY < maxY } -func (f *Feature) scaleFor(index int, factor float32) float32 { - if f.transform.mode == fitRelative { - return fitScale(frameSize(f.loaded[index]), f.viewports[index]) * factor +func (f *Feature) scaleForTransform(index int, transform photoTransform) float32 { + if transform.mode == fitRelative { + return fitScale(frameSize(f.loaded[index]), f.viewports[index]) * transform.factor } - return factor + return transform.factor } func frameSize(loaded *imaging.LoadedImage) fyne.Size { diff --git a/internal/ui/compare/uiqueue.go b/internal/ui/compare/uiqueue.go index f7b5f0d..42646e3 100644 --- a/internal/ui/compare/uiqueue.go +++ b/internal/ui/compare/uiqueue.go @@ -2,8 +2,8 @@ package compare import "fyne.io/fyne/v2" -// UIQueue is how comparison load and vector workers hand completed widget -// work to the UI goroutine. Production uses fyneQueue; tests install a +// UIQueue is how comparison load, vector, and tile workers hand completed +// widget work to the UI goroutine. Production uses fyneQueue; tests install a // drainable queue because Fyne's test driver runs fyne.Do inline on the worker. type UIQueue interface { Do(func()) diff --git a/internal/ui/compare/vector.go b/internal/ui/compare/vector.go index 055b3d1..589b7e6 100644 --- a/internal/ui/compare/vector.go +++ b/internal/ui/compare/vector.go @@ -2,7 +2,6 @@ package compare import ( "image" - "sync" "time" "fyne.io/fyne/v2" @@ -14,7 +13,7 @@ const defaultCompareVectorDebounce = 90 * time.Millisecond type vectorRasterState struct { lifecycle requestLifecycle - pending sync.WaitGroup + pending workTracker raster image.Point requested image.Point } @@ -54,6 +53,22 @@ func displayPixelSize(object fyne.CanvasObject, display fyne.Size) (int, int) { return int(position.X + 0.5), int(position.Y + 0.5) } +func displayPixelPosition(object fyne.CanvasObject) image.Point { + if object == nil { + return image.Point{} + } + position := object.Position() + if app := fyne.CurrentApp(); app != nil && app.Driver() != nil { + driver := app.Driver() + if canvas := driver.CanvasForObject(object); canvas != nil { + position = driver.AbsolutePositionForObject(object) + x, y := canvas.PixelCoordinateForPosition(position) + return image.Pt(x, y) + } + } + return image.Pt(int(position.X+0.5), int(position.Y+0.5)) +} + func (f *Feature) requestVectorRender(index int, display fyne.Size) { if !f.active || !f.ready || index < 0 || index >= len(f.loaded) { return @@ -63,7 +78,7 @@ func (f *Feature) requestVectorRender(index int, display fyne.Size) { return } - width, height := f.vectorPixels(f.panes[index].image, display) + width, height := f.vectorPixels(f.panes[index].input, display) width, height = imaging.ClampVectorRaster(width, height) target := image.Pt(width, height) state := &f.vectors[index] @@ -102,6 +117,10 @@ func (f *Feature) rasterizeVector(index int, vector *imaging.Vector, target imag if err != nil || !token.latest() { return } + prepared, err := f.prepareSource(token.context(), frame) + if err != nil || !token.latest() { + return + } f.queueUI(func() { state := &f.vectors[index] @@ -110,9 +129,9 @@ func (f *Feature) rasterizeVector(index int, vector *imaging.Vector, target imag return } f.rendered[index] = frame + f.renderSources[index] = prepared state.setRaster(frame) - f.panes[index].image.Image = frame - f.panes[index].image.Refresh() - f.repaint() + f.panes[index].present(paneScene{source: f.renderSources[index]}) + f.applyTransform() }) } diff --git a/internal/ui/compare/vector_test.go b/internal/ui/compare/vector_test.go index 68bbbcf..3b6dd04 100644 --- a/internal/ui/compare/vector_test.go +++ b/internal/ui/compare/vector_test.go @@ -4,6 +4,7 @@ import ( "context" "image" "image/color" + "sync" "testing" "time" @@ -51,7 +52,7 @@ func TestCompareSettle_WaitsForQueuedVectorCompletion(t *testing.T) { if err != nil { t.Fatalf("decode SVG fixture: %v", err) } - feature := New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "left.svg" { return vector, nil } @@ -79,6 +80,67 @@ func TestCompareSettle_WaitsForQueuedVectorCompletion(t *testing.T) { } } +func TestCompareSettle_DrainsVectorReplacementBeforeWaitingForObsoleteTiles(t *testing.T) { + app := fynetest.NewApp() + t.Cleanup(app.Quit) + + vector, err := imaging.DecodeLoaded(context.Background(), uitest.SVGBytes(4096, 2048), imaging.DefaultImgCacheBytes) + if err != nil { + t.Fatalf("decode SVG fixture: %v", err) + } + feature := newFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + if uri.Name() == "left.svg" { + return vector, nil + } + return &imaging.LoadedImage{Frames: []image.Image{image.NewRGBA(image.Rect(0, 0, 40, 20))}}, nil + }, Callbacks{}, newShaderPaneRenderer) + feature.vectorDebounce = 0 + feature.vectorPixels = func(fyne.CanvasObject, fyne.Size) (int, int) { return 2048, 1024 } + queue := &uitest.UIQueue{} + feature.SetUIQueue(queue) + + left := feature.panes[0].renderer.(*shaderPaneRenderer) + oldTileStarted := make(chan struct{}) + var started sync.Once + left.generateTile = func(ctx context.Context, source *renderSource, key tileKey) (*renderTile, error) { + if source.frame.Bounds().Dx() == 4096 { + started.Do(func() { close(oldTileStarted) }) + <-ctx.Done() + return nil, ctx.Err() + } + return generateRenderTile(ctx, source, key) + } + feature.vectorRasterize = func(_ *imaging.Vector, width, height int) (image.Image, error) { + select { + case <-oldTileStarted: + case <-time.After(time.Second): + t.Fatal("obsolete tile worker did not start before vector replacement") + } + return image.NewRGBA(image.Rect(0, 0, width, height)), nil + } + + feature.Overlay().Resize(fyne.NewSize(800, 400)) + feature.Open([2]fyne.URI{ + storage.NewFileURI("left.svg"), + storage.NewFileURI("right.png"), + }) + t.Cleanup(func() { + feature.Close() + cleanupCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = feature.Settle(cleanupCtx) + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := feature.Settle(ctx); err != nil { + t.Fatalf("Settle waited for an obsolete tile before applying its queued vector replacement: %v", err) + } + if got := feature.rendered[0].Bounds().Size(); got != image.Pt(2048, 1024) { + t.Fatalf("settled vector raster = %v, want 2048x1024 replacement", got) + } +} + func TestCompareStale_VectorRenderCannotPaintSupersededTarget(t *testing.T) { app := fynetest.NewApp() t.Cleanup(app.Quit) @@ -91,7 +153,7 @@ func TestCompareStale_VectorRenderCannotPaintSupersededTarget(t *testing.T) { newFinished := make(chan struct{}) releaseOld := make(chan struct{}) - feature := New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "left.svg" { return vector, nil } @@ -157,7 +219,7 @@ func TestCompareCancel_VectorCompletionCannotPaintAfterClose(t *testing.T) { if err != nil { t.Fatalf("decode SVG fixture: %v", err) } - feature := New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "left.svg" { return vector, nil } @@ -206,7 +268,7 @@ func TestCompareVector_RasterTargetHonorsExistingPixelLimit(t *testing.T) { t.Fatalf("decode SVG fixture: %v", err) } requested := image.Point{} - feature := New(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { + feature := newReferenceFeature(func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { if uri.Name() == "left.svg" { return vector, nil } diff --git a/internal/ui/compare/workgroup.go b/internal/ui/compare/workgroup.go new file mode 100644 index 0000000..1727cb5 --- /dev/null +++ b/internal/ui/compare/workgroup.go @@ -0,0 +1,72 @@ +package compare + +import ( + "context" + "sync" +) + +// workTracker is a reusable, context-selectable worker barrier. Each busy +// epoch owns its own channel, so a timed-out waiter never leaves a goroutine +// behind and cannot race a later Add after the prior epoch settles. +type workTracker struct { + mu sync.Mutex + active int + idle chan struct{} +} + +func (w *workTracker) Add(delta int) { + if delta == 0 { + return + } + + w.mu.Lock() + defer w.mu.Unlock() + previous := w.active + next := previous + delta + if next < 0 { + panic("compare: negative work tracker count") + } + if previous == 0 && next > 0 { + w.idle = make(chan struct{}) + } + w.active = next + if previous > 0 && next == 0 { + close(w.idle) + w.idle = nil + } +} + +func (w *workTracker) Done() { w.Add(-1) } + +func (w *workTracker) Go(run func()) { + w.Add(1) + go func() { + defer w.Done() + run() + }() +} + +func (w *workTracker) Wait() { + if done := w.done(); done != nil { + <-done + } +} + +func (w *workTracker) WaitContext(ctx context.Context) error { + done := w.done() + if done == nil { + return nil + } + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (w *workTracker) done() <-chan struct{} { + w.mu.Lock() + defer w.mu.Unlock() + return w.idle +} diff --git a/internal/ui/compare/workgroup_test.go b/internal/ui/compare/workgroup_test.go new file mode 100644 index 0000000..1cee964 --- /dev/null +++ b/internal/ui/compare/workgroup_test.go @@ -0,0 +1,37 @@ +package compare + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestWorkTracker_ContextTimeoutDoesNotPoisonReuse(t *testing.T) { + var work workTracker + work.Add(1) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := work.WaitContext(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("WaitContext error = %v, want context cancellation", err) + } + + work.Done() + work.Add(1) + go work.Done() + ctx, cancel = context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := work.WaitContext(ctx); err != nil { + t.Fatalf("reused work tracker did not settle: %v", err) + } +} + +func TestWorkTracker_WaitReturnsImmediatelyBeforeFirstWork(t *testing.T) { + var work workTracker + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := work.WaitContext(ctx); err != nil { + t.Fatalf("empty work tracker wait: %v", err) + } +} diff --git a/internal/ui/compare_fidelity_test.go b/internal/ui/compare_fidelity_test.go index 805c154..b9276e4 100644 --- a/internal/ui/compare_fidelity_test.go +++ b/internal/ui/compare_fidelity_test.go @@ -81,11 +81,11 @@ func requireSuccessfulCompareLoad(t *testing.T, loads map[string]observedCompare return result.loaded } -func comparisonImageHolding(t *testing.T, v *viewer, frame image.Image) *canvas.Image { +func comparisonImageHolding(t *testing.T, v *viewer, frame image.Image) *canvas.Shader { t.Helper() - for _, candidate := range comparisonImages(v.compare.Overlay()) { - if candidate.Image == frame { + for _, candidate := range comparisonShaders(v.compare.Overlay()) { + if candidate.Textures["overview"] == frame { return candidate } } @@ -161,8 +161,8 @@ func TestCompareAnimated_FreezesFirstDecodedFrameForEntireSession(t *testing.T) assertFrozen := func(stage string) { t.Helper() comparisonImageHolding(t, v, first) - for _, candidate := range comparisonImages(v.compare.Overlay()) { - if candidate.Image == later { + for _, candidate := range comparisonShaders(v.compare.Overlay()) { + if candidate.Textures["overview"] == later { t.Fatalf("comparison displayed a later animation frame %s", stage) } } diff --git a/internal/ui/compare_test.go b/internal/ui/compare_test.go index e13940c..8f0d467 100644 --- a/internal/ui/compare_test.go +++ b/internal/ui/compare_test.go @@ -41,20 +41,116 @@ func waitForCompare(t *testing.T, v *viewer) { } func fireCompareShortcut(v *viewer) { + fireCompareShortcutWithModifier(v, fyne.KeyModifierShortcutDefault) +} + +func fireCompareShortcutWithModifier(v *viewer, modifier fyne.KeyModifier) { handler := &fyne.ShortcutHandler{} wireGlobalShortcuts(handler, v) handler.TypedShortcut(&desktop.CustomShortcut{ KeyName: fyne.KeyD, - Modifier: fyne.KeyModifierShortcutDefault, + Modifier: modifier, }) } -func comparisonImages(root fyne.CanvasObject) []*canvas.Image { - var images []*canvas.Image +type shortcutCapture struct { + shortcuts []fyne.Shortcut +} + +func (c *shortcutCapture) AddShortcut(shortcut fyne.Shortcut, _ func(fyne.Shortcut)) { + c.shortcuts = append(c.shortcuts, shortcut) +} + +func TestCompareShortcut_RegistersDefaultAndPhysicalControlWithoutDuplicates(t *testing.T) { + v, _, _ := newTestUI(t) + capture := &shortcutCapture{} + wireCompareShortcut(capture, v) + + want := map[fyne.KeyModifier]bool{fyne.KeyModifierShortcutDefault: true} + want[fyne.KeyModifierControl] = true + got := make(map[fyne.KeyModifier]int) + for _, shortcut := range capture.shortcuts { + custom, ok := shortcut.(*desktop.CustomShortcut) + if !ok { + t.Fatalf("comparison shortcut type = %T, want *desktop.CustomShortcut", shortcut) + } + if custom.KeyName != fyne.KeyD { + t.Errorf("comparison shortcut key = %v, want D", custom.KeyName) + } + got[custom.Modifier]++ + } + for modifier := range want { + if got[modifier] != 1 { + t.Errorf("comparison D shortcut registrations for modifier %v = %d, want 1", modifier, got[modifier]) + } + } + for modifier, count := range got { + if !want[modifier] || count != 1 { + t.Errorf("unexpected comparison D shortcut registration: modifier=%v count=%d", modifier, count) + } + } +} + +func TestCompareShortcut_PhysicalControlOpensComparison(t *testing.T) { + v := openGridWith(t, "left.jpg", "right.jpg") + v.grid.HandleKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + v.grid.HandleKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + v.grid.HandleKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + + fireCompareShortcutWithModifier(v, fyne.KeyModifierControl) + waitForCompare(t, v) + if !v.compare.Visible() { + t.Fatal("physical Ctrl+D did not open comparison") + } +} + +func TestShutdownClosesActiveComparisonBeforeEventLoopStops(t *testing.T) { + application := fynetest.NewApp() + v, win := buildStartupViewer(application) + v.grid.SetUIQueue(&uitest.UIQueue{}) + v.compare.SetUIQueue(&uitest.UIQueue{}) + t.Cleanup(win.Close) + t.Cleanup(func() { drain(t, v) }) + + uris := []fyne.URI{ + uitest.TempJPEGURI(t, "a.jpg", 4, 4, color.White), + uitest.TempJPEGURI(t, "b.jpg", 4, 4, color.White), + uitest.TempJPEGURI(t, "c.jpg", 4, 4, color.White), + } + dropAndWait(t, v, uris...) + warmThumbs(t, v) + v.grid.Toggle() + v.grid.HandleKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + v.grid.HandleKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + v.grid.HandleKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + fireCompareShortcut(v) + waitForCompare(t, v) + + lifecycle, ok := application.Lifecycle().(interface{ OnStopped() func() }) + if !ok { + t.Skip("test app lifecycle does not expose its stopped hook") + } + original := lifecycle.OnStopped() + registerShutdown(application, v) + shutdown := lifecycle.OnStopped() + application.Lifecycle().SetOnStopped(original) + if shutdown == nil { + t.Fatal("registerShutdown did not install a stopped hook") + } + + shutdown() + if v.compare.Visible() { + t.Fatal("shutdown left comparison workers and surface active") + } +} + +func comparisonShaders(root fyne.CanvasObject) []*canvas.Shader { + var shaders []*canvas.Shader var walk func(fyne.CanvasObject) walk = func(object fyne.CanvasObject) { - if img, ok := object.(*canvas.Image); ok && img.Image != nil { - images = append(images, img) + if shader, ok := object.(*canvas.Shader); ok && + strings.HasPrefix(shader.Name, "picfetch-compare-tiled-") && shader.Visible() { + shaders = append(shaders, shader) } switch object := object.(type) { case *fyne.Container: @@ -66,7 +162,7 @@ func comparisonImages(root fyne.CanvasObject) []*canvas.Image { } } walk(root) - return images + return shaders } func comparisonButton(t *testing.T, root fyne.CanvasObject, text string) *widget.Button { @@ -90,6 +186,36 @@ func comparisonButton(t *testing.T, root fyne.CanvasObject, text string) *widget return found } +func comparisonHasVisibleLabel(root fyne.CanvasObject, text string) bool { + found := false + var walk func(fyne.CanvasObject) + walk = func(object fyne.CanvasObject) { + if label, ok := object.(*widget.Label); ok && label.Visible() && label.Text == text { + found = true + } + switch object := object.(type) { + case *fyne.Container: + for _, child := range object.Objects { + walk(child) + } + case *container.Clip: + walk(object.Content) + } + } + walk(root) + return found +} + +type comparisonKeyHooks struct { + down func(*fyne.KeyEvent) + up func(*fyne.KeyEvent) +} + +func (h *comparisonKeyHooks) OnKeyDown() func(*fyne.KeyEvent) { return h.down } +func (h *comparisonKeyHooks) SetOnKeyDown(fn func(*fyne.KeyEvent)) { h.down = fn } +func (h *comparisonKeyHooks) OnKeyUp() func(*fyne.KeyEvent) { return h.up } +func (h *comparisonKeyHooks) SetOnKeyUp(fn func(*fyne.KeyEvent)) { h.up = fn } + func comparisonBackButton(t *testing.T, root fyne.CanvasObject) *widget.Button { t.Helper() return comparisonButton(t, root, lang.L("Back to Grid")) @@ -584,7 +710,7 @@ func TestCompareZoom_KeyboardRoutesToComparisonWithoutChangingCoveredState(t *te fireCompareShortcut(v) waitForCompare(t, v) - images := comparisonImages(v.compare.Overlay()) + images := comparisonShaders(v.compare.Overlay()) if len(images) != 2 { t.Fatalf("comparison images = %d, want 2", len(images)) } @@ -593,7 +719,7 @@ func TestCompareZoom_KeyboardRoutesToComparisonWithoutChangingCoveredState(t *te v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyPlus}) - images = comparisonImages(v.compare.Overlay()) + images = comparisonShaders(v.compare.Overlay()) for i, img := range images { if img.Size().Width <= beforeSizes[i].Width || img.Size().Height <= beforeSizes[i].Height { t.Errorf("comparison image %d after + = %v, want larger than %v", i, img.Size(), beforeSizes[i]) @@ -604,6 +730,223 @@ func TestCompareZoom_KeyboardRoutesToComparisonWithoutChangingCoveredState(t *te } } +func TestCompareLinkControl_CtrlLAndButtonShareReadyGate(t *testing.T) { + v := openGridWith(t, "left.jpg", "right.jpg") + v.grid.SelectAll() + + started := make(chan struct{}, 2) + release := make(chan struct{}) + releaseLoads := func() { + select { + case <-release: + default: + close(release) + } + } + t.Cleanup(releaseLoads) + v.compareLoad = func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + started <- struct{}{} + <-release + return &imaging.LoadedImage{Frames: []image.Image{image.NewRGBA(image.Rect(0, 0, 20, 10))}}, nil + } + + fireCompareShortcut(v) + for range 2 { + select { + case <-started: + case <-time.After(testTimeout): + t.Fatal("timed out waiting for both comparison loads to start") + } + } + + overlay := v.compare.Overlay() + unlink := comparisonButton(t, overlay, lang.L("Unlink")) + if !unlink.Disabled() { + t.Fatal("Unlink is enabled before both comparison images are ready") + } + + var modifiers fyne.KeyModifier + v.keyModifiers = func() fyne.KeyModifier { return modifiers } + hooks := &comparisonKeyHooks{} + wireComparisonLinkToggleHook(hooks, v) + modifiers = fyne.KeyModifierControl + hooks.down(&fyne.KeyEvent{Name: fyne.KeyL}) + modifiers = 0 + + if got := comparisonButton(t, overlay, lang.L("Unlink")); got != unlink { + t.Error("pre-ready Ctrl+L replaced the disabled Unlink control") + } + if !unlink.Disabled() { + t.Error("pre-ready Ctrl+L enabled the Unlink control") + } + if comparisonHasVisibleLabel(overlay, lang.L("Unlinked")) { + t.Error("pre-ready Ctrl+L showed the Unlinked status") + } + + releaseLoads() + waitForCompare(t, v) + if unlink.Disabled() { + t.Fatal("Unlink stayed disabled after both comparison images became ready") + } + + modifiers = fyne.KeyModifierControl + hooks.down(&fyne.KeyEvent{Name: fyne.KeyL}) + modifiers = 0 + link := comparisonButton(t, overlay, lang.L("Link")) + if link != unlink { + t.Error("Ctrl+L replaced the comparison link control") + } + if !comparisonHasVisibleLabel(overlay, lang.L("Unlinked")) { + t.Error("ready Ctrl+L did not show the Unlinked status") + } + + fynetest.Tap(link) + if got := comparisonButton(t, overlay, lang.L("Unlink")); got != unlink { + t.Error("Link tap replaced the comparison link control") + } + if comparisonHasVisibleLabel(overlay, lang.L("Unlinked")) { + t.Error("Link tap left the Unlinked status visible") + } +} + +func TestCompareLinkToggle_CanvasOverlayOwnsPhysicalCtrlL(t *testing.T) { + v := openActiveComparisonWithExtra(t) + v.keyModifiers = func() fyne.KeyModifier { return fyne.KeyModifierControl } + hooks := &comparisonKeyHooks{} + wireComparisonLinkToggleHook(hooks, v) + + modal := canvas.NewRectangle(color.Transparent) + v.win.Canvas().Overlays().Add(modal) + hooks.down(&fyne.KeyEvent{Name: fyne.KeyL}) + if comparisonHasVisibleLabel(v.compare.Overlay(), lang.L("Unlinked")) { + t.Fatal("physical Ctrl+L unlinked comparison behind a canvas overlay") + } + + v.win.Canvas().Overlays().Remove(modal) + hooks.down(&fyne.KeyEvent{Name: fyne.KeyL}) + if !comparisonHasVisibleLabel(v.compare.Overlay(), lang.L("Unlinked")) { + t.Error("physical Ctrl+L stayed blocked after the canvas overlay was removed") + } +} + +func TestCompareLinkToggle_ZoomsOnlyTheLastHoveredPaneWithoutHeldModifier(t *testing.T) { + v := openGridWith(t, "a.jpg", "b.jpg", "c.jpg") + v.compareLoad = func(_ context.Context, _ fyne.URI) (*imaging.LoadedImage, error) { + return &imaging.LoadedImage{Frames: []image.Image{image.NewRGBA(image.Rect(0, 0, 800, 400))}}, nil + } + var modifiers fyne.KeyModifier + v.keyModifiers = func() fyne.KeyModifier { return modifiers } + v.grid.HandleKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + v.grid.HandleKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + v.grid.HandleKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + fireCompareShortcut(v) + waitForCompare(t, v) + + images := comparisonShaders(v.compare.Overlay()) + if len(images) != 2 { + t.Fatalf("comparison images = %d, want 2", len(images)) + } + before := [2]fyne.Size{images[0].Size(), images[1].Size()} + overlayPosition := v.app.Driver().AbsolutePositionForObject(v.compare.Overlay()) + leftCenter := overlayPosition.Add(fyne.NewPos(v.compare.Overlay().Size().Width/4, v.compare.Overlay().Size().Height/2)) + fynetest.MoveMouse(v.win.Canvas(), leftCenter) + + hooks := &comparisonKeyHooks{} + wireComparisonLinkToggleHook(hooks, v) + modifiers = fyne.KeyModifierControl + hooks.down(&fyne.KeyEvent{Name: fyne.KeyL}) + modifiers = 0 + v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyPlus}) + + images = comparisonShaders(v.compare.Overlay()) + if images[0].Size().Width <= before[0].Width { + t.Errorf("left unlinked comparison image after + = %v, want larger than %v", images[0].Size(), before[0]) + } + if images[1].Size() != before[1] { + t.Errorf("right comparison image after left-targeted + = %v, want unchanged %v", images[1].Size(), before[1]) + } +} + +func TestCompareLinkToggle_ChainsHookPersistsAndRelinksOnSecondPress(t *testing.T) { + v := openActiveComparisonWithExtra(t) + var modifiers fyne.KeyModifier + v.keyModifiers = func() fyne.KeyModifier { return modifiers } + + downCalls, upCalls := 0, 0 + hooks := &comparisonKeyHooks{ + down: func(*fyne.KeyEvent) { downCalls++ }, + up: func(*fyne.KeyEvent) { upCalls++ }, + } + wireComparisonLinkToggleHook(hooks, v) + + modifiers = fyne.KeyModifierControl + hooks.down(&fyne.KeyEvent{Name: fyne.KeyL}) + if !comparisonHasVisibleLabel(v.compare.Overlay(), lang.L("Unlinked")) { + t.Fatal("first Ctrl+L press did not show the comparison unlink status") + } + for range 3 { + v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyL}) + } + if !comparisonHasVisibleLabel(v.compare.Overlay(), lang.L("Unlinked")) { + t.Error("repeated typed L events retriggered the physical Ctrl+L toggle") + } + + modifiers = 0 + hooks.up(&fyne.KeyEvent{Name: desktop.KeyControlLeft}) + if !comparisonHasVisibleLabel(v.compare.Overlay(), lang.L("Unlinked")) { + t.Error("releasing Control relinked panes toggled apart by Ctrl+L") + } + + modifiers = fyne.KeyModifierControl + hooks.down(&fyne.KeyEvent{Name: fyne.KeyL}) + if comparisonHasVisibleLabel(v.compare.Overlay(), lang.L("Unlinked")) { + t.Error("second Ctrl+L press did not relink the comparison panes") + } + if downCalls != 2 || upCalls != 1 { + t.Errorf("existing key-hook calls = down %d up %d, want 2 and 1", downCalls, upCalls) + } +} + +func TestCompareLinkToggle_ControlAloneDoesNotChangeMode(t *testing.T) { + v := openActiveComparisonWithExtra(t) + var modifiers fyne.KeyModifier + v.keyModifiers = func() fyne.KeyModifier { return modifiers } + + hooks := &comparisonKeyHooks{} + wireComparisonLinkToggleHook(hooks, v) + + modifiers = fyne.KeyModifierControl + hooks.down(&fyne.KeyEvent{Name: desktop.KeyControlLeft}) + if comparisonHasVisibleLabel(v.compare.Overlay(), lang.L("Unlinked")) { + t.Error("pressing Control by itself unlinked the comparison panes") + } +} + +func TestCompareLinkToggle_RequiresExactPhysicalControlL(t *testing.T) { + tests := []struct { + name string + modifiers fyne.KeyModifier + }{ + {name: "no modifier"}, + {name: "Shift", modifiers: fyne.KeyModifierShift}, + {name: "Command", modifiers: fyne.KeyModifierSuper}, + {name: "Control Shift", modifiers: fyne.KeyModifierControl | fyne.KeyModifierShift}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + v := openActiveComparisonWithExtra(t) + v.keyModifiers = func() fyne.KeyModifier { return tc.modifiers } + hooks := &comparisonKeyHooks{} + wireComparisonLinkToggleHook(hooks, v) + + hooks.down(&fyne.KeyEvent{Name: fyne.KeyL}) + if comparisonHasVisibleLabel(v.compare.Overlay(), lang.L("Unlinked")) { + t.Errorf("L with modifiers %v toggled comparison linking", tc.modifiers) + } + }) + } +} + func TestComparePanInputs_CanvasDragAndShiftWheelStayInComparison(t *testing.T) { v := openGridWith(t, "a.jpg", "b.jpg", "c.jpg") v.compareLoad = func(_ context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { @@ -621,13 +964,13 @@ func TestComparePanInputs_CanvasDragAndShiftWheelStayInComparison(t *testing.T) v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyPlus}) } - images := comparisonImages(v.compare.Overlay()) + images := comparisonShaders(v.compare.Overlay()) beforeState := snapshotCompareCommands(v) leftBefore := images[0].Position() canvasSize := v.win.Canvas().Size() leftCenter := fyne.NewPos(canvasSize.Width/4, canvasSize.Height/2) fynetest.Drag(v.win.Canvas(), leftCenter, 40, 20) - images = comparisonImages(v.compare.Overlay()) + images = comparisonShaders(v.compare.Overlay()) if images[0].Position() == leftBefore { t.Fatal("canvas drag did not pan the active comparison view") } @@ -637,7 +980,7 @@ func TestComparePanInputs_CanvasDragAndShiftWheelStayInComparison(t *testing.T) rightCenter := fyne.NewPos(canvasSize.Width*3/4, canvasSize.Height/2) fynetest.Scroll(v.win.Canvas(), rightCenter, -15, 25) v.keyModifiers = func() fyne.KeyModifier { return 0 } - images = comparisonImages(v.compare.Overlay()) + images = comparisonShaders(v.compare.Overlay()) if images[1].Position() == rightBefore { t.Fatal("canvas Shift+wheel did not pan the active comparison view") } @@ -661,7 +1004,7 @@ func TestCompareSwipePointer_CanvasRoutesDividerAndPaneDrag(t *testing.T) { v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyPlus}) } - images := comparisonImages(v.compare.Overlay()) + images := comparisonShaders(v.compare.Overlay()) if len(images) != 2 { t.Fatalf("comparison images = %d, want 2", len(images)) } @@ -677,7 +1020,7 @@ func TestCompareSwipePointer_CanvasRoutesDividerAndPaneDrag(t *testing.T) { if !uitest.ApproxEqual(afterDivider, beforeDivider+80) { t.Errorf("canvas divider drag moved center to %.2f, want %.2f", afterDivider, beforeDivider+80) } - images = comparisonImages(v.compare.Overlay()) + images = comparisonShaders(v.compare.Overlay()) for i, img := range images { if img.Position() != beforePositions[i] { t.Errorf("comparison image %d moved during canvas divider drag: %v, want %v", i, img.Position(), beforePositions[i]) @@ -688,7 +1031,7 @@ func TestCompareSwipePointer_CanvasRoutesDividerAndPaneDrag(t *testing.T) { overlayPosition := v.app.Driver().AbsolutePositionForObject(v.compare.Overlay()) panePoint := overlayPosition.Add(fyne.NewPos(v.compare.Overlay().Size().Width/4, v.compare.Overlay().Size().Height/2)) fynetest.Drag(v.win.Canvas(), panePoint, 40, 20) - images = comparisonImages(v.compare.Overlay()) + images = comparisonShaders(v.compare.Overlay()) if images[0].Position() == afterDividerPositions[0] { t.Fatal("canvas drag away from the divider did not pan the comparison images") } @@ -722,6 +1065,33 @@ func TestCompareDividerKeys_ViewerRoutesShiftWithoutChangingCoveredState(t *test } } +func TestCompareDividerKeys_RemainActiveWhilePanesAreUnlinked(t *testing.T) { + v := openActiveComparisonWithExtra(t) + fynetest.Tap(comparisonButton(t, v.compare.Overlay(), lang.L("Swipe"))) + divider, _ := comparisonDivider(t, v.compare.Overlay()) + width := v.compare.Overlay().Size().Width + var modifiers fyne.KeyModifier + v.keyModifiers = func() fyne.KeyModifier { return modifiers } + hooks := &comparisonKeyHooks{} + wireComparisonLinkToggleHook(hooks, v) + + modifiers = fyne.KeyModifierControl + hooks.down(&fyne.KeyEvent{Name: fyne.KeyL}) + modifiers = 0 + v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyRight}) + if center := divider.Position().X + divider.Size().Width/2; !uitest.ApproxEqual(center, width*0.55) { + t.Fatalf("unlinked Right divider center = %.2f, want %.2f", center, width*0.55) + } + modifiers = fyne.KeyModifierShift + v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyLeft}) + if center := divider.Position().X + divider.Size().Width/2; !uitest.ApproxEqual(center, width*0.54) { + t.Errorf("unlinked Shift+Left divider center = %.2f, want fine step to %.2f", center, width*0.54) + } + if !comparisonHasVisibleLabel(v.compare.Overlay(), lang.L("Unlinked")) { + t.Error("divider keys relinked panes toggled apart by Ctrl+L") + } +} + func TestCompareGridLeak_PointerGesturesCannotScrollCoveredGrid(t *testing.T) { names := make([]string, 40) for i := range names { @@ -846,11 +1216,11 @@ func TestCompareSelection_HiddenHostIndicesDetermineLeftAndRight(t *testing.T) { fireCompareShortcut(v) waitForCompare(t, v) - images := comparisonImages(v.compare.Overlay()) + images := comparisonShaders(v.compare.Overlay()) if len(images) != 2 { t.Fatalf("comparison images = %d, want 2", len(images)) } - if left, right := images[0].Image.Bounds().Dx(), images[1].Image.Bounds().Dx(); left != 111 || right != 222 { + if left, right := images[0].Textures["overview"].Bounds().Dx(), images[1].Textures["overview"].Bounds().Dx(); left != 111 || right != 222 { t.Errorf("left/right source widths = %d/%d, want 111/222 in ascending host order", left, right) } if got, want := v.grid.Selection(), []int{1, 3}; !slices.Equal(got, want) { @@ -891,8 +1261,8 @@ func TestCompareSelection_DuplicateFilterKeepsHiddenSelectedHostFile(t *testing. } fireCompareShortcut(v) waitForCompare(t, v) - images := comparisonImages(v.compare.Overlay()) - if len(images) != 2 || images[0].Image.Bounds().Dx() != 101 || images[1].Image.Bounds().Dx() != 202 { + images := comparisonShaders(v.compare.Overlay()) + if len(images) != 2 || images[0].Textures["overview"].Bounds().Dx() != 101 || images[1].Textures["overview"].Bounds().Dx() != 202 { t.Errorf("comparison did not retain hidden host selection in file order") } v.compare.Close() diff --git a/internal/ui/favorites/favorites_test.go b/internal/ui/favorites/favorites_test.go index ed0a54f..d40c857 100644 --- a/internal/ui/favorites/favorites_test.go +++ b/internal/ui/favorites/favorites_test.go @@ -434,6 +434,9 @@ func TestCompareMenuState_DisablesStaticAndRefreshedFavoriteCommands(t *testing. if !item.IsSeparator && !item.Disabled { t.Errorf("%q stayed enabled during comparison", item.Label) } + if item != f.addItem && item != f.manageItem && !item.IsSeparator && item.Shortcut == nil { + t.Errorf("disabled favorite %q lost its display shortcut", item.Label) + } } // Rebuilding the dynamic entries while isolation is active must not @@ -446,6 +449,9 @@ func TestCompareMenuState_DisablesStaticAndRefreshedFavoriteCommands(t *testing. if !item.IsSeparator && !item.Disabled { t.Errorf("refreshed %q became enabled during comparison", item.Label) } + if item != f.addItem && item != f.manageItem && !item.IsSeparator && item.Shortcut == nil { + t.Errorf("refreshed disabled favorite %q lost its display shortcut", item.Label) + } } f.SetCommandsEnabled(true) @@ -454,6 +460,13 @@ func TestCompareMenuState_DisablesStaticAndRefreshedFavoriteCommands(t *testing. t.Errorf("%q stayed disabled after comparison", item.Label) } } + for i := range f.names { + item := f.menu.Items[2+i] + want := ShortcutForIndex(i) + if want != nil && (item.Shortcut == nil || item.Shortcut.ShortcutName() != want.ShortcutName()) { + t.Errorf("favorite %q shortcut after comparison = %v, want restored %q", item.Label, item.Shortcut, want.ShortcutName()) + } + } } // TestSetHasFilesDoesNotRefreshMenus pins the deliberate omission: SetHasFiles diff --git a/internal/ui/help/manual.md b/internal/ui/help/manual.md index 7daaf90..ecf952d 100644 --- a/internal/ui/help/manual.md +++ b/internal/ui/help/manual.md @@ -431,14 +431,17 @@ instead of arrowing through them one at a time. Selected thumbnails are washed in the accent colour, and the top bar counts them (`12 selected`). A click without dragging still just opens an image. - With exactly two files selected, press **`Cmd/Ctrl+D`** or choose - **Actions -> Compare selected images**. An opaque comparison opens in the + **Actions -> Compare selected images**. On macOS, `Cmd+D` is the native + shortcut; physical **`Ctrl+D`** also works on macOS. An opaque comparison opens in the same window with both images fitted into fixed 50/50 panes; the file earlier in the current grid order is on the left. Each side shows its own spinner while loading. Translucent bottom-corner badges identify the files by base name; if those names match, both expand to the shortest distinguishing folder/file suffix. The window title follows the same order, for example - `Compare: left.jpg | right.jpg - PicFetch`. A translucent toolbar stays at - the top right: **Back to Grid** remains available while loading, and + `Compare: left.jpg | right.jpg - PicFetch`. A separate translucent card holds + the top-left **Unlink** button; it and physical **`Ctrl+L`** remain inactive + until both images are ready. A translucent action toolbar stays at the top + right: **Back to Grid** remains available while loading, and **Swap** becomes available once both images are ready. Swap exchanges the images, badges, and title without loading either file again. **Swipe** switches both images to the full comparison viewport and adds a vertical @@ -446,20 +449,44 @@ instead of arrowing through them one at a time. divider to change the reveal; dragging elsewhere continues to pan both images. While swipe is active, **`Left`** / **`Right`** move the divider by 5 percentage points, **`Shift+Left`** / **`Shift+Right`** by 1 point, and - **`Home`** / **`End`** move it to 0%/100%. **Side by side** returns to fixed - 50/50 panes. Switching layouts keeps the linked view and divider position; - a new comparison starts side by side at 50%. Zoom and pan stay linked - between the comparison panes. Scroll over either pane or use - **`+`** / **`-`** to zoom both around the shared view. Dragging either - comparison pane or using Shift+scroll pans both; the shared point is clamped - so neither image can be pulled away from its pane. **`0`** fits and centers - both; **`1`** shows both at 100% (one decoded image pixel per canvas point). + **`Home`** / **`End`** move it to 0%/100%. + **Side by side** returns to fixed 50/50 panes. Switching layouts preserves + each photo's position and size, the camera, and the divider position; a new + comparison starts side by side at 50%. During normal linked use, zoom and pan operate + one overhead camera above the two photos. Scroll over either pane or use + **`+`** / **`-`** to zoom that camera; dragging either comparison pane or + using Shift+scroll moves both views by the same screen distance. Camera + movement stops before either photo can pass completely beyond the centre of + its pane. **`0`** frames both photos in their current arrangement with one + camera move while retaining their relative sizes and offsets. **`1`** returns the camera to + its 1x home view relative to the stored arrangement; after the photos have + been resized separately, it does not make both of them decoded-pixel 100%. + Use the top-left **Unlink** button or press physical **`Ctrl+L`** (`Ctrl`, + including on macOS; not `Cmd`) to toggle the panes between linked and + unlinked views. The first click or press unlinks them until either control is + used again; releasing Control has no effect. Once unlinked, the button + changes to **Link**, and the status **Unlinked** appears immediately beside + it, followed by **Left** or **Right** once a pane has been targeted. Drag, + scroll, or Shift+scroll then changes only the pane under the pointer; + unmodified **`0`**, **`1`**, **`+`**, and **`-`** change the + hovered or last-hovered photo and do nothing until a pane has been targeted. + Here **`0`** fits and centres only that photo in the current camera view, and + **`1`** shows only that photo at decoded-pixel 100%. A photo may be moved + until one of its edges reaches the pane centre. Changing the link state never + moves or resizes either photo: pressing **`Ctrl+L`** again locks the current + arrangement, and later linked controls move only the camera. Window resizing + and layout changes preserve the photo arrangement and camera. Swap locks and + clears any divergence from the last-targeted side before exchanging the images. A + new comparison always starts linked. Raster sources stay at full decoded resolution and use their canonical EXIF-corrected orientation; a temporary rotation in the single-image viewer is not carried into comparison. SVGs re-render at their effective screen-pixel size whenever zoom, layout, or window size changes. RAW files use the same embedded JPEG preview as the normal viewer. Animated inputs stay frozen on their first decoded frame for the entire comparison session. + A bounded overview remains visible while sharper detail tiles arrive in the background. + Pan and zoom update that stable GPU surface directly, so interaction does not + wait for the sharper tiles. Full fidelity can require the combined decoded memory of both sources even when the image cache retains only one. The existing encoded-input and vector-raster limits still apply. If either source cannot complete, PicFetch diff --git a/internal/ui/help/manual_de.md b/internal/ui/help/manual_de.md index 9d0b5c3..86e5d2c 100644 --- a/internal/ui/help/manual_de.md +++ b/internal/ui/help/manual_de.md @@ -480,7 +480,9 @@ einzeln durchzublättern. eingefärbt, und die obere Leiste zählt sie (`12 ausgewählt`). Ein Klick ohne Ziehen öffnet weiterhin nur ein Bild. - Sind genau zwei Dateien ausgewählt, drücken Sie **`Cmd/Strg+D`** oder wählen - **Aktionen -> Ausgewählte Bilder vergleichen**. Im selben Fenster erscheint + **Aktionen -> Ausgewählte Bilder vergleichen**. Unter macOS ist `Cmd+D` die + native Tastenkombination; die physische **`Ctrl+D`** funktioniert unter macOS ebenfalls. + Im selben Fenster erscheint eine undurchsichtige Vergleichsansicht, in der beide Bilder in feste 50/50-Bereiche eingepasst sind; die in der aktuellen Rasterreihenfolge frühere Datei steht links. Während des Ladens zeigt jede Seite einen eigenen @@ -488,8 +490,11 @@ einzeln durchzublättern. die Dateien mit ihrem Basisnamen; sind diese gleich, werden beide zum kürzesten unterscheidbaren Ordner/Datei-Suffix erweitert. Der Fenstertitel folgt derselben Reihenfolge, zum Beispiel - `Vergleich: links.jpg | rechts.jpg - PicFetch`. Oben rechts bleibt eine - durchscheinende Werkzeugleiste sichtbar: **Zurück zur Rasteransicht** ist + `Vergleich: links.jpg | rechts.jpg - PicFetch`. Eine separate + durchscheinende Karte enthält die Schaltfläche **Entkoppeln** oben links; + sie und die physische Tastenkombination **`Ctrl+L`** bleiben inaktiv, bis + beide Bilder bereit sind. Oben rechts bleibt eine durchscheinende + Aktionsleiste sichtbar: **Zurück zur Rasteransicht** ist auch beim Laden verfügbar, **Tauschen** wird aktiv, sobald beide Bilder bereit sind. Tauschen vertauscht Bilder, Abzeichen und Titel, ohne eine Datei erneut zu laden. **Wischen** legt beide Bilder über den vollständigen @@ -500,15 +505,39 @@ einzeln durchzublättern. die Trennlinie um 5 Prozentpunkte, **`Shift+Left`** / **`Shift+Right`** um 1 Prozentpunkt und **`Home`** / **`End`** setzen sie auf 0 %/100 %. **Nebeneinander** kehrt zu festen 50/50-Bereichen zurück. Beim Wechsel der - Anordnung bleiben die gekoppelte Ansicht und die Trennlinienposition erhalten; - ein neuer Vergleich beginnt nebeneinander bei 50 %. Zoom und Verschieben - bleiben gekoppelt zwischen den beiden Vergleichsbereichen. Scrollen Sie über - einem Bereich oder verwenden Sie - **`+`** / **`-`**, um beide um die gemeinsame Ansicht zu zoomen. Ziehen in - einem Vergleichsbereich oder Shift+Scrollen verschiebt beide; der gemeinsame - Punkt wird so begrenzt, dass kein Bild aus seinem Bereich gezogen werden - kann. **`0`** passt beide Bilder ein und zentriert sie; **`1`** zeigt beide - mit 100 % (ein dekodiertes Bildpixel pro Canvas-Punkt). + Anordnung bleiben Position und Größe beider Fotos, die Kamera und die + Trennlinienposition erhalten; ein neuer Vergleich beginnt nebeneinander bei + 50 %. Im normal gekoppelten Zustand steuern Zoom und Verschieben eine + gemeinsame Kamera über den beiden Fotos. Scrollen Sie über einem Bereich + oder verwenden Sie **`+`** / **`-`**, um diese Kamera zu zoomen. Ziehen in + einem Vergleichsbereich oder Shift+Scrollen bewegt beide Ansichten um + dieselbe Bildschirmstrecke. Die Kamerabewegung stoppt, bevor ein Foto die + Mitte seines Bereichs vollständig passiert. **`0`** rahmt beide Fotos in ihrer + aktuellen Anordnung mit einer Kamerabewegung ein und behält ihre relativen + Größen und Versätze bei. **`1`** setzt die Kamera auf ihre 1x-Ausgangsansicht relativ + zur gespeicherten Anordnung zurück; nach getrenntem Skalieren zeigt + es nicht beide Fotos mit 100 % der dekodierten Pixelgröße. Verwenden Sie die + Schaltfläche **Entkoppeln** oben links oder drücken Sie die physische + Tastenkombination **`Ctrl+L`** (`Ctrl`/`Strg`, auch unter macOS; nicht `Cmd`), + um zwischen gekoppelten und entkoppelten Ansichten umzuschalten. Der erste + Klick oder Tastendruck entkoppelt die Bereiche, bis eines der beiden + Bedienelemente erneut verwendet wird; das Loslassen von Control hat keine + Wirkung. Nach dem Entkoppeln wechselt die Schaltfläche zu **Koppeln**, und der + Status **Entkoppelt** erscheint direkt daneben; nach der Auswahl eines + Bereichs folgt zusätzlich **Links** oder **Rechts**. Ziehen, Scrollen oder Shift+Scrollen + ändert dann nur den Bereich unter dem Mauszeiger; die unveränderten Tasten + **`0`**, **`1`**, **`+`** und **`-`** ändern das Foto unter dem Mauszeiger + beziehungsweise das zuletzt berührte Foto und bewirken nichts, solange noch + kein Bereich ausgewählt wurde. Dabei passt **`0`** nur dieses Foto in die + aktuelle Kameraansicht ein und zentriert es; **`1`** zeigt nur dieses Foto mit + 100 % der dekodierten Pixelgröße. Ein Foto lässt sich verschieben, bis einer + seiner Ränder die Mitte des Bereichs erreicht. Das Umschalten der Kopplung + bewegt oder skaliert keines der Fotos: Beim erneuten Drücken von **`Ctrl+L`** + wird die aktuelle Anordnung gekoppelt, danach verändern gekoppelte Befehle nur + noch die Kamera. Fenstergrößen- und Anordnungsänderungen bewahren die + Fotoanordnung und Kamera. Tauschen koppelt und verwirft zuerst alle + Unterschiede anhand des zuletzt berührten Bereichs und vertauscht dann die + Bilder. Ein neuer Vergleich beginnt immer gekoppelt. Rasterquellen bleiben in der vollen dekodierten Auflösung und verwenden ihre kanonische EXIF-korrigierte Ausrichtung; eine vorübergehende Drehung in der Einzelbildansicht wird nicht in den Vergleich übernommen. SVGs werden für @@ -516,7 +545,10 @@ einzeln durchzublättern. Anordnung oder Fenstergröße ändern. RAW-Dateien verwenden dieselbe eingebettete JPEG-Vorschau wie die normale Einzelbildansicht. Animierte Eingaben bleiben auf ihrem ersten dekodierten Einzelbild eingefroren, solange - der Vergleich geöffnet ist. Volle Wiedergabetreue kann den kombinierten + der Vergleich geöffnet ist. Eine begrenzte Übersicht bleibt sichtbar, während schärfere + Detailkacheln im Hintergrund eintreffen. Verschieben und Zoomen aktualisieren diese stabile + GPU-Fläche direkt, sodass die Interaktion nicht auf die schärferen Kacheln wartet. + Volle Wiedergabetreue kann den kombinierten dekodierten Speicher beider Quellen beanspruchen, auch wenn der Bild-Cache nur eine davon behält. Die vorhandenen Grenzen für kodierte Eingaben und Vektor-Raster gelten weiterhin. Kann eine Quelle nicht vollständig geladen diff --git a/internal/ui/help/manual_test.go b/internal/ui/help/manual_test.go index 1a57c4f..5b8fa1a 100644 --- a/internal/ui/help/manual_test.go +++ b/internal/ui/help/manual_test.go @@ -130,9 +130,15 @@ func TestManualDocumentsComparisonEntryAndExit(t *testing.T) { if !strings.Contains(manualMD, "Cmd/Ctrl+D") || !strings.Contains(manualMD, "Back to Grid") { t.Error("manual.md does not document the comparison shortcut and exit") } + if !strings.Contains(manualMD, "physical **`Ctrl+D`** also works on macOS") { + t.Error("manual.md does not document the physical Ctrl+D comparison shortcut on macOS") + } if !strings.Contains(manualDE, "Cmd/Strg+D") || !strings.Contains(manualDE, "Zurück zur Rasteransicht") { t.Error("manual_de.md does not document the comparison shortcut and exit") } + if !strings.Contains(manualDE, "physische **`Ctrl+D`** funktioniert unter macOS ebenfalls") { + t.Error("manual_de.md does not document the physical Ctrl+D comparison shortcut on macOS") + } } func TestManualDocumentsComparisonIdentityAndSwap(t *testing.T) { @@ -177,27 +183,39 @@ func TestManualDocumentsComparisonCommandIsolation(t *testing.T) { } } -func TestManualDocumentsComparisonLinkedZoomAndPan(t *testing.T) { +func TestManualDocumentsComparisonCameraAndPhotoControls(t *testing.T) { english := strings.Join(strings.Fields(manualMD), " ") for _, phrase := range []string{ - "Zoom and pan stay linked", - "**`0`** fits and centers both", - "**`1`** shows both at 100%", - "Dragging either comparison pane or using Shift+scroll pans both", + "zoom and pan operate one overhead camera above the two photos", + "**`0`** frames both photos in their current arrangement with one camera move", + "**`1`** returns the camera to its 1x home view", + "using Shift+scroll moves both views by the same screen distance", + "Here **`0`** fits and centres only that photo", + "Changing the link state never moves or resizes either photo", + "top-left **Unlink** button", + "remain inactive until both images are ready", + "button changes to **Link**", + "status **Unlinked** appears immediately beside it", } { if !strings.Contains(english, phrase) { - t.Errorf("manual.md does not document linked comparison phrase %q", phrase) + t.Errorf("manual.md does not document comparison camera/photo phrase %q", phrase) } } german := strings.Join(strings.Fields(manualDE), " ") for _, phrase := range []string{ - "Zoom und Verschieben bleiben gekoppelt", - "**`0`** passt beide Bilder ein und zentriert sie", - "**`1`** zeigt beide mit 100 %", - "Ziehen in einem Vergleichsbereich oder Shift+Scrollen verschiebt beide", + "Zoom und Verschieben eine gemeinsame Kamera über den beiden Fotos", + "**`0`** rahmt beide Fotos in ihrer aktuellen Anordnung mit einer Kamerabewegung ein", + "**`1`** setzt die Kamera auf ihre 1x-Ausgangsansicht", + "Shift+Scrollen bewegt beide Ansichten um dieselbe Bildschirmstrecke", + "passt **`0`** nur dieses Foto in die aktuelle Kameraansicht ein", + "Das Umschalten der Kopplung bewegt oder skaliert keines der Fotos", + "Schaltfläche **Entkoppeln** oben links", + "bleiben inaktiv, bis beide Bilder bereit sind", + "wechselt die Schaltfläche zu **Koppeln**", + "Status **Entkoppelt** erscheint direkt daneben", } { if !strings.Contains(german, phrase) { - t.Errorf("manual_de.md does not document linked comparison phrase %q", phrase) + t.Errorf("manual_de.md does not document comparison camera/photo phrase %q", phrase) } } } @@ -240,6 +258,8 @@ func TestManualDocumentsComparisonSourceFidelity(t *testing.T) { "SVGs re-render at their effective screen-pixel size", "RAW files use the same embedded JPEG preview", "Animated inputs stay frozen on their first decoded frame", + "overview remains visible while sharper detail tiles arrive in the background", + "Pan and zoom update that stable GPU surface directly", "combined decoded memory", "encoded-input and vector-raster limits", "never downsampled or removed", @@ -256,6 +276,8 @@ func TestManualDocumentsComparisonSourceFidelity(t *testing.T) { "SVGs werden für ihre effektive Bildschirm-Pixelgröße neu gerendert", "RAW-Dateien verwenden dieselbe eingebettete JPEG-Vorschau", "Animierte Eingaben bleiben auf ihrem ersten dekodierten Einzelbild eingefroren", + "Übersicht bleibt sichtbar, während schärfere Detailkacheln im Hintergrund eintreffen", + "Verschieben und Zoomen aktualisieren diese stabile GPU-Fläche direkt", "kombinierten dekodierten Speicher", "Grenzen für kodierte Eingaben und Vektor-Raster", "weder verkleinert noch entfernt", diff --git a/internal/ui/keys.go b/internal/ui/keys.go index f828aa3..59ed81b 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -12,13 +12,13 @@ import ( // defaultKeyModifiers reports the keyboard modifiers currently held, which // a fyne.KeyEvent doesn't carry: desktop.Driver.CurrentKeyModifiers is kept // in sync by the glfw driver on every key event regardless of which widget -// has focus, unlike a window-level SetOnKeyDown/SetOnKeyUp hook, which Fyne -// only calls when nothing focusable currently has focus. Both consumers - -// Shift+R below and internal/ui/zoom's Shift+scroll pan - reach it through -// the viewer's keyModifiers field rather than calling it directly, so tests -// can stub it per-viewer: Fyne's test driver doesn't implement -// desktop.Driver at all, so the type assertion here is always false under -// test. +// has focus, unlike a window-level SetOnKeyDown hook, which Fyne only calls +// when nothing focusable currently has focus. Consumers including Shift+R, +// comparison's physical Ctrl+L toggle, and internal/ui/zoom's Shift+scroll +// pan reach it through the viewer's keyModifiers field rather than calling it +// directly, so tests can stub it per-viewer: Fyne's test driver doesn't +// implement desktop.Driver at all, so the type assertion here is always false +// under test. func defaultKeyModifiers() fyne.KeyModifier { if d, ok := fyne.CurrentApp().Driver().(desktop.Driver); ok { return d.CurrentKeyModifiers() @@ -27,6 +27,28 @@ func defaultKeyModifiers() fyne.KeyModifier { return 0 } +type comparisonKeyDownCanvas interface { + OnKeyDown() func(*fyne.KeyEvent) + SetOnKeyDown(func(*fyne.KeyEvent)) +} + +// wireComparisonLinkToggleHook handles exact physical Ctrl+L presses before +// Fyne turns modified keys into shortcuts. OnKeyDown runs for the press edge +// but not key repeat, so holding L cannot toggle repeatedly. Existing canvas +// hooks are preserved. +func wireComparisonLinkToggleHook(c comparisonKeyDownCanvas, view *viewer) { + previousDown := c.OnKeyDown() + c.SetOnKeyDown(func(ev *fyne.KeyEvent) { + if previousDown != nil { + previousDown(ev) + } + if ev != nil && ev.Name == fyne.KeyL && view.keyModifiers() == fyne.KeyModifierControl && + view.win.Canvas().Overlays().Top() == nil { + view.compare.ToggleLink() + } + }) +} + // handleTypedRune dispatches a single typed character. The grid's filename // search (see internal/ui/grid) is the only thing in the app that reads // characters rather than key names, so outside the grid there is nothing to diff --git a/internal/ui/menu_test.go b/internal/ui/menu_test.go index a7ae651..acc3789 100644 --- a/internal/ui/menu_test.go +++ b/internal/ui/menu_test.go @@ -385,6 +385,27 @@ func TestFavoriteShortcutOpensStoredFilesThroughViewer(t *testing.T) { } } +func TestGlobalFavoriteShortcutOpensStoredFilesThroughViewer(t *testing.T) { + v := newTestViewer(t) + dir := t.TempDir() + image := uitest.TempJPEGURI(t, "global-shortcut-favorite.jpg", 4, 4, color.White) + if err := favstore.Save(dir, "Trip", []fyne.URI{image}); err != nil { + t.Fatalf("favstore.Save: %v", err) + } + v.favorites.SetDir(dir) + + handler := &fyne.ShortcutHandler{} + wireGlobalShortcuts(handler, v) + handler.TypedShortcut(favoriteui.ShortcutForIndex(0)) + waitForScan(t, v) + waitForSort(t, v) + waitUntilLoaded(t, v) + + if len(v.state.files) != 1 || v.state.files[0].Path() != image.Path() { + t.Errorf("files = %v, want favorite image %q", v.state.files, image.Path()) + } +} + // mainMenuRecorder is a fyne.Window that notes, every time something reads // its main menu, which items the Favorites menu held at that moment. Reading // the bar is what refreshMainMenu (windowmenu.go) does and the only trace it diff --git a/internal/ui/run.go b/internal/ui/run.go index 651e096..9229ab0 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -111,6 +111,7 @@ func registerShutdown(application fyne.App, view *viewer) { view.vector.lifecycle.invalidate() view.regionCopyLifecycle.invalidate() view.updateOp.invalidate() + view.compare.Close() // Same reasoning as the invalidations above, for the one piece of // state that outlives the viewer: openwith's queue is diff --git a/internal/ui/shortcuts.go b/internal/ui/shortcuts.go index 741f70b..63f54eb 100644 --- a/internal/ui/shortcuts.go +++ b/internal/ui/shortcuts.go @@ -45,14 +45,23 @@ func wireGlobalShortcuts(c shortcutAdder, view *viewer) { wireExportShortcuts(yielding, view) } -// wireCompareShortcut binds Cmd/Ctrl+D to the comparison command. D is not -// one of Fyne's built-in shortcut types, so the desktop custom shortcut is -// the production path on every desktop platform. +// wireCompareShortcut binds the platform-native Cmd/Ctrl+D and physical +// Ctrl+D to the comparison command. On platforms whose native shortcut is +// already Control, the second registration is omitted. D is not one of +// Fyne's built-in shortcut types, so desktop custom shortcuts are the +// production path on every desktop platform. func wireCompareShortcut(c shortcutAdder, view *viewer) { + open := func(fyne.Shortcut) { view.compareSelected() } c.AddShortcut(&desktop.CustomShortcut{ KeyName: fyne.KeyD, Modifier: fyne.KeyModifierShortcutDefault, - }, func(fyne.Shortcut) { view.compareSelected() }) + }, open) + if fyne.KeyModifierShortcutDefault != fyne.KeyModifierControl { + c.AddShortcut(&desktop.CustomShortcut{ + KeyName: fyne.KeyD, + Modifier: fyne.KeyModifierControl, + }, open) + } } // yieldingShortcuts is the canvas-shortcut yield: every binding registered diff --git a/plans/2026-09-02-address-pr-13-review.md b/plans/2026-09-02-address-pr-13-review.md new file mode 100644 index 0000000..adf0700 --- /dev/null +++ b/plans/2026-09-02-address-pr-13-review.md @@ -0,0 +1,141 @@ +# Address PR 13 review findings + +Route: Standard, promoted from Thin when the review crossed from +`internal/ui/compare` into the assembled viewer. The diff exceeds the usual +eight-file guideline because each of the four independent review findings has +its own regression/configuration coverage; it adds no package, public API, or +architectural seam. + +Deliverable: Close all four Codex review findings on PR 13 and the six Qodana +conversion notices without reintroducing comparison repaints. + +## Spec + +### Problem + +- Swipe divider changes update Fyne clips but do not publish new reveal bounds + to the detail-tile planner. +- Source-normalized detail steps can become unusable in a GLES fragment + shader's `mediump` fallback for very large sources. +- The physical `Ctrl+L` hook can mutate comparison while a Fyne overlay owns + keyboard input. +- The branch added six test files without exact `DuplicatedCode` exclusions; + Qodana also reports six redundant rune conversions in `shader_test.go`. + +### Decisions + +| Question | Decision | +|---|---| +| Divider update seam | Store each pane's last complete scene and republish it with current reveal bounds and physical pane origin. Do not repaint the owner or recompute transforms. | +| GLES precision | Bind detail tiles in physical pane-pixel coordinates. Keep normalized coordinates only for the overview fallback. | +| Modal ownership | Apply the normal dispatcher's `Overlays().Top()` guard to the low-level physical-key hook. | +| Qodana | Synchronize every test path mechanically and use decimal slot names instead of rune conversions. | + +### Acceptance criteria + +1. Divider keys and drags republish reveal-aware scenes while the owner repaint + count and image transform stay unchanged. + Verify: `go test ./internal/ui/compare -run 'TestPaneRendererScene_Divider|TestCompareSwipePointer_DividerDragDoesNotRefreshStaticContent' -count=1` +2. A visible level-zero tile from a 32768-pixel source binds a representable + pane-pixel step, retained tiles update their geometry, and both shader + variants share the same contract. + Verify: `go test ./internal/ui/compare -run 'TestTileShaderSources|TestShaderPaneRenderer_LargeSource|TestShaderPaneRenderer_MapsScene' -count=1` +3. Exact physical `Ctrl+L` is inert while a canvas overlay is present and works + after the overlay is removed. + Verify: `go test ./internal/ui -run 'TestCompareLinkToggle_.*Overlay' -count=1` +4. Every current `_test.go` has one exact duplication exclusion, and the six + reported rune conversions are absent. + Verify: `make check-qodana-test-exclusions` and + `! rg "string\\(rune" internal/ui/compare/shader_test.go` + +### Non-goals and honest limit + +- No renderer replacement, tile-cache redesign, or runtime precision query. +- Fyne's software test driver does not execute the native GLES shader. The + source/uniform contract and large-source arithmetic are covered locally; + a fresh hosted Qodana result requires pushing the local commit. + +## Task graph + +Tasks 1 through 4 are independent and all feed the final gate. Delegation is +disabled for this run, so every task remains T0 inline. + +### Task 1 - Refresh divider scenes + +Owner: T0 inline +Files: modify/test `internal/ui/compare/{compare,transform,swipe,renderer_test}.go` +Depends: none +Contract: `pane.present(paneScene)` records the latest snapshot; +`Feature.applyReveal()` republishes current reveal geometry. +Test: A divider move adds one scene per pane without changing image geometry. +Verify: Acceptance criterion 1. +Budget: <= 0 spawns, <= 1 review round, full suite: no. + +### Task 2 - Make detail coordinates mediump-safe + +Owner: T0 inline +Files: modify/test `internal/ui/compare/{renderer,shader,shader_test,transform,vector}.go` +Depends: none +Contract: `paneScene.panePosition` is physical; detail `Min` and `Step` +uniforms are pane pixels; overview lookup remains normalized. +Test: A 32768-pixel source exposes a one-pixel detail step and shader source +contains no normalized detail lookup or unit clamp. +Verify: Acceptance criterion 2. +Budget: <= 0 spawns, <= 2 review rounds, full suite: no. + +### Task 3 - Isolate the physical link hook + +Owner: T0 inline +Files: modify/test `internal/ui/{keys.go,compare_test.go}` +Depends: none +Contract: `wireComparisonLinkToggleHook` calls `ToggleLink` only with no top +canvas overlay. +Test: An overlay blocks physical `Ctrl+L`; removing it restores the shortcut. +Verify: Acceptance criterion 3. +Budget: <= 0 spawns, <= 1 review round, full suite: no. + +### Task 4 - Close Qodana findings and bookkeeping + +Owner: T0 inline +Files: modify `qodana.yaml`, `Makefile`, `todos.md`, and this plan; modify/test +`internal/ui/compare/shader_test.go`. +Depends: none +Contract: `make check-qodana-test-exclusions` rejects missing/stale exact test +paths; shader slot names use `strconv.Itoa`. +Test: The exclusion check passes and the focused shader contract test passes. +Verify: Acceptance criterion 4. +Budget: <= 0 spawns, <= 1 review round, full suite: no. + +### Final gate + +Owner: T0 inline +Files: all changed files +Depends: tasks 1, 2, 3, and 4 +Contract: repository verification remains green. +Test: All repository tests under the race detector. +Verify: `make verify` +Budget: <= 0 spawns, <= 1 review round, full suite: yes. + +## Outcome and evidence + +- Each behavioral regression was observed red for the review's stated reason + before its production fix. A later source-contract guard also caught and + removed the stale normalized `1.0` tile-maximum clamp. +- All four acceptance commands pass. +- `go test ./internal/ui/compare -count=1` and + `go test ./internal/ui -run 'Compare|Comparison' -count=1` pass. +- `make verify` passes, including formatting, TUF validation, Qodana exclusion + consistency, vet, build, and the Linux/amd64 race suite. +- The implementation was committed concurrently as local `8c83586`; the agent + did not create that commit. Concurrent release-signing work is outside this + plan and remains untouched. + +## Cost ledger + +| Task | Spawns (budget/actual) | Review rounds | Full suite | Notes | +|---|---:|---:|---:|---| +| T1 | 0 / 0 | 1 | no | Inline hot context | +| T2 | 0 / 0 | 2 | no | Second pass caught stale unit clamp | +| T3 | 0 / 0 | 1 | no | Inline hot context | +| T4 | 0 / 0 | 1 | no | Config edits arrived concurrently and were validated | +| gate | 0 / 0 | 1 | yes | Passed once | diff --git a/plans/2026-09-02-windows-release-signing.md b/plans/2026-09-02-windows-release-signing.md new file mode 100644 index 0000000..6c12d37 --- /dev/null +++ b/plans/2026-09-02-windows-release-signing.md @@ -0,0 +1,43 @@ +# Windows release signing + +## Problem + +The tag-release workflow currently publishes Windows ZIP files built by +build-cross without a code signature. A Certum SimplySign cloud certificate is +available and must sign every published Windows executable without storing its +private key in the repository. + +## Decisions + +| Decision | Choice | +|---|---| +| Signing point | A dedicated Windows job after build-cross, before release. | +| Credential boundary | Protected GitHub environment release-signing. | +| Build artifact boundary | Separate unsigned Windows, signed Windows, and Linux artifacts. | +| Signing policy | SHA-256 file digest, Certum RFC-3161 timestamp, then SignTool verification. | +| Publication policy | Release downloads signed Windows artifacts only. | +| SimplySign card | Must be pinless because CI cannot answer the interactive card-PIN prompt. | +| Installer trust | Explicit Certum 9.4.4.92 URL plus Authenticode verification before installation. | + +## Acceptance criteria + +1. Tag releases cannot publish Windows archives until the protected signing job + succeeds. +2. Both Windows architectures are signed and verified before being repackaged. +3. Certum credentials are referenced only as GitHub Environment secrets. +4. Linux and macOS artifacts retain their current release behavior. +5. Repository documentation describes the required secrets and approval setup. + +## Non-goals + +- Configuring the real GitHub Environment or entering the user’s secrets. +- Signing development builds, pull requests, macOS applications, or Linux + binaries. +- Replacing Certum SimplySign with another certificate provider. + +## Verification + +- Parse the release workflow as YAML. +- Confirm the final release job downloads picfetch-windows-signed and never + picfetch-windows-unsigned. +- Confirm both ZIP names are enforced in the signing job before publication. diff --git a/qodana.yaml b/qodana.yaml index ed02c25..10972c3 100644 --- a/qodana.yaml +++ b/qodana.yaml @@ -48,7 +48,8 @@ include: # internal/imaging/orientation.go carry their own source-local # //goland:noinspection DuplicatedCode suppressions. Two glob dialects # ("**/*_test.go", then "**_test.go") were tried first and did not take, so -# this lists every *_test.go file explicitly. See +# this lists every *_test.go file explicitly. Keep it synchronized with +# `make sync-qodana-test-exclusions`; `make verify` checks it. See # finished_refactorings/2026-08-29-qodana-duplication-close.md. exclude: - name: DuplicatedCode @@ -100,8 +101,14 @@ exclude: - "internal/ui/autoupdate_test.go" - "internal/ui/batch_test.go" - "internal/ui/clipboard_test.go" + - "internal/ui/compare/async_tile_test.go" - "internal/ui/compare/compare_test.go" + - "internal/ui/compare/renderer_test.go" + - "internal/ui/compare/shader_test.go" + - "internal/ui/compare/source_test.go" + - "internal/ui/compare/tile_test.go" - "internal/ui/compare/vector_test.go" + - "internal/ui/compare/workgroup_test.go" - "internal/ui/compare_fidelity_test.go" - "internal/ui/compare_test.go" - "internal/ui/copyselection/copyselection_test.go" diff --git a/todos.md b/todos.md index 157350e..99af101 100644 --- a/todos.md +++ b/todos.md @@ -6,21 +6,49 @@ #### New Features +![Trane comparing images](https://raw.githubusercontent.com/frathe/picfetch/main/assets/trane/trane_lightwall.png) + +- Comparison pane linking can be toggled with the ready-gated **Unlink** / **Link** + control at the top left or physical `Ctrl+L` on every platform. Its adjacent + status identifies the targeted side while unlinked. Pan, wheel/Shift+wheel, + and `0` / `1` / `+` / `-` then affect only that photo; once linked again, the + same controls move one overhead camera while both photos retain their current + arrangement. + #### Bugfix +- Linking or unlinking comparison panes no longer moves or resizes either + photo. +- In Swipe comparison, pointer hover, pan, wheel, and transform-key targeting + now follow the revealed photo while panes are unlinked instead of always + selecting the right photo. +- Moving the Swipe divider now refreshes detail tiles for the newly revealed + area without repainting or re-decoding the comparison surface. +- Large images keep sharp comparison detail on GLES because tile lookup uses + visible pane pixels instead of source-normalized values that mediump can + round to zero. +- Physical `Ctrl+L` now respects Fyne dialogs and popup menus instead of + changing comparison link state behind their canvas overlay. +- Pan and zoom in side-by-side and Swipe comparison now render through bounded + GPU tiles instead of resampling the full images on every gesture. +- Physical `Ctrl+D` opens comparison alongside the platform-native shortcut. + #### Internal -## ACTIVE DEVELOPMENT +- Qodana's duplication exclusions now cover every `*_test.go` file. Run + `make sync-qodana-test-exclusions` after adding or removing tests; + `make verify` checks that the list stays synchronized. +- Updated the indirect gRPC-Go dependency to `v1.83.2`, resolving + `CVE-2026-84304` / `GHSA-vp52-pcj8-j9qc`. Was never used (better save than sorry) +- Raised the minimum Go version to `1.27.1`, pinned `govulncheck` at `v1.7.0`, + and updated Rekor to `v1.5.4`, which replaces its unmaintained + `x/crypto/openpgp` implementation with the maintained Proton fork. The scan + finds no reachable or imported vulnerabilities; `GO-2026-5932` remains only + a module-level notice because latest `x/crypto v0.55.0` is still required for + unaffected cryptography and the advisory has no patched release. ## TODO -### Compare two grid-selected images additions -- **Manual Comparison:** When the ctrl-key is hold down and one of the sides is paned of zoomed, - only the side the cursor is on is affected. so the lock between the two sides is released as long - the crtl key is hold down. WHen letting go of ctrl the two sides are glued back together. but at - the current position and zoom level. from there one pan and zoom again influences both sides. -- swipe view should be the default view in comarison mode.. - ### Functional test coverage Audit baseline, 2026-09-01: package-local statement coverage came from a diff --git a/translations/de.json b/translations/de.json index 8bbd479..d46e152 100644 --- a/translations/de.json +++ b/translations/de.json @@ -153,6 +153,11 @@ "Side by side": "Nebeneinander", "Swap": "Tauschen", "Back to Grid": "Zurück zur Rasteransicht", + "Link": "Koppeln", + "Unlink": "Entkoppeln", + "Unlinked": "Entkoppelt", + "Unlinked: Left": "Entkoppelt: Links", + "Unlinked: Right": "Entkoppelt: Rechts", "Compare: %s | %s - PicFetch": "Vergleich: %s | %s - PicFetch", "Return to Grid View before opening files": "Kehren Sie zur Rasteransicht zurück, bevor Sie Dateien öffnen", "Rotate image (CW)": "Bild drehen (im Uhrzeigersinn)", diff --git a/translations/en.json b/translations/en.json index 025a584..987eb18 100644 --- a/translations/en.json +++ b/translations/en.json @@ -153,6 +153,11 @@ "Side by side": "Side by side", "Swap": "Swap", "Back to Grid": "Back to Grid", + "Link": "Link", + "Unlink": "Unlink", + "Unlinked": "Unlinked", + "Unlinked: Left": "Unlinked: Left", + "Unlinked: Right": "Unlinked: Right", "Compare: %s | %s - PicFetch": "Compare: %s | %s - PicFetch", "Return to Grid View before opening files": "Return to Grid View before opening files", "Rotate image (CW)": "Rotate image (CW)",