From bdb6d398fb1d8e789f55203a87c042d5bb47047e Mon Sep 17 00:00:00 2001 From: frathe Date: Wed, 19 Aug 2026 10:07:18 +0200 Subject: [PATCH 1/6] Refactoring 01: Extract an App-State Controller --- ARCHITECTURE.md | 24 +- FyneApp.toml | 2 +- internal/ui/batch.go | 8 +- internal/ui/batch_test.go | 28 +- internal/ui/build.go | 7 +- internal/ui/clipboard.go | 4 +- internal/ui/clipboard_test.go | 8 +- internal/ui/delete_test.go | 40 +- internal/ui/drop.go | 36 +- internal/ui/e2e_test.go | 12 +- internal/ui/exif_test.go | 6 +- internal/ui/grid_test.go | 10 +- internal/ui/info.go | 10 +- internal/ui/keys.go | 16 +- internal/ui/library_test.go | 391 +++++++++++++----- internal/ui/load.go | 40 +- internal/ui/menu_test.go | 16 +- internal/ui/preferences_wiring_test.go | 12 +- internal/ui/run.go | 6 +- internal/ui/save.go | 2 +- internal/ui/save_test.go | 2 +- internal/ui/session_test.go | 4 +- internal/ui/slideshow_test.go | 44 +- internal/ui/sort.go | 30 +- internal/ui/viewer.go | 88 ++-- internal/ui/zoom_test.go | 2 +- ...ler.md => 01_DONE_app_state_controller.md} | 29 +- 27 files changed, 520 insertions(+), 357 deletions(-) rename planed_refactoring/{01_app_state_controller.md => 01_DONE_app_state_controller.md} (51%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4fd4833..4028f14 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,9 +16,16 @@ command-line paths to URIs (`argsToURIs`), and hands over to `ui.Run`. It stays ### `internal/ui` -The application. `viewer` (unexported) holds the core state — the file set, the current index, the image and its decode -cache — and the files below are its own code: construction, the key dispatcher, drop, load, display. Everything that -could own state independently of that core is a subpackage, listed after this table. +The application. Its unexported, package-local `appState` is the model boundary for the current file set: raw scan/drop +order, displayed order, current index, sort mode, and merge mode. `viewer` is that model's façade and the UI +orchestration hub: it owns Fyne widgets, rendering, and the operations that turn user events into state transitions. +Everything that could own state independently of that core is a subpackage, listed after this table. + +This is deliberately not a general controller extraction. Async scan/load/sort work and its generation/cancellation +guards remain with `viewer`, as do window geometry, menu enablement, and the widget-facing display/cache state they +coordinate. Native file-picker/save dialogs are likewise outside this boundary: `openfiles.go` and `export.go` remain +small viewer glue over `internal/filepicker`. Feature packages keep their existing narrow consumer-side `Host` +interfaces; `appState` is not exported or passed to them as a broad controller. `Run` is the package's only exported symbol. The `viewer` type never leaves the package; what the subpackages see of it is a set of exported methods on an unexported type (the "vocabulary" in `viewer.go`), each subpackage binding only to @@ -33,7 +40,8 @@ interface. | `build.go` | `buildViewer` — constructs and wires the whole UI, composed from per-feature widget constructors (`newDropzoneUI`/`newScanUI`/`newInfoOverlayUI` here, `newToast` in toast.go, each returning a small widget-cluster struct/component); also the keyboard-shortcut wiring (`wireOpenShortcuts`/`wireFavoriteShortcuts`/`wireClipboardShortcuts`/`wireDeleteShortcut`/`wireSaveShortcut`/`wireSelectAllShortcut`). The window's overlay stack is built here too, and the tail of its order is load-bearing: the grid's backdrop is opaque, so the delete confirmation and the toast are stacked *above* it or they render where nobody can see them | | `windowtrack.go` | The app's two window-geometry bindings, one line each now that both mechanisms are shared with the secondary windows that remember their own geometry: `windowSizeTracker` (over `widgets.NewSizeTracker` — records the window's size on every layout pass) and `startWindowPosPolling` (over `winpos.Poll` — keeps `viewer.winPos` current, skipped while the slideshow is full-screen; returns a stop func `Run` calls at shutdown). Plus `widgetGeometry`/`prefGeometry`, the translation between `preferences.WindowGeometry` and `widgets.Geometry` — the same four values, owned separately so neither package has to import the other | | `testdata/` | Golden-master screenshots for the e2e suite (moved here with the code that reads them, since a relative path can't reach a parent directory) | -| `viewer.go` | The `viewer` struct (UI state, navigation, image cache) — the core of the app — plus its small core-state methods: title handling, `clearToDropzone`/`reset`/`showWelcomeState`, `closeFiles` (the File menu's "Close Files" item — like `reset` but never closes the window), `undoGridMaximize` (undoes a grid-triggered `winpos.Maximize` — see `grid/`'s `ConsumeMaximized` — before a resize elsewhere tries to shrink the window back down), merge-mode toggle/get/set, `showFileIfPresent`, and the exported vocabulary the feature packages' `Host` interfaces bind to (`CurrentFile`, `RemoveFile`, `RemoveFiles`, `ShowImage`, `ShowToast`, `ShowEmptyStateError`, `ForceRepaint`, `FileCount`, `FileAt`, `OpenFiles`, `CurrentIndex`, `Generation`, `Unfocus`, `Modifiers`, `Advance`). `OpenFiles` sends a favorite's stored list through the existing drop/scan path; `RemoveFiles` is the batch form `internal/ui/deletion` binds to - descending, duplicate-tolerant, and the one place the grid is reconciled after the file set shrinks (`grid.FilesChanged`, plus `grid.Close` once nothing is left); plain `RemoveFile` stays for `load.go`'s failed-decode retry | +| `state.go` | The unexported, package-local `appState`: the current files, raw scan/drop order, current index, sort mode, and merge mode. Its mutation helpers copy replacement lists, reset/clamp the index, and remove one corresponding raw-order duplicate, so the model cannot split its displayed and unsorted lists. It is intentionally a state model, not a feature-facing controller: only `viewer` accesses it | +| `viewer.go` | The `viewer` façade and orchestration hub: Fyne/UI state, navigation, image cache, and the small methods that apply `appState` changes to the screen. It owns title handling, `clearToDropzone`/`reset`/`showWelcomeState`, `closeFiles` (the File menu's "Close Files" item — like `reset` but never closes the window), `undoGridMaximize` (undoes a grid-triggered `winpos.Maximize` — see `grid/`'s `ConsumeMaximized` — before a resize elsewhere tries to shrink the window back down), merge-mode toggle/get/set, `showFileIfPresent`, and the exported vocabulary the feature packages' narrow `Host` interfaces bind to (`CurrentFile`, `RemoveFile`, `RemoveFiles`, `ShowImage`, `ShowToast`, `ShowEmptyStateError`, `ForceRepaint`, `FileCount`, `FileAt`, `OpenFiles`, `CurrentIndex`, `Generation`, `Unfocus`, `Modifiers`, `Advance`). `OpenFiles` sends a favorite's stored list through the existing drop/scan path; `RemoveFiles` is the batch form `internal/ui/deletion` binds to - descending, duplicate-tolerant, and the one place the grid is reconciled after the file set shrinks (`grid.FilesChanged`, plus `grid.Close` once nothing is left); plain `RemoveFile` stays for `load.go`'s failed-decode retry | | `keys.go` | `handleKeyEvent` — the single keyboard dispatcher every unmodified key press runs through — plus `handleTypedRune`, its typed-*character* twin (wired to `SetOnTypedRune` alongside it in `build.go`). The grid's filename search is the only consumer of actual characters rather than key names, so runes are delivered only while the grid is up and dropped everywhere else | | `menu.go` | `buildMainMenu` — the window's menu bar: a File menu (Open Files…/Save Changes/Export as PNG…/Export as JPEG…/Set as Wallpaper/Close Files/Settings…, built here) composed with `help.Menu()`'s Help menu. The one place that decides how the two compose, per the cross-feature-composition rule below | | `drop.go` | Drop handling and the recursive folder scan: `handleDrop`, its shared completion step `applyScanResult`, `applyScannedFiles` (merges or replaces the file set, then reorders and displays it via `sort.go`'s `startSort` instead of sorting inline on the UI goroutine), `cancelScan`, `realPathOf`, the `maxScan` cap and its `MaxScan`/`SetMaxScan` get/set (the settings window's binding) | @@ -95,9 +103,11 @@ guards live in this package's dispatcher (`handleKeyEvent`'s `G` case checks `sl `togglePictureFrameMode`, which closes the grid on the way in. That is the general rule for cross-feature interaction after the split: features expose state and actions, and `internal/ui` decides how they compose. -What is still a method on `*viewer` (defined in `viewer.go`) is the core the split deliberately stopped at: drop, scan, -load, display, navigation, rotation, and the thin glue files above. Every feature that could own its own state now -does — the structural extractions are finished. +`appState` is the accepted model boundary inside this package, while `viewer` deliberately remains the façade that +orchestrates it with widgets and features. The split stops before async scan/load/sort lifecycle, geometry restoration, +menu enablement, native file-picker/save-dialog glue, display/cache state, and rendering: moving those would either +mix Fyne lifecycle into the model or widen feature dependencies. Every feature that could own its own state still does, +through its existing narrow consumer-side interface rather than a shared controller. ### `internal/imaging` diff --git a/FyneApp.toml b/FyneApp.toml index 95e8e91..80d3dcd 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -2,7 +2,7 @@ Name = "PicFetch" ID = "io.github.frathe.picfetch" Version = "0.1.7" -Build = 310 +Build = 315 [Migrations] fyneDo = true diff --git a/internal/ui/batch.go b/internal/ui/batch.go index e01adb8..5423c06 100644 --- a/internal/ui/batch.go +++ b/internal/ui/batch.go @@ -48,10 +48,10 @@ func (v *viewer) deleteGridSelection() { ts := make([]deletion.Target, 0, len(targets)) for _, i := range targets { - if i < 0 || i >= len(v.files) { + if i < 0 || i >= len(v.state.files) { continue } - ts = append(ts, deletion.Target{URI: v.files[i], Index: i}) + ts = append(ts, deletion.Target{URI: v.state.files[i], Index: i}) } v.deletion.RequestFiles(ts) @@ -86,8 +86,8 @@ func (v *viewer) copyGridSelection() { paths := make([]string, 0, len(targets)) for _, i := range targets { - if i >= 0 && i < len(v.files) { - paths = append(paths, v.files[i].Path()) + if i >= 0 && i < len(v.state.files) { + paths = append(paths, v.state.files[i].Path()) } } if len(paths) == 0 { diff --git a/internal/ui/batch_test.go b/internal/ui/batch_test.go index f7a0a6c..4afa20a 100644 --- a/internal/ui/batch_test.go +++ b/internal/ui/batch_test.go @@ -126,7 +126,7 @@ func TestBatchDelete_RemovesEverySelectedFileAndLeavesTheGridOpen(t *testing.T) return nil }) - kept := v.files[1].Path() + kept := v.state.files[1].Path() v.grid.ClearSelection() v.grid.SelectAll() // Deselect the middle one, so this is a real subset rather than "all". @@ -144,8 +144,8 @@ func TestBatchDelete_RemovesEverySelectedFileAndLeavesTheGridOpen(t *testing.T) if slices.Contains(moved, kept) { t.Errorf("the deselected file %q was moved to the Trash", kept) } - if len(v.files) != 1 { - t.Errorf("len(v.files) = %d, want 1 left", len(v.files)) + if len(v.state.files) != 1 { + t.Errorf("len(v.state.files) = %d, want 1 left", len(v.state.files)) } if !v.grid.Visible() { t.Error("the grid should stay open after a batch delete, so the user keeps their place") @@ -168,8 +168,8 @@ func TestBatchDelete_ClosesTheGridWhenNothingIsLeft(t *testing.T) { v.deletion.HandleKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) v.deletion.Settle() - if len(v.files) != 0 { - t.Fatalf("len(v.files) = %d, want every file gone", len(v.files)) + if len(v.state.files) != 0 { + t.Fatalf("len(v.state.files) = %d, want every file gone", len(v.state.files)) } if v.grid.Visible() { t.Error("the grid should close once its last file is deleted") @@ -253,7 +253,7 @@ func TestCopy_WhileGridVisibleCopiesTheSelectionAsFileReferences(t *testing.T) { handler.TypedShortcut(&fyne.ShortcutCopy{}) waitForClipboard(t, v) - want := []string{v.files[0].Path(), v.files[1].Path(), v.files[2].Path()} + want := []string{v.state.files[0].Path(), v.state.files[1].Path(), v.state.files[2].Path()} if !slices.Equal(got, want) { t.Errorf("CopyFiles paths = %v, want %v", got, want) } @@ -275,7 +275,7 @@ func TestCopy_WhileGridVisibleFallsBackToTheHighlightedCell(t *testing.T) { handler.TypedShortcut(&fyne.ShortcutCopy{}) waitForClipboard(t, v) - if want := []string{v.files[1].Path()}; !slices.Equal(got, want) { + if want := []string{v.state.files[1].Path()}; !slices.Equal(got, want) { t.Errorf("CopyFiles paths = %v, want %v", got, want) } } @@ -361,13 +361,13 @@ func TestRemoveFiles_DropsEveryIndexAndPurgesTheirCacheEntries(t *testing.T) { } dropAndWait(t, v, uris...) - gone := []string{v.files[0].String(), v.files[2].String()} - kept := v.files[1].String() + gone := []string{v.state.files[0].String(), v.state.files[2].String()} + kept := v.state.files[1].String() v.RemoveFiles([]int{0, 2}) - if len(v.files) != 1 || v.files[0].String() != kept { - t.Errorf("v.files = %v, want only %s left", v.files, kept) + if len(v.state.files) != 1 || v.state.files[0].String() != kept { + t.Errorf("v.state.files = %v, want only %s left", v.state.files, kept) } for _, key := range gone { if v.imgCache.Contains(key) { @@ -388,12 +388,12 @@ func TestRemoveFiles_HandlesUnsortedIndices(t *testing.T) { } dropAndWait(t, v, uris...) - kept := v.files[1].String() + kept := v.state.files[1].String() v.RemoveFiles([]int{2, 0}) - if len(v.files) != 1 || v.files[0].String() != kept { - t.Errorf("v.files = %v, want only %s left", v.files, kept) + if len(v.state.files) != 1 || v.state.files[0].String() != kept { + t.Errorf("v.state.files = %v, want only %s left", v.state.files, kept) } } diff --git a/internal/ui/build.go b/internal/ui/build.go index 019506f..7d2fb8d 100644 --- a/internal/ui/build.go +++ b/internal/ui/build.go @@ -159,7 +159,7 @@ func newScanUI() scanUI { // hands over. A dedicated pair rather than reusing scanUI's - a background // scan (a merge-mode drop) can still be in flight when a sort-mode change is // requested, since handleKeyEvent's S-key guard only checks -// len(v.files)<2/v.loading, not v.scanning, and the two would otherwise +// len(v.state.files)<2/v.loading, not v.scanning, and the two would otherwise // fight over one pair of widgets. Unlike scanUI's label, this one's text // never changes: the ask is only to show that a sort is running, not to // track its progress the way the scan counter does. @@ -306,8 +306,7 @@ func buildViewer(application fyne.App) (*viewer, fyne.Window) { infoText: info.text, infoCard: info.card, exifLink: info.exifLink, - sortMode: filesort.FromPref(prefs.SortMode), - mergeMode: prefs.MergeMode, + state: newAppState(filesort.FromPref(prefs.SortMode), prefs.MergeMode), baseTitle: appTitle, help: help.New(application, appTitle, assets.WelcomeWebP), exif: exifwin.New(application, func() (fyne.URI, bool) { return view.displayedFile() }), @@ -530,7 +529,7 @@ func wireFavoriteShortcuts(c shortcutAdder, open func(index int)) { // AddShortcut rather than handleKeyEvent's plain SetOnTypedKey dispatch, for // the same reason wireOpenShortcuts does: modified key combos never reach // TypedKey at all. Deliberately not gated behind handleKeyEvent's -// len(v.files)<2 navigation guard - both work fine with a single file +// len(v.state.files)<2 navigation guard - both work fine with a single file // loaded, and copyImageToClipboard/copyPathToClipboard already no-op safely // when nothing is loaded yet. // diff --git a/internal/ui/clipboard.go b/internal/ui/clipboard.go index b9191e3..46177ac 100644 --- a/internal/ui/clipboard.go +++ b/internal/ui/clipboard.go @@ -16,10 +16,10 @@ import ( // text clipboard. No shell-out needed here, unlike copyImageToClipboard // below - fyne.Clipboard already handles text on every platform. func (v *viewer) copyPathToClipboard() { - if len(v.files) == 0 { + if len(v.state.files) == 0 { return } - v.app.Clipboard().SetContent(v.files[v.index].Path()) + v.app.Clipboard().SetContent(v.state.files[v.state.index].Path()) } // copyImageToClipboard puts the currently displayed frame onto the system diff --git a/internal/ui/clipboard_test.go b/internal/ui/clipboard_test.go index f7390d5..0c3f97a 100644 --- a/internal/ui/clipboard_test.go +++ b/internal/ui/clipboard_test.go @@ -26,8 +26,8 @@ func TestCopyPathToClipboard_SetsFilePath(t *testing.T) { v, _, _ := newTestUI(t) jpegURI := uitest.TempJPEGURI(t, "picked.jpg", 4, 4, color.RGBA{R: 100, A: 255}) - v.files = []fyne.URI{jpegURI} - v.index = 0 + v.state.files = []fyne.URI{jpegURI} + v.state.index = 0 v.copyPathToClipboard() @@ -135,8 +135,8 @@ func TestWireClipboardShortcuts_CopiesImageAndPath(t *testing.T) { v, _, _ := newTestUI(t) jpegURI := uitest.TempJPEGURI(t, "picked.jpg", 4, 4, color.RGBA{R: 100, A: 255}) - v.files = []fyne.URI{jpegURI} - v.index = 0 + v.state.files = []fyne.URI{jpegURI} + v.state.index = 0 v.img.Image = image.NewRGBA(image.Rect(0, 0, 2, 2)) called := make(chan struct{}, 1) diff --git a/internal/ui/delete_test.go b/internal/ui/delete_test.go index c94dcc1..6b41a4b 100644 --- a/internal/ui/delete_test.go +++ b/internal/ui/delete_test.go @@ -51,10 +51,10 @@ func TestHandleKeyEvent_DeleteConfirmSwallowsNavigationButRespondsToItsOwnKeys(t dropAndWait(t, v, a, b) v.deletion.Request() - startIndex := v.index + startIndex := v.state.index v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyRight}) - if v.index != startIndex { + if v.state.index != startIndex { t.Error("arrow-key navigation should be swallowed while the delete confirmation is up") } @@ -62,7 +62,7 @@ func TestHandleKeyEvent_DeleteConfirmSwallowsNavigationButRespondsToItsOwnKeys(t if v.deletion.Visible() { t.Error("Escape should dismiss the confirmation instead of falling through to its usual meaning") } - if len(v.files) != 2 { + if len(v.state.files) != 2 { t.Error("Escape on the confirmation must not also reset the loaded file set") } } @@ -84,11 +84,11 @@ func TestPerformDelete_RemovesCurrentFileAndAdvancesToTheNextOne(t *testing.T) { if _, err := os.Stat(a.Path()); !os.IsNotExist(err) { t.Errorf("a.jpg should no longer exist on disk, stat = %v", err) } - if len(v.files) != 1 || v.files[0].String() != b.String() { - t.Fatalf("files = %v, want just b.jpg left", v.files) + if len(v.state.files) != 1 || v.state.files[0].String() != b.String() { + t.Fatalf("files = %v, want just b.jpg left", v.state.files) } - if v.index != 0 { - t.Errorf("index = %d, want 0 (b.jpg took a.jpg's slot)", v.index) + if v.state.index != 0 { + t.Errorf("index = %d, want 0 (b.jpg took a.jpg's slot)", v.state.index) } if !v.toast.card.Visible() { t.Error("expected a toast confirming the deletion") @@ -98,9 +98,9 @@ func TestPerformDelete_RemovesCurrentFileAndAdvancesToTheNextOne(t *testing.T) { // TestPerformDelete_OnLastImageOfMultipleAdvancesWithoutPanicking is a // regression test: deleting while positioned on the last image of a -// multi-file set left v.index equal to the new (shrunk) length, so the very +// multi-file set left v.state.index equal to the new (shrunk) length, so the very // next CurrentFile() call - performDelete's own "did that empty the set?" -// check - indexed v.files out of range and crashed the whole app. +// check - indexed v.state.files out of range and crashed the whole app. func TestPerformDelete_OnLastImageOfMultipleAdvancesWithoutPanicking(t *testing.T) { uitest.StubTrashMove(t, func(path string) error { return os.Remove(path) }) v := newTestViewer(t) @@ -110,18 +110,18 @@ func TestPerformDelete_OnLastImageOfMultipleAdvancesWithoutPanicking(t *testing. v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyRight}) waitUntilLoaded(t, v) - if v.index != 1 { - t.Fatalf("setup: index = %d, want 1 (on b.jpg, the last image)", v.index) + if v.state.index != 1 { + t.Fatalf("setup: index = %d, want 1 (on b.jpg, the last image)", v.state.index) } confirmDelete(t, v) waitUntilLoaded(t, v) - if len(v.files) != 1 || v.files[0].String() != a.String() { - t.Fatalf("files = %v, want just a.jpg left", v.files) + if len(v.state.files) != 1 || v.state.files[0].String() != a.String() { + t.Fatalf("files = %v, want just a.jpg left", v.state.files) } - if v.index != 0 { - t.Errorf("index = %d, want 0 (a.jpg took b.jpg's slot)", v.index) + if v.state.index != 0 { + t.Errorf("index = %d, want 0 (a.jpg took b.jpg's slot)", v.state.index) } settleToast(t, v) } @@ -137,8 +137,8 @@ func TestPerformDelete_LastFileReturnsToEmptyDropzone(t *testing.T) { confirmDelete(t, v) - if len(v.files) != 0 { - t.Error("v.files should be empty after deleting the last file") + if len(v.state.files) != 0 { + t.Error("v.state.files should be empty after deleting the last file") } if v.dropzone == nil || !v.dropzone.Visible() { t.Error("expected the drop zone to reappear once nothing is left") @@ -151,7 +151,7 @@ func TestPerformDelete_LastFileReturnsToEmptyDropzone(t *testing.T) { // TestPerformDelete_OSFailureKeepsTheFileAndToastsAnError guards the // trash.Move error path through the real viewer: if the move fails, the -// file must stay in v.files (nothing silently dropped from the set for a +// file must stay in v.state.files (nothing silently dropped from the set for a // file that's actually still there) and the user must be told. func TestPerformDelete_OSFailureKeepsTheFileAndToastsAnError(t *testing.T) { uitest.StubTrashMove(t, func(path string) error { return os.Remove(path) }) @@ -168,8 +168,8 @@ func TestPerformDelete_OSFailureKeepsTheFileAndToastsAnError(t *testing.T) { confirmDelete(t, v) - if len(v.files) != 1 { - t.Error("a file that failed to delete must stay in v.files") + if len(v.state.files) != 1 { + t.Error("a file that failed to delete must stay in v.state.files") } if !v.toast.card.Visible() { t.Error("expected a toast reporting the deletion failure") diff --git a/internal/ui/drop.go b/internal/ui/drop.go index f4794d5..8c5b4fe 100644 --- a/internal/ui/drop.go +++ b/internal/ui/drop.go @@ -21,7 +21,7 @@ import ( // instead of racing a large tree to completion for a result nobody will // see. // -// Unlike reset, it never touches v.files or v.unsortedFiles: a merge-mode +// Unlike reset, it never touches v.state.files or v.state.unsortedFiles: a merge-mode // scan can be cancelled mid-way through without losing images that were // already loaded before it started. Only a scan that had nothing loaded yet // (the first-ever drop) needs the drop zone put back the way handleDrop @@ -38,7 +38,7 @@ func (v *viewer) cancelScan() { v.scanSpinner.Hide() v.scanLabel.Hide() - if len(v.files) == 0 { + if len(v.state.files) == 0 { v.showWelcomeState() v.dropzone.Show() } @@ -65,7 +65,7 @@ func realPathOf(u fyne.URI) string { // exercise the cap). It's a safety valve for pathological trees (a runaway // symlink cycle EvalSymlinks doesn't resolve to a repeat, or a genuinely // enormous archive) - past this, stat-ing and holding URIs would stall the -// scan goroutine and bloat v.files well past anything the viewer or its +// scan goroutine and bloat v.state.files well past anything the viewer or its // sort/preload paths are meant to handle. const defaultMaxScannedFiles = 200_000 @@ -116,7 +116,7 @@ func (v *viewer) handleDrop(uris []fyne.URI) { // a folder scan can take seconds, and toggling M while one is still // running shouldn't retroactively change how this already-in-flight // drop gets applied. - merging := v.mergeMode && len(v.files) > 0 + merging := v.state.MergeMode() && len(v.state.files) > 0 gen := v.invalidateLoad() v.stopAnimation() @@ -190,7 +190,7 @@ func (v *viewer) handleDrop(uris []fyne.URI) { // seenFiles dedupes images within this one scan, keyed the same way // as visitedDirs: dropping a folder together with one of its own // subfolders, or a symlinked file reachable via two different - // directory paths, would otherwise add the same picture to v.files + // directory paths, would otherwise add the same picture to v.state.files // twice. This is scoped to a single handleDrop call, not across // drops - merge mode has always allowed re-merging a file that's // already loaded (see RemoveFile's comment on why it removes by @@ -328,20 +328,21 @@ func (v *viewer) applyScanResult(gen uint64, merging bool, uris, images []fyne.U } // applyScannedFiles merges or replaces the file set with images, then -// reorders v.unsortedFiles/v.files under the current sort mode in the +// reorders v.state.unsortedFiles/v.state.files under the current sort mode in the // background via startSort (sort.go) - same reason SetSortMode does: the // capture-date/modified/size modes stat or Exif-read every file, which would // otherwise freeze the UI for as long as this scan just took to gather them, // right as it finishes. // -// v.unsortedFiles and v.files are deliberately only ever written together, -// atomically, once the reorder lands - never one without the other. This +// v.state.unsortedFiles and v.state.files are deliberately only ever written together, +// once the reorder lands - never one without the other. A replacement also +// resets index in that same callback. This // matters because RemoveFile's own comment documents them as required to // always hold the same set of files (just possibly different order) so a // later sort toggle doesn't resurrect a removed file; updating -// v.unsortedFiles synchronously here but leaving v.files to catch up later +// v.state.unsortedFiles synchronously here but leaving v.state.files to catch up later // would violate that invariant for as long as the background reorder is -// still running, and could leave v.index pointing past the end of a v.files +// still running, and could leave v.state.index pointing past the end of a v.state.files // a *different*, later-landing reorder (a concurrent SetSortMode call, say) // has already replaced out from under it. Keeping both deferred to the same // onDone callback means that can't happen: whichever reorder's generation is @@ -350,20 +351,23 @@ func (v *viewer) applyScanResult(gen uint64, merging bool, uris, images []fyne.U func (v *viewer) applyScannedFiles(merging bool, images []fyne.URI) { var unsorted []fyne.URI if merging { - // Copied rather than appended onto v.unsortedFiles directly - same + // Copied rather than appended onto v.state.unsortedFiles directly - same // reason SetSortMode's own snapshot is a copy: this slice is about to // be read by a background goroutine, and appending onto - // v.unsortedFiles's existing backing array (when it has spare + // v.state.unsortedFiles's existing backing array (when it has spare // capacity) would let a concurrent RemoveFile mutate the same memory // the goroutine is reading. - unsorted = append(append([]fyne.URI(nil), v.unsortedFiles...), images...) + unsorted = append(append([]fyne.URI(nil), v.state.unsortedFiles...), images...) } else { unsorted = images } - v.startSort(v.sortMode, unsorted, func(ordered []fyne.URI) { - v.unsortedFiles = unsorted - v.files = ordered + v.startSort(v.state.SortMode(), unsorted, func(ordered []fyne.URI) { + if !merging { + v.state.replaceFiles(unsorted, ordered) + } else { + v.state.setFiles(unsorted, ordered) + } v.ForceRepaint() if merging { diff --git a/internal/ui/e2e_test.go b/internal/ui/e2e_test.go index 532b927..10a9eef 100644 --- a/internal/ui/e2e_test.go +++ b/internal/ui/e2e_test.go @@ -110,7 +110,7 @@ func TestE2E_BadDropWithNothingLoadedShowsPlaceholder(t *testing.T) { // something unsupported after images were already loaded used to leave the // last image visible behind the error toast and placeholder art, because // the empty-image branches showed the placeholder without ever clearing -// the previous v.img/v.files. ShowEmptyStateError (viewer.go) fixes this +// the previous v.img/v.state.files. ShowEmptyStateError (viewer.go) fixes this // by fully resetting the display before showing the error. func TestE2E_BadDropAfterImagesClearsDisplay(t *testing.T) { v, win, _ := newTestUI(t) @@ -123,8 +123,8 @@ func TestE2E_BadDropAfterImagesClearsDisplay(t *testing.T) { if v.img.Visible() || v.img.Image != nil { t.Error("the previous image must not linger behind the error") } - if v.files != nil { - t.Errorf("files = %v, want nil after a drop with nothing supported", v.files) + if v.state.files != nil { + t.Errorf("files = %v, want nil after a drop with nothing supported", v.state.files) } if !v.emptyStateArt.Visible() { t.Error("expected the error placeholder art in place of the cleared image") @@ -145,8 +145,8 @@ func TestE2E_EscapeResetsAfterImagesLoaded(t *testing.T) { v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyEscape}) - if v.files != nil { - t.Errorf("files = %v, want nil after Escape resets", v.files) + if v.state.files != nil { + t.Errorf("files = %v, want nil after Escape resets", v.state.files) } if v.img.Visible() { t.Error("image should be hidden after Escape resets") @@ -249,7 +249,7 @@ func TestE2E_EscapeQuitsWhenNothingLoaded(t *testing.T) { } // TestE2E_EscapeCancelsScanInsteadOfClosing checks the priority handleKeyEvent -// gives Escape while a scan is in flight: len(v.files) == 0 is exactly the +// gives Escape while a scan is in flight: len(v.state.files) == 0 is exactly the // state a first-ever drop's scan runs in, so without the v.scanning check // ahead of it, this would otherwise hit the "nothing loaded" branch above // and close the window out from under a scan the user meant to cancel. diff --git a/internal/ui/exif_test.go b/internal/ui/exif_test.go index 9661575..9b7d340 100644 --- a/internal/ui/exif_test.go +++ b/internal/ui/exif_test.go @@ -83,7 +83,7 @@ func TestShowExifWindow_ContentAndRefreshOnNavigation(t *testing.T) { t.Errorf("exifText = %q, want %q", got, want) } - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) if got := v.exif.Text().Text; got != want { @@ -172,14 +172,14 @@ func TestExifLink_VisibilityFollowsNavigation(t *testing.T) { t.Fatal("the EXIF link should be shown for the first file, which has EXIF metadata") } - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) if v.exifLink.Visible() { t.Error("the EXIF link should hide again after navigating to a file with no EXIF metadata") } - v.ShowImage(v.index - 1) + v.ShowImage(v.state.index - 1) waitUntilLoaded(t, v) if !v.exifLink.Visible() { diff --git a/internal/ui/grid_test.go b/internal/ui/grid_test.go index 4bb4fbb..2f85a38 100644 --- a/internal/ui/grid_test.go +++ b/internal/ui/grid_test.go @@ -101,14 +101,14 @@ func TestHandleKeyEvent_GridVisible_SwallowsNavigation(t *testing.T) { warmThumbs(t, v) v.grid.Toggle() - before := v.index + before := v.state.index // Right is intercepted by the grid (it moves the highlight) rather // than falling through to normal next-image navigation. v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyRight}) - if v.index != before { - t.Errorf("index changed to %d while the grid was up, want unchanged from %d", v.index, before) + if v.state.index != before { + t.Errorf("index changed to %d while the grid was up, want unchanged from %d", v.state.index, before) } if !v.grid.Visible() { t.Error("Right should not close the grid") @@ -132,8 +132,8 @@ func TestHandleKeyEvent_GridVisible_ReturnNavigatesAndCloses(t *testing.T) { if v.grid.Visible() { t.Error("committing a cell should close the grid") } - if v.index != 1 { - t.Errorf("index = %d, want 1 - the highlighted image should now be on screen", v.index) + if v.state.index != 1 { + t.Errorf("index = %d, want 1 - the highlighted image should now be on screen", v.state.index) } } diff --git a/internal/ui/info.go b/internal/ui/info.go index 9466acf..ec96d47 100644 --- a/internal/ui/info.go +++ b/internal/ui/info.go @@ -33,7 +33,7 @@ func (v *viewer) toggleInfoOverlay() { // changes, while updateInfoOverlay also runs on every zoom change - and a // zoom can't add or remove a file's metadata. func (v *viewer) syncInfoOverlayVisibility() { - if v.infoVisible && len(v.files) > 0 && v.img.Image != nil { + if v.infoVisible && len(v.state.files) > 0 && v.img.Image != nil { v.updateInfoOverlay() if v.currentHasEXIF { v.exifLink.Show() @@ -52,14 +52,14 @@ func (v *viewer) syncInfoOverlayVisibility() { // onChanged callback after every zoom change, unconditionally, without // checking visibility itself first. func (v *viewer) updateInfoOverlay() { - if !v.infoVisible || len(v.files) == 0 || v.img.Image == nil { + if !v.infoVisible || len(v.state.files) == 0 || v.img.Image == nil { return } w, h := v.displayedDimensions() - name := v.files[v.index].Name() - if n := len(v.files); n > 1 { - name = fmt.Sprintf("%s (%d/%d)", name, v.index+1, n) + name := v.state.files[v.state.index].Name() + if n := len(v.state.files); n > 1 { + name = fmt.Sprintf("%s (%d/%d)", name, v.state.index+1, n) } lines := []string{ diff --git a/internal/ui/keys.go b/internal/ui/keys.go index a103e5d..d60d22f 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -88,13 +88,13 @@ func (v *viewer) handleKeyEvent(ev *fyne.KeyEvent) { // picture-frame mode is on, Escape leaves it (like any other // full-screen app) instead of resetting the session - press it // again afterwards for that. A scan in progress takes priority over - // both the close and reset branches below: len(v.files) == 0 is + // both the close and reset branches below: len(v.state.files) == 0 is // exactly the state a first-ever drop's scan runs in, so without // this check Escape would close the window out from under a scan // the user meant to cancel instead. v.sorting takes the same - // priority for the same reason, and for the same len(v.files) == 0 + // priority for the same reason, and for the same len(v.state.files) == 0 // risk during a first-ever drop's reorder - but unlike cancelScan, - // cancelSort (sort.go) never touches v.files/v.unsortedFiles at + // cancelSort (sort.go) never touches v.state.files/v.state.unsortedFiles at // all (they're never written until the reorder's own onDone runs), // so cancelling a resort of an already-loaded set just stops the // background work and leaves what's on screen exactly as it was, @@ -107,7 +107,7 @@ func (v *viewer) handleKeyEvent(ev *fyne.KeyEvent) { v.cancelScan() } else if v.sorting { v.cancelSort() - } else if len(v.files) == 0 { + } else if len(v.state.files) == 0 { v.win.Close() } else { v.reset() @@ -221,19 +221,19 @@ func (v *viewer) handleKeyEvent(ev *fyne.KeyEvent) { // Ignore repeat events fired while the previous image is still // decoding/rendering, instead of piling up decodes for images the // user has already navigated past. - if len(v.files) < 2 || v.loading.Load() { + if len(v.state.files) < 2 || v.loading.Load() { return } switch ev.Name { case fyne.KeyRight, fyne.KeyDown: - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) case fyne.KeyLeft, fyne.KeyUp: - v.ShowImage(v.index - 1) + v.ShowImage(v.state.index - 1) case fyne.KeyHome: v.ShowImage(0) case fyne.KeyEnd: - v.ShowImage(len(v.files) - 1) + v.ShowImage(len(v.state.files) - 1) case fyne.KeyS: v.toggleSort() default: diff --git a/internal/ui/library_test.go b/internal/ui/library_test.go index 8c2aa0a..25df6ba 100644 --- a/internal/ui/library_test.go +++ b/internal/ui/library_test.go @@ -271,8 +271,8 @@ func TestHandleDrop_EmptyDrop(t *testing.T) { v.handleDrop(nil) - if v.files != nil { - t.Errorf("files = %v, want nil after an empty drop", v.files) + if v.state.files != nil { + t.Errorf("files = %v, want nil after an empty drop", v.state.files) } if n := len(v.win.Canvas().Overlays().List()); n != 0 { @@ -289,8 +289,8 @@ func TestHandleDrop_NoSupportedImages(t *testing.T) { }) waitForScan(t, v) - if v.files != nil { - t.Errorf("files = %v, want nil when nothing dropped is a supported image", v.files) + if v.state.files != nil { + t.Errorf("files = %v, want nil when nothing dropped is a supported image", v.state.files) } if !v.toast.card.Visible() { @@ -317,8 +317,8 @@ func TestHandleDrop_ErrorAfterImagesClearsDisplay(t *testing.T) { // previous image sitting behind the error toast and placeholder art. dropAndWaitScan(t, v, uitest.FakeURI{FileName: "notes.txt", Ext: ".txt"}) - if v.files != nil { - t.Errorf("files = %v, want nil after a drop with nothing supported", v.files) + if v.state.files != nil { + t.Errorf("files = %v, want nil after a drop with nothing supported", v.state.files) } if v.img.Image != nil { t.Error("the previous image should be cleared, not left showing behind the error") @@ -351,8 +351,8 @@ func TestHandleDrop_FiltersUnsupportedFiles(t *testing.T) { waitForSort(t, v) waitUntilLoaded(t, v) - if len(v.files) != 1 || v.files[0].Name() != jpegURI.Name() { - t.Errorf("files = %v, want only %q kept", v.files, jpegURI.Name()) + if len(v.state.files) != 1 || v.state.files[0].Name() != jpegURI.Name() { + t.Errorf("files = %v, want only %q kept", v.state.files, jpegURI.Name()) } } @@ -364,8 +364,8 @@ func TestHandleDrop_AcceptsPNGAndGIF(t *testing.T) { dropAndWait(t, v, storage.NewFileURI(pngPath), storage.NewFileURI(gifPath)) - if len(v.files) != 2 { - t.Fatalf("files = %v, want both the PNG and the GIF kept", v.files) + if len(v.state.files) != 2 { + t.Fatalf("files = %v, want both the PNG and the GIF kept", v.state.files) } } @@ -383,8 +383,8 @@ func TestHandleDrop_SecondDropWithoutMergeModeReplaces(t *testing.T) { waitForSort(t, v) waitUntilLoaded(t, v) - if len(v.files) != 1 || v.files[0].Name() != "b.jpg" { - t.Errorf("files = %v, want only %q - the second drop should replace the first", v.files, "b.jpg") + if len(v.state.files) != 1 || v.state.files[0].Name() != "b.jpg" { + t.Errorf("files = %v, want only %q - the second drop should replace the first", v.state.files, "b.jpg") } } @@ -395,15 +395,15 @@ func TestHandleDrop_MergeModeMergesIntoExistingSet(t *testing.T) { dropAndWait(t, v, a) b := uitest.TempJPEGURI(t, "b.jpg", 4, 4, color.White) - v.mergeMode = true + v.state.SetMergeMode(true) dropAndWait(t, v, b) - if len(v.files) != 2 { - t.Fatalf("files = %v, want both a.jpg and b.jpg after a merge-mode drop", v.files) + if len(v.state.files) != 2 { + t.Fatalf("files = %v, want both a.jpg and b.jpg after a merge-mode drop", v.state.files) } // The merge should have jumped to the file just added, not stayed on a.jpg. - if got := v.files[v.index].Name(); got != "b.jpg" { + if got := v.state.files[v.state.index].Name(); got != "b.jpg" { t.Errorf("displayed file = %q, want b.jpg (the just-merged file) in view", got) } } @@ -414,11 +414,11 @@ func TestHandleDrop_MergeModeDropWithNothingSupportedKeepsExistingSet(t *testing a := uitest.TempJPEGURI(t, "a.jpg", 4, 4, color.White) dropAndWait(t, v, a) - v.mergeMode = true + v.state.SetMergeMode(true) dropAndWaitScan(t, v, uitest.FakeURI{FileName: "notes.txt", Ext: ".txt"}) - if len(v.files) != 1 || v.files[0].Name() != "a.jpg" { - t.Errorf("files = %v, want the existing a.jpg untouched by a merge-mode drop with nothing supported", v.files) + if len(v.state.files) != 1 || v.state.files[0].Name() != "a.jpg" { + t.Errorf("files = %v, want the existing a.jpg untouched by a merge-mode drop with nothing supported", v.state.files) } if v.img.Image == nil { t.Error("the existing image should stay displayed, not cleared, when a merge-mode drop finds nothing new") @@ -563,7 +563,7 @@ func TestToggleInfoOverlay_ContentAndPersistenceAcrossNavigation(t *testing.T) { // Step to the second file: the card must refresh, not keep showing a's // info. - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) v.updateInfoOverlay() @@ -678,8 +678,8 @@ func TestHandleDrop_RecursesIntoNestedDirectories(t *testing.T) { dropAndWait(t, v, storage.NewFileURI(root)) - if len(v.files) != 3 { - t.Fatalf("files = %v, want the 3 nested photos, none of the .DS_Store junk", v.files) + if len(v.state.files) != 3 { + t.Fatalf("files = %v, want the 3 nested photos, none of the .DS_Store junk", v.state.files) } if v.dropzone.Visible() { @@ -705,8 +705,8 @@ func TestHandleDrop_SymlinkCycleDoesNotHang(t *testing.T) { dropAndWait(t, v, storage.NewFileURI(root)) - if len(v.files) != 1 { - t.Fatalf("files = %v, want the 1 real photo, not one entry per pass through the symlink cycle", v.files) + if len(v.state.files) != 1 { + t.Fatalf("files = %v, want the 1 real photo, not one entry per pass through the symlink cycle", v.state.files) } } @@ -728,8 +728,8 @@ func TestHandleDrop_CapsFileCountForLargeTrees(t *testing.T) { dropAndWait(t, v, storage.NewFileURI(root)) - if len(v.files) != 3 { - t.Fatalf("files = %d, want the scan to stop at maxScan (3)", len(v.files)) + if len(v.state.files) != 3 { + t.Fatalf("files = %d, want the scan to stop at maxScan (3)", len(v.state.files)) } if !v.toast.card.Visible() { @@ -894,8 +894,8 @@ func TestCancelScan_PreservesExistingFilesInMergeMode(t *testing.T) { v := newTestViewer(t) existing := uitest.TempJPEGURI(t, "existing.jpg", 4, 4, color.White) - v.files = []fyne.URI{existing} - v.unsortedFiles = []fyne.URI{existing} + v.state.files = []fyne.URI{existing} + v.state.unsortedFiles = []fyne.URI{existing} v.dropzone.Hide() v.scanning = true @@ -904,8 +904,8 @@ func TestCancelScan_PreservesExistingFilesInMergeMode(t *testing.T) { v.cancelScan() - if len(v.files) != 1 || v.files[0].String() != existing.String() { - t.Errorf("files = %v, want the pre-existing file untouched by cancelling a merge-mode scan", v.files) + if len(v.state.files) != 1 || v.state.files[0].String() != existing.String() { + t.Errorf("files = %v, want the pre-existing file untouched by cancelling a merge-mode scan", v.state.files) } if v.dropzone.Visible() { t.Error("drop zone should stay hidden - an image was already loaded before the cancelled scan started") @@ -953,8 +953,8 @@ func TestHandleDrop_SupersededScanGoroutineExits(t *testing.T) { t.Fatal("superseded scan's goroutine never exited - scanDone was never closed") } - if len(v.files) != 1 || v.files[0].String() != jpegB.String() { - t.Errorf("files = %v, want only the second drop's file applied", v.files) + if len(v.state.files) != 1 || v.state.files[0].String() != jpegB.String() { + t.Errorf("files = %v, want only the second drop's file applied", v.state.files) } } @@ -979,15 +979,15 @@ func TestHandleDrop_DedupesOverlappingDirectories(t *testing.T) { dropAndWait(t, v, storage.NewFileURI(root), storage.NewFileURI(sub)) - if len(v.files) != 2 { - t.Fatalf("files = %v, want top.jpg and nested.jpg once each, not nested.jpg twice from the overlapping drop", v.files) + if len(v.state.files) != 2 { + t.Fatalf("files = %v, want top.jpg and nested.jpg once each, not nested.jpg twice from the overlapping drop", v.state.files) } } // TestHandleDrop_DedupesDuplicateURIsInDirectDrop covers the fast (no // directories) path in handleDrop: passing the same file twice in one drop - // which os.Args launch or a native chooser's output could in principle -// produce - should not add it to v.files twice. +// produce - should not add it to v.state.files twice. func TestHandleDrop_DedupesDuplicateURIsInDirectDrop(t *testing.T) { v := newTestViewer(t) @@ -995,8 +995,8 @@ func TestHandleDrop_DedupesDuplicateURIsInDirectDrop(t *testing.T) { dropAndWait(t, v, photo, photo) - if len(v.files) != 1 { - t.Fatalf("files = %v, want the duplicate URI collapsed to a single entry", v.files) + if len(v.state.files) != 1 { + t.Fatalf("files = %v, want the duplicate URI collapsed to a single entry", v.state.files) } } @@ -1228,8 +1228,8 @@ func TestViewerShow_LoadsAndNavigates(t *testing.T) { dropAndWait(t, v, first, second, third) - if v.index != 0 { - t.Fatalf("index = %d, want 0 after the initial drop", v.index) + if v.state.index != 0 { + t.Fatalf("index = %d, want 0 after the initial drop", v.state.index) } if v.img.Image == nil { t.Fatal("expected an image to be loaded") @@ -1242,32 +1242,32 @@ func TestViewerShow_LoadsAndNavigates(t *testing.T) { } // Step forward to the second image. - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) - if v.index != 1 { - t.Fatalf("index = %d, want 1 after stepping forward", v.index) + if v.state.index != 1 { + t.Fatalf("index = %d, want 1 after stepping forward", v.state.index) } if b := v.img.Image.Bounds(); b.Dx() != 20 || b.Dy() != 10 { t.Errorf("loaded image size = %dx%d, want 20x10", b.Dx(), b.Dy()) } // Right at the end wraps around to the first image. - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) - if v.index != 0 { - t.Fatalf("index = %d, want wraparound to 0", v.index) + if v.state.index != 0 { + t.Fatalf("index = %d, want wraparound to 0", v.state.index) } // Left from the first image wraps around to the last one. - v.ShowImage(v.index - 1) + v.ShowImage(v.state.index - 1) waitUntilLoaded(t, v) - if v.index != 2 { - t.Fatalf("index = %d, want wraparound to the last index (2)", v.index) + if v.state.index != 2 { + t.Fatalf("index = %d, want wraparound to the last index (2)", v.state.index) } if b := v.img.Image.Bounds(); b.Dx() != 15 || b.Dy() != 25 { t.Errorf("loaded image size = %dx%d, want 15x25", b.Dx(), b.Dy()) @@ -1288,7 +1288,7 @@ func TestHandleDrop_NaturalSortsByDefault(t *testing.T) { dropAndWait(t, v, img10, img1, img2) var got []string - for _, u := range v.files { + for _, u := range v.state.files { got = append(got, u.Name()) } want := []string{"IMG_1.jpg", "IMG_2.jpg", "IMG_10.jpg"} @@ -1333,11 +1333,11 @@ func TestToggleSort_CyclesThroughAllModesAndBackToName(t *testing.T) { natural := []string{"IMG_1.jpg", "IMG_2.jpg", "IMG_10.jpg"} scanOrder := namesOf(dropOrder) // IMG_10.jpg, IMG_1.jpg, IMG_2.jpg - if got := namesOf(v.files); !slices.Equal(got, natural) { + if got := namesOf(v.state.files); !slices.Equal(got, natural) { t.Fatalf("files = %v, want natural-sorted %v before any toggle", got, natural) } - if v.sortMode != filesort.ByName { - t.Fatalf("sortMode = %v, want filesort.ByName before any toggle", v.sortMode) + if v.state.SortMode() != filesort.ByName { + t.Fatalf("sortMode = %v, want filesort.ByName before any toggle", v.state.SortMode()) } // Step onto IMG_2.jpg (index 1 in natural order, i.e. position 2/3) @@ -1345,7 +1345,7 @@ func TestToggleSort_CyclesThroughAllModesAndBackToName(t *testing.T) { // throughout. v.ShowImage(1) waitUntilLoaded(t, v) - if got := v.files[v.index].Name(); got != "IMG_2.jpg" { + if got := v.state.files[v.state.index].Name(); got != "IMG_2.jpg" { t.Fatalf("displayed file = %q, want IMG_2.jpg before cycling", got) } if title := v.win.Title(); !strings.Contains(title, "(2/3)") { @@ -1369,13 +1369,13 @@ func TestToggleSort_CyclesThroughAllModesAndBackToName(t *testing.T) { waitForSort(t, v) waitUntilLoaded(t, v) - if v.sortMode != step.mode { - t.Fatalf("sortMode = %v, want %v", v.sortMode, step.mode) + if v.state.SortMode() != step.mode { + t.Fatalf("sortMode = %v, want %v", v.state.SortMode(), step.mode) } - if got := namesOf(v.files); !slices.Equal(got, scanOrder) { + if got := namesOf(v.state.files); !slices.Equal(got, scanOrder) { t.Errorf("[mode %v] files = %v, want %v", step.mode, got, scanOrder) } - if got := v.files[v.index].Name(); got != "IMG_2.jpg" { + if got := v.state.files[v.state.index].Name(); got != "IMG_2.jpg" { t.Errorf("[mode %v] displayed file = %q, want IMG_2.jpg to stay in view", step.mode, got) } @@ -1393,13 +1393,13 @@ func TestToggleSort_CyclesThroughAllModesAndBackToName(t *testing.T) { waitForSort(t, v) waitUntilLoaded(t, v) - if v.sortMode != filesort.ByName { - t.Fatalf("sortMode = %v, want filesort.ByName after wrapping around", v.sortMode) + if v.state.SortMode() != filesort.ByName { + t.Fatalf("sortMode = %v, want filesort.ByName after wrapping around", v.state.SortMode()) } - if got := namesOf(v.files); !slices.Equal(got, natural) { + if got := namesOf(v.state.files); !slices.Equal(got, natural) { t.Errorf("files = %v, want natural-sorted %v after wrapping around", got, natural) } - if got := v.files[v.index].Name(); got != "IMG_2.jpg" { + if got := v.state.files[v.state.index].Name(); got != "IMG_2.jpg" { t.Errorf("displayed file = %q, want IMG_2.jpg to stay in view", got) } @@ -1423,39 +1423,212 @@ func TestSetSortMode_JumpsDirectlyRatherThanCycling(t *testing.T) { img1 := uitest.TempJPEGURI(t, "IMG_1.jpg", 4, 4, color.White) dropAndWait(t, v, img10, img1) // natural sort: IMG_1.jpg, IMG_10.jpg - current := v.files[v.index].Name() + current := v.state.files[v.state.index].Name() v.SetSortMode(filesort.ByDropOrder) - if v.sortMode != filesort.ByDropOrder { - t.Errorf("sortMode = %v, want ByDropOrder straight after one SetSortMode call", v.sortMode) + if v.state.SortMode() != filesort.ByDropOrder { + t.Errorf("sortMode = %v, want ByDropOrder straight after one SetSortMode call", v.state.SortMode()) } waitForSort(t, v) waitUntilLoaded(t, v) - if got := v.files[v.index].Name(); got != current { + if got := v.state.files[v.state.index].Name(); got != current { t.Errorf("displayed file = %q, want it to stay on %q across the sort-mode change", got, current) } } // TestSetSortMode_SafeWithNoFilesLoaded guards the settings window's own // call site: unlike toggleSort's S key (gated behind handleKeyEvent's -// len(v.files)<2 guard), the settings window can change the sort order +// len(v.state.files)<2 guard), the settings window can change the sort order // before anything has ever been dropped. func TestSetSortMode_SafeWithNoFilesLoaded(t *testing.T) { v := newTestViewer(t) v.SetSortMode(filesort.BySize) - if v.sortMode != filesort.BySize { - t.Errorf("sortMode = %v, want BySize", v.sortMode) + if v.state.SortMode() != filesort.BySize { + t.Errorf("sortMode = %v, want BySize", v.state.SortMode()) } } +func TestViewerFileStateSlicesRemainEquivalentAcrossTransitions(t *testing.T) { + v := newTestViewer(t) + + a := uitest.TempJPEGURI(t, "2.jpg", 4, 4, color.White) + b := uitest.TempJPEGURI(t, "1.jpg", 4, 4, color.White) + c := uitest.TempJPEGURI(t, "3.jpg", 4, 4, color.White) + + dropAndWait(t, v, a, c) + assertEquivalentFileSlices(t, v) + + v.SetSortMode(filesort.ByDropOrder) + waitForSort(t, v) + waitUntilLoaded(t, v) + assertEquivalentFileSlices(t, v) + + v.SetMergeMode(true) + dropAndWait(t, v, b) + assertEquivalentFileSlices(t, v) + + v.RemoveFile(v.state.index) + assertEquivalentFileSlices(t, v) + + v.SetMergeMode(false) + dropAndWait(t, v, a) + assertEquivalentFileSlices(t, v) +} + +func TestViewerIndexStaysValidAcrossFileStateTransitions(t *testing.T) { + v := newTestViewer(t) + + a := uitest.TempJPEGURI(t, "2.jpg", 4, 4, color.White) + b := uitest.TempJPEGURI(t, "1.jpg", 4, 4, color.White) + c := uitest.TempJPEGURI(t, "3.jpg", 4, 4, color.White) + + assertValidFileIndex(t, v) + + dropAndWait(t, v, a, c) + assertValidFileIndex(t, v) + + v.ShowImage(len(v.state.files) - 1) + waitUntilLoaded(t, v) + assertValidFileIndex(t, v) + + v.SetSortMode(filesort.ByDropOrder) + waitForSort(t, v) + waitUntilLoaded(t, v) + assertValidFileIndex(t, v) + + v.SetMergeMode(true) + dropAndWait(t, v, b) + assertValidFileIndex(t, v) + + v.RemoveFile(v.state.index) + assertValidFileIndex(t, v) + + v.SetMergeMode(false) + dropAndWait(t, v, a) + assertValidFileIndex(t, v) + + v.reset() + assertValidFileIndex(t, v) +} + +func TestViewerModesApplyBeforeAndAfterLoadingFiles(t *testing.T) { + v := newTestViewer(t) + + v.SetSortMode(filesort.ByDropOrder) + v.SetMergeMode(true) + + if v.SortMode() != filesort.ByDropOrder || !v.MergeMode() { + t.Fatal("modes set before a drop were not retained") + } + + a := uitest.TempJPEGURI(t, "2.jpg", 4, 4, color.White) + b := uitest.TempJPEGURI(t, "1.jpg", 4, 4, color.White) + dropAndWait(t, v, a) + dropAndWait(t, v, b) + + if got := namesOfURIs(v.state.files); !slices.Equal(got, []string{"2.jpg", "1.jpg"}) { + t.Errorf("files = %v, want merge mode and drop-order mode applied", got) + } + + v.SetSortMode(filesort.ByName) + waitForSort(t, v) + waitUntilLoaded(t, v) + v.SetMergeMode(false) + + if got := namesOfURIs(v.state.files); !slices.Equal(got, []string{"1.jpg", "2.jpg"}) { + t.Errorf("files = %v, want name sort applied after loading", got) + } + if v.MergeMode() { + t.Error("merge mode should be disabled after loading") + } +} + +func TestStaleFileStateCompletionsDoNotOverwriteNewerState(t *testing.T) { + v := newTestViewer(t) + + current := []fyne.URI{ + uitest.FakeURI{FileName: "current.jpg", Ext: ".jpg"}, + } + stale := []fyne.URI{ + uitest.FakeURI{FileName: "stale.jpg", Ext: ".jpg"}, + } + v.state.files = append([]fyne.URI(nil), current...) + v.state.unsortedFiles = append([]fyne.URI(nil), current...) + + staleScanGen := v.gen.Load() + v.gen.Add(1) + scanDone := make(chan struct{}) + v.applyScanResult(staleScanGen, false, stale, stale, false, scanDone) + <-scanDone + assertEquivalentFileSlices(t, v) + if got := namesOfURIs(v.state.files); !slices.Equal(got, []string{"current.jpg"}) { + t.Errorf("files = %v, want newer scan state retained", got) + } + + staleSortGen := v.sortGen.Load() + v.sortGen.Add(1) + v.sorting = true + sortDone := make(chan struct{}) + called := false + v.finishSort(staleSortGen, stale, sortDone, func() {}, func([]fyne.URI) { + called = true + }) + <-sortDone + + if called { + t.Error("stale sort completion should not invoke its state-writing callback") + } + if !v.sorting { + t.Error("stale sort completion should not clear a newer sort's in-flight state") + } + assertEquivalentFileSlices(t, v) + if got := namesOfURIs(v.state.files); !slices.Equal(got, []string{"current.jpg"}) { + t.Errorf("files = %v, want newer sort state retained", got) + } +} + +func assertEquivalentFileSlices(t *testing.T, v *viewer) { + t.Helper() + + files := namesOfURIs(v.state.files) + unsorted := namesOfURIs(v.state.unsortedFiles) + slices.Sort(files) + slices.Sort(unsorted) + if !slices.Equal(files, unsorted) { + t.Errorf("files = %v and unsortedFiles = %v do not contain the same URIs", v.state.files, v.state.unsortedFiles) + } +} + +func assertValidFileIndex(t *testing.T, v *viewer) { + t.Helper() + + if len(v.state.files) == 0 { + if v.state.index != 0 { + t.Errorf("index = %d, want 0 with no files", v.state.index) + } + return + } + if v.state.index < 0 || v.state.index >= len(v.state.files) { + t.Errorf("index = %d, want a value in [0, %d)", v.state.index, len(v.state.files)) + } +} + +func namesOfURIs(files []fyne.URI) []string { + names := make([]string, len(files)) + for i, u := range files { + names[i] = u.Name() + } + return names +} + // TestSetSortMode_SnapshotDoesNotAliasUnsortedFiles is a -race regression // test for the snapshot SetSortMode hands to startSort's goroutine: a plain -// slice-header copy of v.unsortedFiles aliases its backing array, which +// slice-header copy of v.state.unsortedFiles aliases its backing array, which // RemoveFile (a failed-decode retry, a Shift+Delete) then shifts *in place* // on the UI goroutine while filesort.Order is still copying it - an // unsynchronized read/write on the same memory. Nothing here asserts: the @@ -1480,8 +1653,8 @@ func TestSetSortMode_SnapshotDoesNotAliasUnsortedFiles(t *testing.T) { unsorted = append(unsorted, uitest.FakeURI{FileName: fmt.Sprintf("img_%05d.jpg", i), Ext: ".jpg"}) } - v.files = append([]fyne.URI(nil), unsorted...) - v.unsortedFiles = unsorted + v.state.files = append([]fyne.URI(nil), unsorted...) + v.state.unsortedFiles = unsorted v.SetSortMode(filesort.ByModTime) @@ -1495,10 +1668,10 @@ func TestSetSortMode_SnapshotDoesNotAliasUnsortedFiles(t *testing.T) { // TestHandleKeyEvent_EscapeDuringFirstDropReorderDoesNotCloseWindow guards // keys.go's Escape branch: a first-ever drop's scan clears v.scanning back // to false before applyScannedFiles's startSort (drop.go/sort.go) has -// actually populated v.files, so for as long as that reorder is still -// computing, v.files reads exactly like the "nothing left to reset" state +// actually populated v.state.files, so for as long as that reorder is still +// computing, v.state.files reads exactly like the "nothing left to reset" state // Escape otherwise closes the window on. v.sorting is what tells the two -// apart. Drives the in-flight state directly - v.sorting true, v.files +// apart. Drives the in-flight state directly - v.sorting true, v.state.files // still empty, v.sortCancel a harmless stub standing in for the real one // startSort would have paired with it - rather than racing a real drop's // background goroutine to reproduce that window, the same approach @@ -1536,24 +1709,24 @@ func TestHandleKeyEvent_EscapeDuringResortOfExistingFilesDoesNotClearThem(t *tes b := uitest.TempJPEGURI(t, "b.jpg", 4, 4, color.White) dropAndWait(t, v, a, b) - filesBefore := append([]fyne.URI(nil), v.files...) - indexBefore := v.index + filesBefore := append([]fyne.URI(nil), v.state.files...) + indexBefore := v.state.index v.sorting = true v.sortCancel = func() {} v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyEscape}) - if len(v.files) != len(filesBefore) { - t.Fatalf("files = %v, want unchanged %v after cancelling a resort", v.files, filesBefore) + if len(v.state.files) != len(filesBefore) { + t.Fatalf("files = %v, want unchanged %v after cancelling a resort", v.state.files, filesBefore) } - for i, u := range v.files { + for i, u := range v.state.files { if u.String() != filesBefore[i].String() { t.Errorf("files[%d] = %q, want unchanged %q after cancelling a resort", i, u, filesBefore[i]) } } - if v.index != indexBefore { - t.Errorf("index = %d, want unchanged %d after cancelling a resort", v.index, indexBefore) + if v.state.index != indexBefore { + t.Errorf("index = %d, want unchanged %d after cancelling a resort", v.state.index, indexBefore) } if v.img.Image == nil { t.Error("the displayed image should not be cleared by cancelling a resort") @@ -1575,11 +1748,11 @@ func TestViewerReset(t *testing.T) { v.reset() - if v.files != nil { - t.Errorf("files = %v, want nil after reset", v.files) + if v.state.files != nil { + t.Errorf("files = %v, want nil after reset", v.state.files) } - if v.index != 0 { - t.Errorf("index = %d, want 0 after reset", v.index) + if v.state.index != 0 { + t.Errorf("index = %d, want 0 after reset", v.state.index) } if v.img.Image != nil { t.Error("image should be cleared after reset") @@ -1658,23 +1831,23 @@ func TestViewerShow_AutoAdvancesPastBrokenFileDuringNavigation(t *testing.T) { dropAndWait(t, v, first, corrupt, third) - if len(v.files) != 3 { - t.Fatalf("files = %v, want all 3 dropped files kept until navigation actually reaches the broken one", v.files) + if len(v.state.files) != 3 { + t.Fatalf("files = %v, want all 3 dropped files kept until navigation actually reaches the broken one", v.state.files) } // Step onto the broken file. - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) - if len(v.files) != 2 { - t.Fatalf("files = %v, want the broken file dropped from the set", v.files) + if len(v.state.files) != 2 { + t.Fatalf("files = %v, want the broken file dropped from the set", v.state.files) } - for _, u := range v.files { + for _, u := range v.state.files { if u.Name() == "2.jpg" { - t.Errorf("files = %v, the broken file should have been removed", v.files) + t.Errorf("files = %v, the broken file should have been removed", v.state.files) } } - if got := v.files[v.index].Name(); got != "3.jpg" { + if got := v.state.files[v.state.index].Name(); got != "3.jpg" { t.Errorf("displayed file = %q, want auto-advance to land on 3.jpg", got) } if v.img.Image == nil { @@ -1697,8 +1870,8 @@ func TestViewerShow_AutoAdvancesPastBrokenFirstFile(t *testing.T) { dropAndWait(t, v, corrupt, second) - if len(v.files) != 1 || v.files[0].Name() != "2.jpg" { - t.Fatalf("files = %v, want only 2.jpg left after the broken first file was auto-skipped", v.files) + if len(v.state.files) != 1 || v.state.files[0].Name() != "2.jpg" { + t.Fatalf("files = %v, want only 2.jpg left after the broken first file was auto-skipped", v.state.files) } if v.img.Image == nil { t.Fatal("expected the app to auto-advance to the one good image instead of giving up on the first failure") @@ -1720,8 +1893,8 @@ func TestViewerShow_AllFilesBrokenFallsBackToEmptyState(t *testing.T) { dropAndWait(t, v, corrupt1, corrupt2) - if v.files != nil { - t.Errorf("files = %v, want nil once every dropped file has failed to decode", v.files) + if v.state.files != nil { + t.Errorf("files = %v, want nil once every dropped file has failed to decode", v.state.files) } if v.img.Image != nil { t.Error("no image should be displayed once every file has failed") @@ -1855,7 +2028,7 @@ func TestFinishLoad_PreloadsBothNeighbors(t *testing.T) { // TestAttemptLoad_CacheHitServesFileRemovedFromDisk proves a cache hit // really does skip the disk read: b's file is deleted from disk right after // it's preloaded, so a real (non-cached) load of it would fail and trigger -// retryAfterLoadFailure, dropping it from v.files. Navigating to it +// retryAfterLoadFailure, dropping it from v.state.files. Navigating to it // succeeding instead demonstrates the display came from imgCache. func TestAttemptLoad_CacheHitServesFileRemovedFromDisk(t *testing.T) { v := newTestViewer(t) @@ -1876,11 +2049,11 @@ func TestAttemptLoad_CacheHitServesFileRemovedFromDisk(t *testing.T) { v.ShowImage(1) waitUntilLoaded(t, v) - if v.index != 1 { - t.Fatalf("index = %d, want 1 - a cache hit must not fall through to retryAfterLoadFailure", v.index) + if v.state.index != 1 { + t.Fatalf("index = %d, want 1 - a cache hit must not fall through to retryAfterLoadFailure", v.state.index) } - if len(v.files) != 2 { - t.Fatalf("files = %v, want b still present - a cache hit must not treat it as broken", v.files) + if len(v.state.files) != 2 { + t.Fatalf("files = %v, want b still present - a cache hit must not treat it as broken", v.state.files) } } @@ -1889,8 +2062,8 @@ func TestRemoveFile_PurgesCacheEntry(t *testing.T) { a := uitest.TempJPEGURI(t, "a.jpg", 4, 4, color.White) b := uitest.TempJPEGURI(t, "b.jpg", 4, 4, color.White) - v.files = []fyne.URI{a, b} - v.unsortedFiles = []fyne.URI{a, b} + v.state.files = []fyne.URI{a, b} + v.state.unsortedFiles = []fyne.URI{a, b} v.imgCache.Add(a.String(), &imaging.LoadedImage{Frames: []image.Image{image.NewRGBA(image.Rect(0, 0, 1, 1))}}) v.RemoveFile(0) @@ -1972,8 +2145,8 @@ func TestAttemptLoad_ReportsAFileTooLargeToOpen(t *testing.T) { if v.img.Image != nil { t.Error("no image should be loaded after a file is refused for its size") } - if len(v.files) != 0 { - t.Errorf("files = %v, want the refused file dropped from the set", v.files) + if len(v.state.files) != 0 { + t.Errorf("files = %v, want the refused file dropped from the set", v.state.files) } if !v.toast.card.Visible() { t.Fatal("expected a toast after a file was refused for its size") @@ -2010,8 +2183,8 @@ func TestAttemptLoad_ToastsAndFallsBackToAStaticFrameForAnOversizedAnimation(t * if v.animStop != nil { t.Error("animStop is armed, want no animation goroutine for a refused animation") } - if len(v.files) != 1 { - t.Errorf("files = %v, want the file kept - it is valid, just too big to animate", v.files) + if len(v.state.files) != 1 { + t.Errorf("files = %v, want the file kept - it is valid, just too big to animate", v.state.files) } if !v.toast.card.Visible() { t.Fatal("expected a toast explaining why the animation isn't playing") diff --git a/internal/ui/load.go b/internal/ui/load.go index 3e31816..d2f53fc 100644 --- a/internal/ui/load.go +++ b/internal/ui/load.go @@ -19,9 +19,9 @@ import ( // ShowImage loads and displays the file at index i, wrapping around at // both ends. A file that fails to decode is dropped from the set and the // next one is tried automatically - see attemptLoad - so a bad file never -// gets stuck on screen or left inconsistent with v.index. +// gets stuck on screen or left inconsistent with v.state.index. func (v *viewer) ShowImage(i int) { - if len(v.files) == 0 { + if len(v.state.files) == 0 { return } @@ -93,7 +93,7 @@ func (v *viewer) invalidateLoad() uint64 { return gen } -// attemptLoad decodes and displays v.files[i] (wrapped into range), sharing +// attemptLoad decodes and displays v.state.files[i] (wrapped into range), sharing // gen, done, and ctx with the rest of its retry chain - see ShowImage's // comment. It first reads the file and probes just its header // (imaging.ReadAndProbe), which is enough to reject an invalid file @@ -104,10 +104,10 @@ func (v *viewer) invalidateLoad() uint64 { // to be the next file (or wraps around to the first, if i was the last); // once nothing is left it falls back to the empty-state error screen. func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan struct{}) { - n := len(v.files) + n := len(v.state.files) i = ((i % n) + n) % n - v.index = i - u := v.files[i] + v.state.index = i + u := v.state.files[i] // A cache hit - either a file already viewed this session, or one // preloadNeighbors decoded speculatively ahead of time - skips the disk @@ -199,7 +199,7 @@ func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan s } // finishLoad displays loaded - already decoded, either just now or earlier -// and pulled from imgCache - as v.files[i], updates the window title/size +// and pulled from imgCache - as v.state.files[i], updates the window title/size // and animation state, kicks off speculative preloading of its neighbors, // and closes done last. Shared by attemptLoad's disk-decode path (called // from inside its completion fyne.Do, which - like every fyne.Do callback @@ -284,8 +284,8 @@ func (v *viewer) finishLoad(ctx context.Context, _ int, u fyne.URI, loaded *imag } v.slides.SetAnimDuration(animDuration) - if n := len(v.files); n > 1 { - title = fmt.Sprintf("%s (%d/%d)", title, v.index+1, n) + if n := len(v.state.files); n > 1 { + title = fmt.Sprintf("%s (%d/%d)", title, v.state.index+1, n) } v.setTitle(title) @@ -311,13 +311,13 @@ func (v *viewer) finishLoad(ctx context.Context, _ int, u fyne.URI, loaded *imag go v.animate(gen, loaded.Frames, loaded.Delays, stop, stopped) } - // Must run - and finish reading v.files/v.index - before done closes + // Must run - and finish reading v.state.files/v.state.index - before done closes // below: done's close is what a waiter (a test's waitUntilLoaded, or a // future navigation) synchronizes on to know this call is finished // touching viewer state. Under the fyne test driver, this whole // function already runs on whatever goroutine called fyne.Do rather // than a dedicated UI goroutine (see attemptLoad's comment on gen), so - // closing done first would let a waiter go on to mutate v.files - via + // closing done first would let a waiter go on to mutate v.state.files - via // reset() or a fresh drop - concurrently with this read. v.preloadNeighbors(ctx, gen) @@ -325,26 +325,26 @@ func (v *viewer) finishLoad(ctx context.Context, _ int, u fyne.URI, loaded *imag } // preloadNeighbors speculatively decodes the files immediately before and -// after v.index in the background, so stepping to either one next is a +// after v.state.index in the background, so stepping to either one next is a // cache hit instead of a fresh disk read + decode. Always called from // finishLoad before done closes - see its comment - so reading -// v.files/v.index here can't race a waiter that's about to mutate them. +// v.state.files/v.state.index here can't race a waiter that's about to mutate them. // ctx is the same one ShowImage created for this generation - the // preloads it starts belong to the generation that's now on screen, so // they get cancelled alongside its own decode the moment a newer // navigation or drop supersedes it (see invalidateLoad). func (v *viewer) preloadNeighbors(ctx context.Context, gen uint64) { - n := len(v.files) + n := len(v.state.files) if n < 2 { return } - next := ((v.index+1)%n + n) % n - prev := ((v.index-1)%n + n) % n + next := ((v.state.index+1)%n + n) % n + prev := ((v.state.index-1)%n + n) % n - v.preloadOne(ctx, v.files[next], gen) + v.preloadOne(ctx, v.state.files[next], gen) if prev != next { - v.preloadOne(ctx, v.files[prev], gen) + v.preloadOne(ctx, v.state.files[prev], gen) } } @@ -444,7 +444,7 @@ func (v *viewer) stopAnimation() { } } -// retryAfterLoadFailure reports msg, drops v.files[i], and either continues +// retryAfterLoadFailure reports msg, drops v.state.files[i], and either continues // the retry chain via attemptLoad or, if that emptied the set, falls back // to the empty-state error screen and finalizes done. See show/attemptLoad // for why gen, done, and ctx are threaded through unchanged rather than @@ -452,7 +452,7 @@ func (v *viewer) stopAnimation() { func (v *viewer) retryAfterLoadFailure(ctx context.Context, msg string, i int, gen uint64, done chan struct{}) { v.RemoveFile(i) - if len(v.files) == 0 { + if len(v.state.files) == 0 { v.ShowEmptyStateError(msg) close(done) return diff --git a/internal/ui/menu_test.go b/internal/ui/menu_test.go index 635b1d8..cef4cb9 100644 --- a/internal/ui/menu_test.go +++ b/internal/ui/menu_test.go @@ -110,8 +110,8 @@ func TestBuildMainMenu_CloseFilesItemResetsToWelcomeState(t *testing.T) { menu := buildMainMenu(v) menu.Items[0].Items[5].Action() - if v.files != nil { - t.Errorf("files = %v, want nil after the Close Files action", v.files) + if v.state.files != nil { + t.Errorf("files = %v, want nil after the Close Files action", v.state.files) } if !v.welcomeArt.Visible() { t.Error("expected the welcome drop zone back after the Close Files action") @@ -147,8 +147,8 @@ func TestFavoritesMenuItemOpensStoredFilesThroughViewer(t *testing.T) { waitForSort(t, v) waitUntilLoaded(t, v) - if len(v.files) != 1 || v.files[0].Path() != image.Path() { - t.Errorf("files = %v, want favorite image %q", v.files, image.Path()) + 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()) } } @@ -185,8 +185,8 @@ func TestFavoriteShortcutOpensStoredFilesThroughViewer(t *testing.T) { waitForSort(t, v) waitUntilLoaded(t, v) - if len(v.files) != 1 || v.files[0].Path() != image.Path() { - t.Errorf("files = %v, want shortcut favorite %q", v.files, image.Path()) + if len(v.state.files) != 1 || v.state.files[0].Path() != image.Path() { + t.Errorf("files = %v, want shortcut favorite %q", v.state.files, image.Path()) } } @@ -243,8 +243,8 @@ func TestCloseFiles_ResetsLoadedFilesToWelcomeState(t *testing.T) { v.closeFiles() - if v.files != nil { - t.Errorf("files = %v, want nil after closeFiles", v.files) + if v.state.files != nil { + t.Errorf("files = %v, want nil after closeFiles", v.state.files) } if !v.welcomeArt.Visible() || !v.dropzone.Visible() { t.Error("expected the welcome drop zone back after closeFiles") diff --git a/internal/ui/preferences_wiring_test.go b/internal/ui/preferences_wiring_test.go index c9e0024..243a15d 100644 --- a/internal/ui/preferences_wiring_test.go +++ b/internal/ui/preferences_wiring_test.go @@ -38,10 +38,10 @@ func TestBuildViewer_LoadsSavedPreferences(t *testing.T) { defer win.Close() t.Cleanup(func() { imaging.SetMaxEncodedBytes(0) }) // process-wide - see memlimits.go - if v.sortMode != filesort.BySize { - t.Errorf("sortMode = %v, want filesort.BySize (from saved preferences)", v.sortMode) + if v.state.SortMode() != filesort.BySize { + t.Errorf("sortMode = %v, want filesort.BySize (from saved preferences)", v.state.SortMode()) } - if !v.mergeMode { + if !v.state.MergeMode() { t.Error("mergeMode = false, want true (from saved preferences)") } if got, want := v.slides.Interval(), 7*time.Second; got != want { @@ -142,10 +142,10 @@ func TestBuildViewer_NoSavedPreferencesUsesShippedDefaults(t *testing.T) { v, win := buildViewer(application) defer win.Close() - if v.sortMode != filesort.ByName { - t.Errorf("sortMode = %v, want filesort.ByName (the shipped default)", v.sortMode) + if v.state.SortMode() != filesort.ByName { + t.Errorf("sortMode = %v, want filesort.ByName (the shipped default)", v.state.SortMode()) } - if v.mergeMode { + if v.state.MergeMode() { t.Error("mergeMode = true, want false (the shipped default)") } if got := v.slides.Interval(); got != 0 { diff --git a/internal/ui/run.go b/internal/ui/run.go index 450a808..7ebbf8c 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -64,7 +64,7 @@ func Run(application fyne.App, initial []fyne.URI) { view.exif.StopTracking() close(view.vectorStop) - session.Save(application, view.unsortedFiles) + session.Save(application, view.state.unsortedFiles) preferences.Save(application, view.currentPreferences()) }) @@ -88,8 +88,8 @@ func (v *viewer) currentPreferences() preferences.State { posX, posY, posSet := v.winPos.Get() return preferences.State{ - SortMode: v.sortMode.PrefValue(), - MergeMode: v.mergeMode, + SortMode: v.state.SortMode().PrefValue(), + MergeMode: v.state.MergeMode(), SlideInterval: v.slides.Interval(), SlideShuffle: v.slides.Shuffle(), MaxScanFiles: v.maxScan, diff --git a/internal/ui/save.go b/internal/ui/save.go index 026a23c..158341c 100644 --- a/internal/ui/save.go +++ b/internal/ui/save.go @@ -18,7 +18,7 @@ import ( // saveRotation so the item is never offered for an action guaranteed to // fail or do nothing. // -// - !v.loading.Load(): attemptLoad sets v.index to the file being +// - !v.loading.Load(): attemptLoad sets v.state.index to the file being // navigated to before that file's pixels have finished decoding, so // mid-load, CurrentFile() already names the new file while // v.displayFrames/v.img.Image still hold the old one's - saving then diff --git a/internal/ui/save_test.go b/internal/ui/save_test.go index b99c5aa..33965ac 100644 --- a/internal/ui/save_test.go +++ b/internal/ui/save_test.go @@ -103,7 +103,7 @@ func TestCanSaveRotation_FalseWhileLoading(t *testing.T) { t.Cleanup(func() { v.loading.Store(false) }) if v.canSaveRotation() { - t.Error("canSaveRotation should be false while a load is in flight - v.index may already point at a file whose pixels haven't finished decoding") + t.Error("canSaveRotation should be false while a load is in flight - v.state.index may already point at a file whose pixels haven't finished decoding") } } diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go index 104a690..1dd810b 100644 --- a/internal/ui/session_test.go +++ b/internal/ui/session_test.go @@ -74,8 +74,8 @@ func TestRestoreSession_LoadsSavedFilesAndHidesLink(t *testing.T) { waitForSort(t, v) waitUntilLoaded(t, v) - if len(v.files) != 2 { - t.Fatalf("len(v.files) = %d, want 2", len(v.files)) + if len(v.state.files) != 2 { + t.Fatalf("len(v.state.files) = %d, want 2", len(v.state.files)) } if v.restoreLink.Visible() { t.Error("restoreLink should hide once the saved session has been restored") diff --git a/internal/ui/slideshow_test.go b/internal/ui/slideshow_test.go index ca49007..dc67427 100644 --- a/internal/ui/slideshow_test.go +++ b/internal/ui/slideshow_test.go @@ -47,8 +47,8 @@ func TestTogglePictureFrameMode_EntersAndExitsFullScreen(t *testing.T) { } // Exiting must not touch the loaded set. - if len(v.files) != 2 { - t.Errorf("files = %d, want 2 to remain loaded after leaving picture-frame mode", len(v.files)) + if len(v.state.files) != 2 { + t.Errorf("files = %d, want 2 to remain loaded after leaving picture-frame mode", len(v.state.files)) } } @@ -87,14 +87,14 @@ func TestHandleKeyEvent_EscapeLeavesPictureFrameModeWithoutResetting(t *testing. if v.win.FullScreen() { t.Error("Escape should leave full-screen") } - if len(v.files) != 2 { - t.Errorf("files = %d, want the loaded set untouched by Escape while in picture-frame mode", len(v.files)) + if len(v.state.files) != 2 { + t.Errorf("files = %d, want the loaded set untouched by Escape while in picture-frame mode", len(v.state.files)) } // A second Escape, now that picture-frame mode is off, falls through to // the usual reset behavior. v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyEscape}) - if v.files != nil { + if v.state.files != nil { t.Error("a second Escape should reset the session, same as usual") } } @@ -108,13 +108,13 @@ func TestHandleKeyEvent_UpDownAdjustIntervalInsteadOfNavigating(t *testing.T) { v.togglePictureFrameMode() t.Cleanup(func() { settleSlideshow(t, v) }) - startIndex := v.index + startIndex := v.state.index v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyUp}) if want := slideshow.DefaultInterval + time.Second; v.slides.Interval() != want { t.Errorf("interval after Up = %v, want %v", v.slides.Interval(), want) } - if v.index != startIndex { + if v.state.index != startIndex { t.Error("Up should not navigate while in picture-frame mode") } @@ -123,7 +123,7 @@ func TestHandleKeyEvent_UpDownAdjustIntervalInsteadOfNavigating(t *testing.T) { if want := slideshow.DefaultInterval - time.Second; v.slides.Interval() != want { t.Errorf("interval after Up then two Downs = %v, want %v", v.slides.Interval(), want) } - if v.index != startIndex { + if v.state.index != startIndex { t.Error("Down should not navigate while in picture-frame mode") } } @@ -141,8 +141,8 @@ func TestHandleKeyEvent_UpDownNavigateOutsidePictureFrameMode(t *testing.T) { v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyDown}) waitUntilLoaded(t, v) - if v.index != 1 { - t.Errorf("index = %d, want 1 after Down outside picture-frame mode", v.index) + if v.state.index != 1 { + t.Errorf("index = %d, want 1 after Down outside picture-frame mode", v.state.index) } if v.slides.Interval() != 0 { t.Errorf("interval = %v, want it untouched by a navigation key", v.slides.Interval()) @@ -162,8 +162,8 @@ func TestHandleKeyEvent_LeftRightStillNavigateInPictureFrameMode(t *testing.T) { v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyRight}) waitUntilLoaded(t, v) - if v.index != 1 { - t.Errorf("index = %d, want 1 after Right in picture-frame mode", v.index) + if v.state.index != 1 { + t.Errorf("index = %d, want 1 after Right in picture-frame mode", v.state.index) } } @@ -198,15 +198,15 @@ func TestAdvance_WrapsAroundAtTheEnd(t *testing.T) { v.Advance() waitUntilLoaded(t, v) - if v.index != 1 { - t.Fatalf("index = %d, want 1 after the first Advance", v.index) + if v.state.index != 1 { + t.Fatalf("index = %d, want 1 after the first Advance", v.state.index) } // A slideshow left running has to loop rather than stop at the end. v.Advance() waitUntilLoaded(t, v) - if v.index != 0 { - t.Errorf("index = %d, want 0 - Advance past the last file wraps around", v.index) + if v.state.index != 0 { + t.Errorf("index = %d, want 0 - Advance past the last file wraps around", v.state.index) } } @@ -226,7 +226,7 @@ func TestShow_TracksAnimatedGIFLoopDuration(t *testing.T) { t.Errorf("AnimDuration after loading the gif = %v, want %v", got, want) } - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) if got := v.slides.AnimDuration(); got != 0 { @@ -301,15 +301,15 @@ func TestAdvance_ShuffleOnNeverRepeatsCurrentIndex(t *testing.T) { v.slides.SetShuffle(true) for i := range 20 { - before := v.index + before := v.state.index v.Advance() waitUntilLoaded(t, v) - if v.index == before { + if v.state.index == before { t.Fatalf("iteration %d: index stayed at %d after Advance with shuffle on", i, before) } - if v.index < 0 || v.index >= len(v.files) { - t.Fatalf("iteration %d: index = %d out of range", i, v.index) + if v.state.index < 0 || v.state.index >= len(v.state.files) { + t.Fatalf("iteration %d: index = %d out of range", i, v.state.index) } } } @@ -433,7 +433,7 @@ func TestShowImage_InPictureFrameModeEndsFullyOpaque(t *testing.T) { v.togglePictureFrameMode() t.Cleanup(func() { settleSlideshow(t, v) }) - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) if v.img.Translucency != 0 { diff --git a/internal/ui/sort.go b/internal/ui/sort.go index dd98171..acf1280 100644 --- a/internal/ui/sort.go +++ b/internal/ui/sort.go @@ -9,48 +9,48 @@ import ( "github.com/frathe/picfetch/internal/filesort" ) -// toggleSort is the S key: it cycles v.sortMode to the next mode - see +// toggleSort is the S key: it cycles the state sort mode to the next mode - see // SetSortMode below, which does the actual work. func (v *viewer) toggleSort() { - v.SetSortMode(v.sortMode.Next()) + v.SetSortMode(v.state.SortMode().Next()) } // SetSortMode sets the sort order directly - the settings window's binding -// for the cycle above. Re-derives v.files from v.unsortedFiles under the +// for the cycle above. Re-derives v.state.files from v.state.unsortedFiles under the // new mode in the background (see filesort.Order's own doc comment: the // capture-date/modified/size modes each stat or Exif-read every file, which // visibly pauses a large recursive folder scan if done inline on the UI // goroutine), keeping whichever file is currently on screen in view across // the switch instead of jumping to wherever position 0 lands. Safe to call // before any files are ever loaded, unlike toggleSort's own S-key call -// site, which is gated behind handleKeyEvent's len(v.files)<2 guard. +// site, which is gated behind handleKeyEvent's len(v.state.files)<2 guard. func (v *viewer) SetSortMode(m filesort.Mode) { - if len(v.files) == 0 { - v.sortMode = m + if len(v.state.files) == 0 { + v.state.SetSortMode(m) v.applyTitle() return } - current := v.files[v.index] + current := v.state.files[v.state.index] - // Defensively copied rather than aliased: v.unsortedFiles's backing + // Defensively copied rather than aliased: v.state.unsortedFiles's backing // array can be mutated in place by RemoveFile (a failed-decode retry // dropping a file, or a Shift+Delete) while this snapshot is still // being read by startSort's background goroutine - a concurrent // read/write on the same backing array that filesort.Order's own copy // of its argument doesn't protect against, since that copy only happens // after this handoff. - unsorted := append([]fyne.URI(nil), v.unsortedFiles...) + unsorted := append([]fyne.URI(nil), v.state.unsortedFiles...) // The title's sort-mode prefix updates immediately, even before the // reorder itself finishes - there's no reason to make the user wait for // a large sort just to see that their choice registered. - v.sortMode = m + v.state.SetSortMode(m) v.applyTitle() v.startSort(m, unsorted, func(ordered []fyne.URI) { - v.files = ordered + v.state.files = ordered v.ForceRepaint() v.showFileIfPresent(current) }) @@ -129,7 +129,7 @@ func (v *viewer) finishSort(gen uint64, ordered []fyne.URI, sortDone chan struct // Superseded either by a newer sort (another startSort call bumped // sortGen again) or by something else that changed - // v.files/v.unsortedFiles while this one was still computing + // v.state.files/v.state.unsortedFiles while this one was still computing // (Shift+Delete, or Escape/File>Close - see those call sites' own // invalidateSort call). Applying ordered in either case would silently // clobber newer state, so just drop it. @@ -156,7 +156,7 @@ func (v *viewer) finishSort(gen uint64, ordered []fyne.URI, sortDone chan struct // stat/Exif loop notice and stop promptly instead of running to completion // in the background for a result nobody will see. // -// Unlike cancelScan, there's nothing to put back: v.files/v.unsortedFiles +// Unlike cancelScan, there's nothing to put back: v.state.files/v.state.unsortedFiles // are never touched until a reorder's own onDone callback runs (see // applyScannedFiles's and SetSortMode's own comments on why the pairing is // atomic), so cancelling before that lands leaves them exactly as they @@ -173,7 +173,7 @@ func (v *viewer) cancelSort() { v.sortSpinner.Hide() v.sortLabel.Hide() - if len(v.files) == 0 { + if len(v.state.files) == 0 { v.showWelcomeState() v.dropzone.Show() } @@ -184,5 +184,5 @@ func (v *viewer) cancelSort() { // SortMode reports the current sort order - the settings window's getter. func (v *viewer) SortMode() filesort.Mode { - return v.sortMode + return v.state.SortMode() } diff --git a/internal/ui/viewer.go b/internal/ui/viewer.go index ce04ad3..220b079 100644 --- a/internal/ui/viewer.go +++ b/internal/ui/viewer.go @@ -112,8 +112,7 @@ type viewer struct { // is nothing to restore. savedSession []fyne.URI - files []fyne.URI - index int + state appState // gen is the load generation: it guards against out-of-order async // loads. It's an atomic rather than a plain uint64 because animate's @@ -137,20 +136,9 @@ type viewer struct { // called. loadCancel context.CancelFunc - // unsortedFiles is the raw scan/drop order, kept alongside files so the - // S key can cycle back to it without rescanning. sortMode picks which - // ordering files currently holds (see sort.go); it persists across - // drops instead of resetting, since it's a standing display preference. - unsortedFiles []fyne.URI - sortMode filesort.Mode - - // mergeMode is a standing preference, toggled by M, that makes - // handleDrop merge newly dropped files into the existing set instead - // of replacing it; it defaults to false (replace) and persists across - // drops like sortMode. baseTitle is the window title without the - // "[merge] " prefix applyTitle adds while mergeMode is on, so toggling - // M can refresh the title immediately without recomputing it. - mergeMode bool + // baseTitle is the window title without the "[merge] " prefix applyTitle + // adds while merge mode is on, so toggling M can refresh the title + // immediately without recomputing it. baseTitle string // loading is true while a decode/render is in flight. The key handler @@ -212,7 +200,7 @@ type viewer struct { // discarded. Used for two things: gating cancelSort (nothing to cancel // if nothing's in flight) and handleKeyEvent's Escape case (keys.go) - // a first-ever drop clears v.scanning before startSort has actually - // populated v.files, so without this Escape would see len(v.files) == + // populated v.state.files, so without this Escape would see len(v.state.files) == // 0 and quit the window instead of cancelling the still-computing // reorder. sorting bool @@ -235,7 +223,7 @@ type viewer struct { // own counter instead, the same way toast.gen (toast.go) does. Bumped // by invalidateSort (sort.go) - every startSort call (a sort-mode // change or a drop landing, which supersedes whatever it might still be - // computing) and everything else that reassigns v.files/v.unsortedFiles + // computing) and everything else that reassigns v.state.files/v.state.unsortedFiles // while a sort could be in flight (Escape, RemoveFile, clearToDropzone) // - so a stale sort result can never clobber newer state. sortGen atomic.Uint64 @@ -490,13 +478,13 @@ func (v *viewer) setTitle(base string) { // drop. func (v *viewer) applyTitle() { title := v.baseTitle - if v.mergeMode { + if v.state.MergeMode() { title = lang.L("[merge]") + " " + title } if v.slides.Shuffle() { title = lang.L("[shuffle]") + " " + title } - if p := filesort.Label(v.sortMode); p != "" { + if p := filesort.Label(v.state.SortMode()); p != "" { title = p + " " + title } v.win.SetTitle(title) @@ -516,9 +504,7 @@ func (v *viewer) clearToDropzone() { v.stopAnimation() v.invalidateSort() // cancel a sort still in flight - see sortGen's field comment - v.files = nil - v.unsortedFiles = nil - v.index = 0 + v.state.clearFiles() // Purged, not left to age out: with no files open, every decode the // cache holds is of something unreachable, so keeping them just spends @@ -568,29 +554,29 @@ func (v *viewer) undoGridMaximize() { // instead of replacing it - see SetMergeMode below, which does the actual // work. func (v *viewer) toggleMergeMode() { - v.SetMergeMode(!v.mergeMode) + v.SetMergeMode(!v.state.MergeMode()) } // SetMergeMode sets merge mode directly - the settings window's binding for // the toggle above - and immediately reflects it in the window title via // the "[merge] " prefix so it doesn't wait for a drop to become visible. func (v *viewer) SetMergeMode(on bool) { - v.mergeMode = on + v.state.SetMergeMode(on) v.applyTitle() } // MergeMode reports whether merge mode is on - the settings window's // getter. func (v *viewer) MergeMode() bool { - return v.mergeMode + return v.state.MergeMode() } -// showFileIfPresent looks up target in v.files by URI identity and shows it +// showFileIfPresent looks up target in v.state.files by URI identity and shows it // if found, reporting whether it was. Used to keep the same file in view // across an operation - a sort toggle or a merge - that reorders or extends -// v.files without changing what's currently on screen. +// v.state.files without changing what's currently on screen. func (v *viewer) showFileIfPresent(target fyne.URI) bool { - for i, u := range v.files { + for i, u := range v.state.files { if u.String() == target.String() { v.ShowImage(i) return true @@ -664,16 +650,16 @@ func (v *viewer) ShowEmptyStateError(msg string) { // CurrentFile returns the file currently displayed and its index, or // ok=false when nothing is loaded. func (v *viewer) CurrentFile() (u fyne.URI, index int, ok bool) { - if len(v.files) == 0 { + if len(v.state.files) == 0 { return nil, 0, false } - return v.files[v.index], v.index, true + return v.state.files[v.state.index], v.state.index, true } // displayedFile is CurrentFile narrowed to what the EXIF panel needs: a // file that is not merely selected but actually decoded and on screen. -// The distinction matters during a failed or in-flight load, when v.files +// The distinction matters during a failed or in-flight load, when v.state.files // is non-empty but there is no image to describe. func (v *viewer) displayedFile() (fyne.URI, bool) { if v.img.Image == nil { @@ -685,9 +671,9 @@ func (v *viewer) displayedFile() (fyne.URI, bool) { return u, ok } -// RemoveFile drops the file at v.files[i] from both v.files and -// v.unsortedFiles, keeping them in sync so a later sort toggle doesn't -// resurrect a file that failed to load. v.files is trimmed by index rather +// RemoveFile drops the file at v.state.files[i] from both v.state.files and +// v.state.unsortedFiles, keeping them in sync so a later sort toggle doesn't +// resurrect a file that failed to load. v.state.files is trimmed by index rather // than by URI match, since merge mode allows dropping the same file twice // and a match would risk removing the wrong duplicate; unsortedFiles has // no equivalent index to use, but any matching duplicate there is an @@ -695,24 +681,8 @@ func (v *viewer) displayedFile() (fyne.URI, bool) { func (v *viewer) RemoveFile(i int) { v.invalidateSort() // cancel a sort still in flight - see sortGen's field comment - target := v.files[i] - v.files = append(v.files[:i], v.files[i+1:]...) + target := v.state.removeFile(i) v.imgCache.Remove(target.String()) - - // Callers always remove the file currently at v.index, so once it's - // gone v.index may point past the new end (e.g. deleting the last - // image) - clamp it back onto the shrunk slice, same as attemptLoad's - // wraparound does for the retry path. - if v.index >= len(v.files) { - v.index = len(v.files) - 1 - } - - for j, u := range v.unsortedFiles { - if u.String() == target.String() { - v.unsortedFiles = append(v.unsortedFiles[:j], v.unsortedFiles[j+1:]...) - break - } - } } // RemoveFiles drops every named index in one pass - what internal/ui/deletion @@ -732,7 +702,7 @@ func (v *viewer) RemoveFile(i int) { func (v *viewer) RemoveFiles(indices []int) { prev := -1 for _, i := range slices.Backward(slices.Sorted(slices.Values(indices))) { - if i == prev || i < 0 || i >= len(v.files) { + if i == prev || i < 0 || i >= len(v.state.files) { continue } prev = i @@ -741,7 +711,7 @@ func (v *viewer) RemoveFiles(indices []int) { } v.grid.FilesChanged() - if len(v.files) == 0 { + if len(v.state.files) == 0 { v.grid.Close() } } @@ -767,12 +737,12 @@ func (v *viewer) Modifiers() fyne.KeyModifier { // FileCount is how many files are currently loaded. func (v *viewer) FileCount() int { - return len(v.files) + return len(v.state.files) } // FileAt returns the file at index i. func (v *viewer) FileAt(i int) fyne.URI { - return v.files[i] + return v.state.files[i] } // OpenFiles sends a file list through the same scan, merge, sort, and display @@ -783,7 +753,7 @@ func (v *viewer) OpenFiles(files []fyne.URI) { // CurrentIndex is the index of the file on screen. func (v *viewer) CurrentIndex() int { - return v.index + return v.state.index } // Generation is the current load generation - see the gen field. @@ -806,8 +776,8 @@ func (v *viewer) Unfocus() { // navigation applies here too. func (v *viewer) Advance() { if v.slides.Shuffle() { - v.ShowImage(randomOtherIndex(len(v.files), v.index)) + v.ShowImage(randomOtherIndex(len(v.state.files), v.state.index)) return } - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) } diff --git a/internal/ui/zoom_test.go b/internal/ui/zoom_test.go index 589c7f4..4a1d49c 100644 --- a/internal/ui/zoom_test.go +++ b/internal/ui/zoom_test.go @@ -89,7 +89,7 @@ func TestShow_ResetsZoomOnNavigation(t *testing.T) { t.Fatal("setup: expected zoom to be off before navigating") } - v.ShowImage(v.index + 1) + v.ShowImage(v.state.index + 1) waitUntilLoaded(t, v) if !v.zoom.Fitting() { diff --git a/planed_refactoring/01_app_state_controller.md b/planed_refactoring/01_DONE_app_state_controller.md similarity index 51% rename from planed_refactoring/01_app_state_controller.md rename to planed_refactoring/01_DONE_app_state_controller.md index 639fff6..eecdb4e 100644 --- a/planed_refactoring/01_app_state_controller.md +++ b/planed_refactoring/01_DONE_app_state_controller.md @@ -1,4 +1,4 @@ -# Refactoring Plan 1: Extract an App-State Controller +# (DONE) Refactoring Plan 1: Extract an App-State Controller ## Objective Separate the root application state from the concrete Fyne widgets so the app has a clear controller layer instead of one large `viewer` object owning both UI and domain state. @@ -6,22 +6,29 @@ Separate the root application state from the concrete Fyne widgets so the app ha ## Why this is first `internal/ui/viewer.go` still owns too many responsibilities at once: the current file set, index, loading state, merge/sort preference state, window geometry, menu state, cached image references, and feature interactions. That makes it difficult to reason about lifecycle and makes new features more fragile. -## Target design -- Keep Fyne widgets as a thin presentation layer. -- Introduce a dedicated app-state/controller that owns: - - loaded file list and current index - - merge/sort/display mode state - - load generation and cancellation state - - window geometry and restore/session state - - menu enablement and visibility decisions -- Let feature code talk to a narrow interface rather than reaching directly into the entire viewer object. +## Accepted boundary +The working implementation establishes an unexported, package-local `appState` in `internal/ui`, not an exported +application-wide controller. It owns the loaded/displayed file lists, current index, sort mode, and merge mode. +`viewer` remains its façade and orchestration hub: it translates events into model changes and renders their effects +through the Fyne widgets. + +This boundary intentionally excludes asynchronous scan/load/sort lifecycle (including generation and cancellation), +window geometry/session wiring, menu enablement/visibility, image/display cache state, and rendering. Those concerns +remain on `viewer` because they coordinate Fyne widgets or asynchronous work rather than describing the current file +model. Native file-picker and save-dialog glue is also out of scope; it remains in the existing `viewer` wrappers over +`internal/filepicker`. + +Feature packages continue to declare their own narrow consumer-side `Host` interfaces (and `exifwin` its one callback). +No broad `Controller` interface is introduced, and `appState` is never handed to feature packages. ## Specific steps 1. Identify the true ownership boundary for the “viewer state” versus UI-only widgets. 2. Extract a small state struct for the app’s current model. 3. Move file/index behavior and mode toggles out of `viewer` and into the controller. 4. Keep `viewer` as a composition hub that renders the state and forwards events. -5. Narrow the Host interfaces for `grid`, `settingswin`, `exifwin`, favorites, slideshow, and others. +5. [x] Verify the existing narrow Host interfaces for `grid`, `settingswin`, `exifwin`, favorites, slideshow, and + deletion remain the accepted consumer boundaries; no broad controller interface is required by the completed + package-local state extraction. 6. Ensure menu enablement and other derived UI state come from controller state instead of ad hoc viewer fields. ## Risks to watch From 6b0c5824f908d51361b12f99a941ef64ef30bcbd Mon Sep 17 00:00:00 2001 From: frathe Date: Wed, 19 Aug 2026 11:02:17 +0200 Subject: [PATCH 2/6] Refactoring 02: Consolidate Async Lifecycle Management --- ARCHITECTURE.md | 28 +-- FyneApp.toml | 2 +- internal/ui/build.go | 1 - internal/ui/drop.go | 38 ++-- internal/ui/e2e_test.go | 2 +- internal/ui/grid/grid.go | 7 +- internal/ui/library_test.go | 191 ++++++++++++------ internal/ui/lifecycle.go | 99 +++++++++ internal/ui/lifecycle_test.go | 106 ++++++++++ internal/ui/load.go | 150 ++++++-------- internal/ui/menu_test.go | 2 +- internal/ui/run.go | 5 +- internal/ui/sort.go | 62 +++--- internal/ui/state.go | 68 +++++++ internal/ui/state_test.go | 98 +++++++++ internal/ui/vector.go | 15 +- internal/ui/vector_test.go | 25 ++- internal/ui/viewer.go | 111 ++++------ .../02_DONE_async_lifecycle_orchestration.md | 114 +++++++++++ .../02_async_lifecycle_orchestration.md | 36 ---- 20 files changed, 799 insertions(+), 361 deletions(-) create mode 100644 internal/ui/lifecycle.go create mode 100644 internal/ui/lifecycle_test.go create mode 100644 internal/ui/state.go create mode 100644 internal/ui/state_test.go create mode 100644 planed_refactoring/02_DONE_async_lifecycle_orchestration.md delete mode 100644 planed_refactoring/02_async_lifecycle_orchestration.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4028f14..550637b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,11 +21,12 @@ order, displayed order, current index, sort mode, and merge mode. `viewer` is th orchestration hub: it owns Fyne widgets, rendering, and the operations that turn user events into state transitions. Everything that could own state independently of that core is a subpackage, listed after this table. -This is deliberately not a general controller extraction. Async scan/load/sort work and its generation/cancellation -guards remain with `viewer`, as do window geometry, menu enablement, and the widget-facing display/cache state they -coordinate. Native file-picker/save dialogs are likewise outside this boundary: `openfiles.go` and `export.go` remain -small viewer glue over `internal/filepicker`. Feature packages keep their existing narrow consumer-side `Host` -interfaces; `appState` is not exported or passed to them as a broad controller. +This is deliberately not a general controller extraction. Async scan/load/sort/vector work remains with `viewer`, but +its generation/cancellation mechanics are shared through `lifecycle.go`'s package-local request contract. Window +geometry, menu enablement, and the widget-facing display/cache state those jobs coordinate remain here too. Native +file-picker/save dialogs are likewise outside this boundary: `openfiles.go` and `export.go` remain small viewer glue +over `internal/filepicker`. Feature packages keep their existing narrow consumer-side `Host` interfaces; `appState` is +not exported or passed to them as a broad controller. `Run` is the package's only exported symbol. The `viewer` type never leaves the package; what the subpackages see of it is a set of exported methods on an unexported type (the "vocabulary" in `viewer.go`), each subpackage binding only to @@ -41,17 +42,18 @@ interface. | `windowtrack.go` | The app's two window-geometry bindings, one line each now that both mechanisms are shared with the secondary windows that remember their own geometry: `windowSizeTracker` (over `widgets.NewSizeTracker` — records the window's size on every layout pass) and `startWindowPosPolling` (over `winpos.Poll` — keeps `viewer.winPos` current, skipped while the slideshow is full-screen; returns a stop func `Run` calls at shutdown). Plus `widgetGeometry`/`prefGeometry`, the translation between `preferences.WindowGeometry` and `widgets.Geometry` — the same four values, owned separately so neither package has to import the other | | `testdata/` | Golden-master screenshots for the e2e suite (moved here with the code that reads them, since a relative path can't reach a parent directory) | | `state.go` | The unexported, package-local `appState`: the current files, raw scan/drop order, current index, sort mode, and merge mode. Its mutation helpers copy replacement lists, reset/clamp the index, and remove one corresponding raw-order duplicate, so the model cannot split its displayed and unsorted lists. It is intentionally a state model, not a feature-facing controller: only `viewer` accesses it | -| `viewer.go` | The `viewer` façade and orchestration hub: Fyne/UI state, navigation, image cache, and the small methods that apply `appState` changes to the screen. It owns title handling, `clearToDropzone`/`reset`/`showWelcomeState`, `closeFiles` (the File menu's "Close Files" item — like `reset` but never closes the window), `undoGridMaximize` (undoes a grid-triggered `winpos.Maximize` — see `grid/`'s `ConsumeMaximized` — before a resize elsewhere tries to shrink the window back down), merge-mode toggle/get/set, `showFileIfPresent`, and the exported vocabulary the feature packages' narrow `Host` interfaces bind to (`CurrentFile`, `RemoveFile`, `RemoveFiles`, `ShowImage`, `ShowToast`, `ShowEmptyStateError`, `ForceRepaint`, `FileCount`, `FileAt`, `OpenFiles`, `CurrentIndex`, `Generation`, `Unfocus`, `Modifiers`, `Advance`). `OpenFiles` sends a favorite's stored list through the existing drop/scan path; `RemoveFiles` is the batch form `internal/ui/deletion` binds to - descending, duplicate-tolerant, and the one place the grid is reconciled after the file set shrinks (`grid.FilesChanged`, plus `grid.Close` once nothing is left); plain `RemoveFile` stays for `load.go`'s failed-decode retry | +| `lifecycle.go` | The package-local async contract: zero-value `revision` and `requestLifecycle` plus immutable `requestToken`. Beginning or invalidating a request advances its revision and cancels the previous context; background work checks the token both before expensive work and before applying through `fyne.Do`. Load, scan, sort, and vector each own an instance rather than sharing invalidation accidentally | +| `viewer.go` | The `viewer` façade and orchestration hub: Fyne/UI state, navigation, image cache, and the small methods that apply `appState` changes to the screen. It owns title handling, `clearToDropzone`/`reset`/`showWelcomeState`, `closeFiles` (the File menu's "Close Files" item — like `reset` but never closes the window), `undoGridMaximize` (undoes a grid-triggered `winpos.Maximize` — see `grid/`'s `ConsumeMaximized` — before a resize elsewhere tries to shrink the window back down), merge-mode toggle/get/set, `showFileIfPresent`, and the exported vocabulary the feature packages' narrow `Host` interfaces bind to (`CurrentFile`, `RemoveFile`, `RemoveFiles`, `ShowImage`, `ShowToast`, `ShowEmptyStateError`, `ForceRepaint`, `FileCount`, `FileAt`, `OpenFiles`, `CurrentIndex`, `Generation`, `Unfocus`, `Modifiers`, `Advance`). `Generation` is the dedicated index-to-URI file-set revision consumed by grid and deletion; navigation does not move it. `OpenFiles` sends a favorite's stored list through the existing drop/scan path; `RemoveFiles` is the batch form `internal/ui/deletion` binds to - descending, duplicate-tolerant, and the one place the grid is reconciled after the file set shrinks (`grid.FilesChanged`, plus `grid.Close` once nothing is left); plain `RemoveFile` stays for `load.go`'s failed-decode retry | | `keys.go` | `handleKeyEvent` — the single keyboard dispatcher every unmodified key press runs through — plus `handleTypedRune`, its typed-*character* twin (wired to `SetOnTypedRune` alongside it in `build.go`). The grid's filename search is the only consumer of actual characters rather than key names, so runes are delivered only while the grid is up and dropped everywhere else | | `menu.go` | `buildMainMenu` — the window's menu bar: a File menu (Open Files…/Save Changes/Export as PNG…/Export as JPEG…/Set as Wallpaper/Close Files/Settings…, built here) composed with `help.Menu()`'s Help menu. The one place that decides how the two compose, per the cross-feature-composition rule below | -| `drop.go` | Drop handling and the recursive folder scan: `handleDrop`, its shared completion step `applyScanResult`, `applyScannedFiles` (merges or replaces the file set, then reorders and displays it via `sort.go`'s `startSort` instead of sorting inline on the UI goroutine), `cancelScan`, `realPathOf`, the `maxScan` cap and its `MaxScan`/`SetMaxScan` get/set (the settings window's binding) | +| `drop.go` | Drop handling and the recursive folder scan: `handleDrop`, its shared completion step `applyScanResult`, `applyScannedFiles` (merges or replaces the file set, then reorders and displays it via `sort.go`'s `startSort` instead of sorting inline on the UI goroutine), `cancelScan`, `realPathOf`, the `maxScan` cap and its `MaxScan`/`SetMaxScan` get/set (the settings window's binding). Scans own `viewer.scanLifecycle`, independent of navigation; a new drop or explicit cancellation supersedes them | | `memlimits.go` | The app's memory budget: the three limits bounding how much decoded-image memory can be held, and the settings window's getter/setter pairs for them (`MaxImageCacheMB`/`SetMaxImageCacheMB`, `MaxThumbCacheMB`/`SetMaxThumbCacheMB`, `MaxFileSizeMB`/`SetMaxFileSizeMB`), plus `bytesPerMB` and the shipped defaults derived from `internal/imaging`'s own. Grouped in one file rather than each sitting beside its consumer the way `MaxScan` (drop.go) and `MaxWindowWidth` (load.go) do, because they have no single consumer — the image cache is read in `load.go`, the thumbnail cache lives in `internal/ui/grid`, and the encoded-input ceiling is process-wide state in `internal/imaging` — while together they are one coherent thing. Each setter reaches through to what actually enforces the limit (`imgCache.SetBudget`, `grid.SetCacheBytes`, `imaging.SetMaxEncodedBytes`); `SetMaxImageCacheMB` additionally retunes the SVG re-render ceiling through `vectorRasterPixelsFor` + `imaging.SetMaxVectorRasterPixels`, since that raster is deliberately never charged to the cache and the derivation is how the user's one memory setting still bounds it | -| `load.go` | Loading and displaying images: `ShowImage`/`attemptLoad`/`finishLoad`/`retryAfterLoadFailure`, neighbor preloading (which bails on the *header* when a neighbor's `imaging.EstimateDecodedBytes` exceeds half the cache budget, and writes with `AddIfFits` rather than `Add`, so a speculative decode can never displace the image on screen), the GIF `animate` loop, `resizeToImage` (takes its `maxW`/`maxH` cap as parameters rather than reading a package constant, so each call site passes the viewer's own `maxWinW`/`maxWinH`) plus `defaultMaxWindowWidth`/`defaultMaxWindowHeight` and the `MaxWindowWidth`/`SetMaxWindowWidth`/`MaxWindowHeight`/`SetMaxWindowHeight` get/set pairs (the settings window's binding) | +| `load.go` | Loading and displaying images: `ShowImage`/`attemptLoad`/`finishLoad`/`retryAfterLoadFailure`, neighbor preloading (which bails on the *header* when a neighbor's `imaging.EstimateDecodedBytes` exceeds half the cache budget, and writes with `AddIfFits` rather than `Add`, so a speculative decode can never displace the image on screen), the GIF `animate` loop, `resizeToImage` (takes its `maxW`/`maxH` cap as parameters rather than reading a package constant, so each call site passes the viewer's own `maxWinW`/`maxWinH`) plus `defaultMaxWindowWidth`/`defaultMaxWindowHeight` and the `MaxWindowWidth`/`SetMaxWindowWidth`/`MaxWindowHeight`/`SetMaxWindowHeight` get/set pairs (the settings window's binding). One `loadLifecycle` token spans a decode's retry chain, preloads, and animation; cancellation wakes semaphore/frame-delay waits promptly | | `toast.go` | The `toast` component - owns its widgets and a cancellable auto-hide lifecycle (atomic generation, per-show stop/done channels, injected duration) - plus the viewer's `showToast` wrapper | | `info.go` | The persistent info overlay (I key): toggle/sync/update, `formatFileSize`. `syncInfoOverlayVisibility` also settles the "Show EXIF data" link, shown only when the file on screen actually has metadata (`viewer.currentHasEXIF`, carried on `imaging.LoadedImage` the same way `FileSize` is) — it sits there rather than in `updateInfoOverlay` because that one also runs on every zoom change, and a zoom can't add or remove a file's Exif. Also home to `displayedDimensions`, the one answer to "how big is this image": exactly `img.Image.Bounds()` for a raster format, but the rotation-aware *logical* size for a vector, whose on-screen raster gets denser as the user zooms — the shared rule behind both what the overlay reports and what `rotate.go`'s `applyRotationLayout` sizes the window to, each of which shipped a bug fix for reading the raw bounds | -| `sort.go` | `toggleSort` (the `S` key) and `SetSortMode`/`SortMode` (the settings window's binding — jumps directly to a mode rather than cycling, safe to call before any files are loaded), plus `startSort`/`finishSort`: the shared background-reorder mechanism (own spinner/label, staleness generation `sortGen`, completion callback) used by both `SetSortMode` and `drop.go`'s `applyScannedFiles`, so neither freezes the UI on a large stat/Exif-heavy sort — the orderings themselves live in `internal/filesort` | +| `sort.go` | `toggleSort` (the `S` key) and `SetSortMode`/`SortMode` (the settings window's binding — jumps directly to a mode rather than cycling, safe to call before any files are loaded), plus `startSort`/`finishSort`: the shared background-reorder mechanism (own spinner/label, `sortLifecycle`, completion callback) used by both `SetSortMode` and `drop.go`'s `applyScannedFiles`, so neither freezes the UI on a large stat/Exif-heavy sort. Only a current token may hide progress, clear `sorting`, or invoke the callback; the orderings themselves live in `internal/filesort` | | `rotate.go` | View-only 90°-step image rotation (`rotateBy`/`resetRotation`/`redrawRotatedFrame`), composed with EXIF orientation at render time - not written to disk until the File > Save Changes action (see `save.go`) explicitly persists it. Stays here rather than becoming a package: it writes `img.Image` on the core load/animation path, which is the app's own side of the contract `zoom/` is written against. `applyRotationLayout` also hands `zoom` a local, axis-swapped *copy* of `v.vectorLogical` on a 90/270° turn - never the field itself, which stays unrotated since the re-render target in `vector.go` is built from it - since a vector's fit scale would otherwise be computed against the wrong axis. Both `rotateBy` and `resetRotation` call `updateFileMenuState` *before* `applyRotationLayout`, not after: the layout call's own resize can synchronously spawn a `vector.go` re-render goroutine (a real, single-goroutine-serialized non-issue in production, since `fyne.Do` there marshals onto the UI goroutine - but the fake test driver runs a `fyne.Do` callback inline instead, so `updateFileMenuState`'s read of `v.img.Image` afterward could otherwise race that goroutine's write under `-race`); ordering it first removes the race outright rather than narrowing it, since `updateFileMenuState` needs nothing `applyRotationLayout` computes | -| `vector.go` | The debounced SVG re-render: how an image stays sharp as the display scale moves, once `internal/imaging` owns the parsing and rasterizing. `requestVectorRender` is `zoom`'s `onScaleChanged` handler - fired for a key, a scroll, or a fit-driven resize - and decides whether the new density is worth a fresh raster via `vectorNeedsRender`'s hysteresis band (`vectorSharpenRatio`/`vectorReleaseRatio`: over 1.05x denser to sharpen, under 0.5x to release memory on the way back out), so a slow scroll or a round-trip zoom doesn't re-render every frame. `rasterizeVector` runs off the UI goroutine, waits out `defaultVectorDebounce` (zeroed in tests) to coalesce a burst of scale changes into one rasterization, and checks the staleness generation `viewer.vectorGen` twice - before rasterizing and again on the `fyne.Do` hand-off - so a superseded request lands nothing. `clearVector` drops the vector state and bumps that generation - called on every path back to the drop zone, and, just as importantly, from `load.go`'s `finishLoad` on every image load: that call is what actually stops a stale rasterization of the image just navigated away from landing on the one just navigated to, ordinary next/previous browsing included, not only a return to the empty state. The aliasing rule `finishLoad` also observes: a vector's displayed frame is replaced in place by every re-render, so it is given its own one-element `displayFrames` slice rather than sharing the cached `LoadedImage`'s backing array - writing through that would mutate the cache entry and invalidate the byte weight `ByteCache` already computed for it | +| `vector.go` | The debounced SVG re-render: how an image stays sharp as the display scale moves, once `internal/imaging` owns the parsing and rasterizing. `requestVectorRender` is `zoom`'s `onScaleChanged` handler - fired for a key, a scroll, or a fit-driven resize - and decides whether the new density is worth a fresh raster via `vectorNeedsRender`'s hysteresis band (`vectorSharpenRatio`/`vectorReleaseRatio`: over 1.05x denser to sharpen, under 0.5x to release memory on the way back out), so a slow scroll or a round-trip zoom doesn't re-render every frame. `rasterizeVector` runs off the UI goroutine under `vectorLifecycle`, whose cancellation coalesces a burst by waking superseded debounce waits; it checks its token before rasterizing and again on the `fyne.Do` hand-off. `clearVector` invalidates that lifecycle on every image change. The aliasing rule `finishLoad` also observes: a vector's displayed frame is replaced in place by every re-render, so it is given its own one-element `displayFrames` slice rather than sharing the cached `LoadedImage`'s backing array - writing through that would mutate the cache entry and invalidate the byte weight `ByteCache` already computed for it | | `save.go` | `canSaveRotation`/`saveRotation`/`updateFileMenuState`: the File menu's "Save Changes" item (also Cmd/Ctrl+S, see `build.go`'s `wireSaveShortcut`) that persists `rotate.go`'s view-only rotation back to the file it came from, via `internal/imaging`'s `SaveRotated`/`CanEncode`. Disabled except when there's a loaded, non-animated, encodable-format image with a pending rotation and no load in flight - see `canSaveRotation`'s own doc comment for why each of those matters; a successful save folds the just-written pixels into `displayFrames` and resets `rotation` to 0 so nothing visibly changes. `updateFileMenuState` lives here but drives all four image-dependent File-menu items - `export.go`'s two and `wallpaper.go`'s one included - since every site that calls it can move both conditions at once | | `export.go` | `canExport`/`exportAs`/`runExport`/`suggestedExportPath`/`exportDestination`: the File menu's "Export as PNG…"/"Export as JPEG…" items, which write the frame on screen to a **new** file via `internal/filepicker`'s `ChooseSave` and `internal/imaging`'s `Export`. `canExport` is deliberately far weaker than `save.go`'s `canSaveRotation` - no encodable source format, no single-frame requirement, no pending rotation - because an export picks the *destination's* format, which is how pixels get out of a WebP/HEIC (decode-only here) or out of one frame of an animation; only the `!v.loading.Load()` guard is shared, and for the same reason. `exportDestination` holds the rule that keeps a file's bytes matching its name: an extension the user typed that this module can encode wins over the menu item, otherwise the menu item's extension is appended. Reuses `chooserDone` rather than a channel of its own - both panels are app-modal, so the open chooser and the save panel are never in flight at once | | `wallpaper.go` | `canSetWallpaper`/`setAsWallpaper`/`applyWallpaper`/`writeWallpaperFile`/`sweepWallpapers`/`defaultWallpaperDir`: the File menu's "Set as Wallpaper" item, over `internal/wallpaper`. `canSetWallpaper` is `canExport` verbatim, for the same reasons - what this writes is a PNG of the frame on screen, so neither the source format nor an animation nor a pending rotation matters, while a load in flight still does. It writes that PNG into `viewer.wallpaperDir` (`os.UserCacheDir()/picfetch/wallpapers`, a `t.TempDir()` under test) rather than pointing the OS at the user's own file, because every platform in `internal/wallpaper` stores a *reference*: the user's file is one Shift+Delete away from leaving the desktop with a broken wallpaper, and the copy also carries the rotation, one frame of an animation, and any decode-only format. The name carries a timestamp because macOS caches the desktop picture by path; `sweepWallpapers` is what keeps that from accumulating a file per invocation, and it deliberately runs only after `wallpaper.Set` succeeds - a failed set removes just the file it wrote, since the previous one may still be the live wallpaper | @@ -86,10 +88,10 @@ Concurrency invariant (established when the suite first became `-race`-clean, phase 2 stage 2): the viewer has **no mutable package state** - test seams that used to be package vars (`toastDuration`, `maxScannedFiles`, `currentKeyModifiers`) are per-viewer fields now - and every background goroutine has both a -staleness guard (generation counter) -and an explicit stop/done signal: `animStop`/`animStopped` (GIF playback), the slideshow's `Exit`/`Settle` pair, the +staleness guard (`requestToken`, or a feature-local generation where its semantics are richer) +and an explicit stop/done signal: the load token's context plus `animStopped` (GIF playback), the slideshow's `Exit`/`Settle` pair, the poller's stop func, the toast's per-show `stop`/`done` pair, `clipboardDone`, `chooserDone`, `wallpaperDone`, the -vector re-render's `vectorGen`/`vectorStop` (see `vector.go`), and the grid's and viewer's +request lifecycles' cancellable contexts, and the grid's and viewer's `pending`/`preloadPending`/`vectorPending` WaitGroups for thumbnail, neighbor-preload, and vector-rasterization decodes. Under Fyne's test driver `fyne.Do` runs a goroutine's callback inline rather than marshaling it to a UI thread, so the test suite leans on those signals (`settleToast`/`settleThumbs`/`settleSlideshow`/`settleChooser` and the diff --git a/FyneApp.toml b/FyneApp.toml index 80d3dcd..704bc52 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -2,7 +2,7 @@ Name = "PicFetch" ID = "io.github.frathe.picfetch" Version = "0.1.7" -Build = 315 +Build = 320 [Migrations] fyneDo = true diff --git a/internal/ui/build.go b/internal/ui/build.go index 7d2fb8d..0a2e249 100644 --- a/internal/ui/build.go +++ b/internal/ui/build.go @@ -320,7 +320,6 @@ func buildViewer(application fyne.App) (*viewer, fyne.Window) { keyModifiers: defaultKeyModifiers, } - view.vectorStop = make(chan struct{}) view.vectorDebounce = defaultVectorDebounce view.vectorRasterize = func(vec *imaging.Vector, w, h int) (image.Image, error) { return vec.RasterAt(w, h) } view.vectorAfter = time.After diff --git a/internal/ui/drop.go b/internal/ui/drop.go index 8c5b4fe..c810c02 100644 --- a/internal/ui/drop.go +++ b/internal/ui/drop.go @@ -14,12 +14,9 @@ import ( ) // cancelScan aborts a scan in progress (Escape while v.scanning is true). -// It bumps gen the same way clearToDropzone/ShowImage already do for loads -// (via invalidateLoad, which also cancels any load/preload context still -// running), so the background goroutine in handleDrop notices via the gen -// check in its directory-walk loop and stops touching the filesystem -// instead of racing a large tree to completion for a result nobody will -// see. +// It invalidates the scan's own lifecycle, so the background goroutine in +// handleDrop stops touching the filesystem without interrupting navigation, +// preloading, or animation for an already-loaded merge-mode file set. // // Unlike reset, it never touches v.state.files or v.state.unsortedFiles: a merge-mode // scan can be cancelled mid-way through without losing images that were @@ -31,8 +28,7 @@ func (v *viewer) cancelScan() { return } - v.invalidateLoad() - v.stopAnimation() + v.scanLifecycle.invalidate() v.scanning = false v.scanSpinner.Hide() @@ -118,8 +114,8 @@ func (v *viewer) handleDrop(uris []fyne.URI) { // drop gets applied. merging := v.state.MergeMode() && len(v.state.files) > 0 - gen := v.invalidateLoad() - v.stopAnimation() + v.invalidateLoad() + token := v.scanLifecycle.begin() v.scanning = true scanDone := make(chan struct{}) @@ -158,7 +154,7 @@ func (v *viewer) handleDrop(uris []fyne.URI) { images = append(images, u) } fyne.Do(func() { - v.applyScanResult(gen, merging, uris, images, false, scanDone) + v.applyScanResult(token, merging, uris, images, false, scanDone) }) return } @@ -198,7 +194,7 @@ func (v *viewer) handleDrop(uris []fyne.URI) { seenFiles := make(map[string]bool) process := func(u fyne.URI) { - if truncated { + if truncated || !token.current() { return } @@ -229,7 +225,7 @@ func (v *viewer) handleDrop(uris []fyne.URI) { // update counter periodically to avoid flooding the UI thread if n == 1 || n%10 == 0 || truncated { fyne.Do(func() { - if v.gen.Load() != gen { + if !token.current() { return } v.scanLabel.SetText(fmt.Sprintf(lang.L("Scanning... %d images"), n)) @@ -243,16 +239,17 @@ func (v *viewer) handleDrop(uris []fyne.URI) { } for len(dirs) > 0 && !truncated { - // A newer drop (or an explicit cancel - see cancelScan) bumped - // gen out from under this scan: stop walking the tree instead of + // A newer drop (or an explicit cancel - see cancelScan) superseded + // this scan's token: stop walking the tree instead of // racing storage.List calls to completion for a result nobody - // will see. The trailing fyne.Do below re-checks gen and would + // will see. The trailing fyne.Do below re-checks the token and would // discard the result anyway; bailing here just stops the wasted // I/O sooner. scanDone is still closed directly, skipping // fyne.Do, to honor its documented contract of always closing // for a stale generation - even though nothing currently waits // on this particular (already-overwritten) channel value. - if v.gen.Load() != gen { + if !token.current() { + token.cancelContext() close(scanDone) return } @@ -268,7 +265,7 @@ func (v *viewer) handleDrop(uris []fyne.URI) { } fyne.Do(func() { - v.applyScanResult(gen, merging, uris, images, truncated, scanDone) + v.applyScanResult(token, merging, uris, images, truncated, scanDone) }) }() } @@ -278,10 +275,11 @@ func (v *viewer) handleDrop(uris []fyne.URI) { // goroutine. It must run on the UI goroutine (both callers wrap it in // fyne.Do) and always closes scanDone, honoring that channel's contract // even when a newer generation has made this result stale. -func (v *viewer) applyScanResult(gen uint64, merging bool, uris, images []fyne.URI, truncated bool, scanDone chan struct{}) { +func (v *viewer) applyScanResult(token requestToken, merging bool, uris, images []fyne.URI, truncated bool, scanDone chan struct{}) { defer close(scanDone) + defer token.cancelContext() - if v.gen.Load() != gen { + if !token.current() { return } v.scanning = false diff --git a/internal/ui/e2e_test.go b/internal/ui/e2e_test.go index 10a9eef..4982540 100644 --- a/internal/ui/e2e_test.go +++ b/internal/ui/e2e_test.go @@ -258,7 +258,7 @@ func TestE2E_EscapeQuitsWhenNothingLoaded(t *testing.T) { func TestE2E_EscapeCancelsScanInsteadOfClosing(t *testing.T) { v, _, closed := newTestUI(t) - v.gen.Add(1) + v.scanLifecycle.begin() v.scanning = true v.scanSpinner.Show() v.scanLabel.Show() diff --git a/internal/ui/grid/grid.go b/internal/ui/grid/grid.go index acb8f2f..028f895 100644 --- a/internal/ui/grid/grid.go +++ b/internal/ui/grid/grid.go @@ -56,9 +56,10 @@ type Host interface { // starts when the grid opens. CurrentIndex() int - // Generation is the app's load generation. A decode captures it when - // it starts and discards its result if it no longer matches, so a - // fresh drop can't have a stale thumbnail painted into it. + // Generation is the app's index-to-URI file-set revision. A decode + // captures it when it starts and discards its result if it no longer + // matches, so replacement, reorder, or removal cannot paint a stale + // thumbnail. Navigation alone leaves it unchanged. Generation() uint64 // ShowImage displays the file at index i. diff --git a/internal/ui/library_test.go b/internal/ui/library_test.go index 25df6ba..ce5384a 100644 --- a/internal/ui/library_test.go +++ b/internal/ui/library_test.go @@ -207,7 +207,9 @@ func drain(t *testing.T, v *viewer) { // slideshow is asked to stop for the same reason, on this goroutine, // since leaving picture-frame mode touches the window. v.invalidateLoad() - v.stopAnimation() + v.scanLifecycle.invalidate() + v.sortLifecycle.invalidate() + v.vectorLifecycle.invalidate() v.slides.Exit() // Vector re-renders: spawned by any effective-scale change, so a test @@ -774,58 +776,49 @@ func TestSetMaxScan_FloorsAtOne(t *testing.T) { } } -// --- invalidateLoad (load-generation cancellation) ------------------------- +// --- invalidateLoad (load-request cancellation) ---------------------------- // TestInvalidateLoad_CancelsPriorLoadContext checks invalidateLoad's own -// contract - bump gen, and cancel whatever context the previous generation's -// decode/preload work was still running under - the same way -// TestHandleKeyEvent_EscapeDuringFirstDropReorderDoesNotCloseWindow stubs -// v.sortCancel rather than racing a real background decode to catch it -// mid-flight. +// contract: advance the lifecycle and cancel the previous request token. func TestInvalidateLoad_CancelsPriorLoadContext(t *testing.T) { v := newTestViewer(t) - var cancelled bool - v.loadCancel = func() { cancelled = true } - genBefore := v.gen.Load() + token := v.loadLifecycle.begin() got := v.invalidateLoad() - if !cancelled { + if token.context().Err() == nil { t.Error("invalidateLoad should cancel the previous generation's load context") } - if got != genBefore+1 { - t.Errorf("invalidateLoad() = %d, want %d (genBefore+1)", got, genBefore+1) + if got != token.revision+1 { + t.Errorf("invalidateLoad() = %d, want %d", got, token.revision+1) } - if v.gen.Load() != got { - t.Errorf("v.gen = %d, want %d", v.gen.Load(), got) + if v.loadLifecycle.currentRevision() != got { + t.Errorf("load revision = %d, want %d", v.loadLifecycle.currentRevision(), got) } } -// TestInvalidateLoad_NilCancelIsSafe checks the guard for the state before -// any image has ever been shown: v.loadCancel is nil until ShowImage's -// first call sets it, and every gen-bumping call site (cancelScan, -// handleDrop, clearToDropzone) can run before that - a first drop's own -// handleDrop, for one. -func TestInvalidateLoad_NilCancelIsSafe(t *testing.T) { +// TestInvalidateLoad_ZeroValueIsSafe covers the state before any image has +// ever been shown. +func TestInvalidateLoad_ZeroValueIsSafe(t *testing.T) { v := newTestViewer(t) - v.loadCancel = nil v.invalidateLoad() // must not panic } -// TestShowImage_SetsLoadCancel checks that ShowImage actually wires a real -// (non-nil) cancel func for its generation, rather than only relying on -// gen's own staleness check - see loadCancel's field comment for why both -// exist. -func TestShowImage_SetsLoadCancel(t *testing.T) { +// TestShowImage_StartsLoadLifecycle checks that navigation owns a cancellable +// lifecycle request rather than relying only on a revision comparison. +func TestShowImage_StartsLoadLifecycle(t *testing.T) { v := newTestViewer(t) a := uitest.TempJPEGURI(t, "a.jpg", 4, 4, color.White) dropAndWait(t, v, a) - if v.loadCancel == nil { - t.Error("ShowImage should set v.loadCancel so a later navigation can cancel this generation's decode/preload work") + v.loadLifecycle.mu.Lock() + hasCancel := v.loadLifecycle.cancel != nil + v.loadLifecycle.mu.Unlock() + if !hasCancel { + t.Error("ShowImage should leave a cancellable load request for preloads and animation") } } @@ -835,12 +828,12 @@ func TestShowImage_SetsLoadCancel(t *testing.T) { // not bump gen or raise a spurious "cancelled scanning" toast. func TestCancelScan_NoOpWhenNotScanning(t *testing.T) { v := newTestViewer(t) - genBefore := v.gen.Load() + revisionBefore := v.scanLifecycle.currentRevision() v.cancelScan() - if v.gen.Load() != genBefore { - t.Error("cancelScan should not bump gen when nothing is scanning") + if v.scanLifecycle.currentRevision() != revisionBefore { + t.Error("cancelScan should not invalidate the scan lifecycle when nothing is scanning") } if v.toast.card.Visible() { t.Error("cancelScan should not raise a toast when nothing is scanning") @@ -849,14 +842,14 @@ func TestCancelScan_NoOpWhenNotScanning(t *testing.T) { // TestCancelScan_CancelsInFlightScanWithNoFilesYet drives cancelScan // directly against the UI state handleDrop leaves in place while its scan -// is still in flight (gen bumped, spinner/counter shown, drop zone hidden), +// is still in flight (token started, spinner/counter shown, drop zone hidden), // without racing handleDrop's own background goroutine to reproduce that // state - see the note on TestHandleDrop_SupersededScanGoroutineExits below // for why the goroutine itself is exercised separately instead. func TestCancelScan_CancelsInFlightScanWithNoFilesYet(t *testing.T) { v := newTestViewer(t) - genBefore := v.gen.Add(1) + token := v.scanLifecycle.begin() v.scanning = true v.scanSpinner.Show() v.scanLabel.Show() @@ -874,8 +867,8 @@ func TestCancelScan_CancelsInFlightScanWithNoFilesYet(t *testing.T) { if !v.dropzone.Visible() || !v.welcomeArt.Visible() { t.Error("drop zone/welcome art should be restored after cancelling a scan that had no files loaded yet") } - if v.gen.Load() == genBefore { - t.Error("cancelScan should bump gen to invalidate the in-flight scan") + if token.current() || token.context().Err() == nil { + t.Error("cancelScan should cancel and supersede the in-flight scan token") } if !v.toast.card.Visible() { t.Error("want a toast confirming the scan was cancelled") @@ -958,6 +951,33 @@ func TestHandleDrop_SupersededScanGoroutineExits(t *testing.T) { } } +// TestNavigationDoesNotInvalidateScan pins the lifecycle split: a user may +// browse an existing set while a merge-mode directory scan is in flight, and +// that navigation must not silently strand scanning=true or discard the scan. +func TestNavigationDoesNotInvalidateScan(t *testing.T) { + v := newTestViewer(t) + + a := uitest.TempJPEGURI(t, "a.jpg", 4, 4, color.White) + b := uitest.TempJPEGURI(t, "b.jpg", 4, 4, color.White) + dropAndWait(t, v, a, b) + + scanToken := v.scanLifecycle.begin() + v.scanning = true + + v.ShowImage(1) + waitUntilLoaded(t, v) + + if !scanToken.current() { + t.Fatal("navigation invalidated an unrelated in-flight scan") + } + if !v.scanning { + t.Fatal("navigation cleared scanning before the scan completed") + } + + v.cancelScan() + settleToast(t, v) +} + // TestHandleDrop_DedupesOverlappingDirectories drops a folder together with // one of its own subfolders in the same call - a folder tree reached via two // different dropped paths - and checks the subfolder's photo isn't counted @@ -1560,22 +1580,25 @@ func TestStaleFileStateCompletionsDoNotOverwriteNewerState(t *testing.T) { v.state.files = append([]fyne.URI(nil), current...) v.state.unsortedFiles = append([]fyne.URI(nil), current...) - staleScanGen := v.gen.Load() - v.gen.Add(1) + staleScanToken := v.scanLifecycle.begin() + v.scanLifecycle.begin() scanDone := make(chan struct{}) - v.applyScanResult(staleScanGen, false, stale, stale, false, scanDone) + v.applyScanResult(staleScanToken, false, stale, stale, false, scanDone) <-scanDone assertEquivalentFileSlices(t, v) if got := namesOfURIs(v.state.files); !slices.Equal(got, []string{"current.jpg"}) { t.Errorf("files = %v, want newer scan state retained", got) } - staleSortGen := v.sortGen.Load() - v.sortGen.Add(1) + staleSortToken := v.sortLifecycle.begin() + newSortToken := v.sortLifecycle.begin() + defer newSortToken.cancelContext() v.sorting = true + v.sortSpinner.Show() + v.sortLabel.Show() sortDone := make(chan struct{}) called := false - v.finishSort(staleSortGen, stale, sortDone, func() {}, func([]fyne.URI) { + v.finishSort(staleSortToken, stale, sortDone, func([]fyne.URI) { called = true }) <-sortDone @@ -1586,12 +1609,56 @@ func TestStaleFileStateCompletionsDoNotOverwriteNewerState(t *testing.T) { if !v.sorting { t.Error("stale sort completion should not clear a newer sort's in-flight state") } + if !v.sortSpinner.Visible() || !v.sortLabel.Visible() { + t.Error("stale sort completion should not hide the newer sort's progress UI") + } assertEquivalentFileSlices(t, v) if got := namesOfURIs(v.state.files); !slices.Equal(got, []string{"current.jpg"}) { t.Errorf("files = %v, want newer sort state retained", got) } } +func TestInvalidateSortCancelsAndFinalizesCurrentProgress(t *testing.T) { + v := newTestViewer(t) + + token := v.sortLifecycle.begin() + v.sorting = true + v.sortSpinner.Show() + v.sortLabel.Show() + + v.invalidateSort() + + if token.current() || token.context().Err() == nil { + t.Fatal("invalidateSort should cancel and supersede the current sort token") + } + if v.sorting || v.sortSpinner.Visible() || v.sortLabel.Visible() { + t.Fatal("invalidateSort should synchronously finalize the current sort progress UI") + } +} + +// TestGenerationTracksFileSetIdentityNotNavigation protects the contract used +// by grid and deletion: indices retain their meaning across navigation but not +// across a removal. +func TestGenerationTracksFileSetIdentityNotNavigation(t *testing.T) { + v := newTestViewer(t) + + a := uitest.TempJPEGURI(t, "a.jpg", 4, 4, color.White) + b := uitest.TempJPEGURI(t, "b.jpg", 4, 4, color.White) + dropAndWait(t, v, a, b) + + beforeNavigation := v.Generation() + v.ShowImage(1) + waitUntilLoaded(t, v) + if got := v.Generation(); got != beforeNavigation { + t.Fatalf("Generation changed from %d to %d on navigation", beforeNavigation, got) + } + + v.RemoveFile(0) + if got := v.Generation(); got <= beforeNavigation { + t.Fatalf("Generation = %d after removal, want greater than %d", got, beforeNavigation) + } +} + func assertEquivalentFileSlices(t *testing.T, v *viewer) { t.Helper() @@ -1672,16 +1739,15 @@ func TestSetSortMode_SnapshotDoesNotAliasUnsortedFiles(t *testing.T) { // computing, v.state.files reads exactly like the "nothing left to reset" state // Escape otherwise closes the window on. v.sorting is what tells the two // apart. Drives the in-flight state directly - v.sorting true, v.state.files -// still empty, v.sortCancel a harmless stub standing in for the real one -// startSort would have paired with it - rather than racing a real drop's +// still empty, and sortLifecycle armed - rather than racing a real drop's // background goroutine to reproduce that window, the same approach // TestCancelScan_CancelsInFlightScanWithNoFilesYet uses for the gathering // phase. func TestHandleKeyEvent_EscapeDuringFirstDropReorderDoesNotCloseWindow(t *testing.T) { v, _, closed := newTestUI(t) + v.sortLifecycle.begin() v.sorting = true - v.sortCancel = func() {} v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyEscape}) @@ -1712,8 +1778,8 @@ func TestHandleKeyEvent_EscapeDuringResortOfExistingFilesDoesNotClearThem(t *tes filesBefore := append([]fyne.URI(nil), v.state.files...) indexBefore := v.state.index + v.sortLifecycle.begin() v.sorting = true - v.sortCancel = func() {} v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyEscape}) @@ -1921,19 +1987,19 @@ func TestViewerShow_AnimatesGIF(t *testing.T) { dropAndWait(t, v, storage.NewFileURI(path)) // animate() writes v.img.Image from its own goroutine for as long as - // gen stays current, which the fyne test driver never marshals onto + // its load token stays current, which the fyne test driver never marshals onto // this one - so reading v.img.Image from here at any point before that // goroutine has fully stopped would race with those writes, even right // after waitForAnimFrame observes a given count: animate is free to // keep writing further frames in between that observation and the next // statement. animFrame reaching 2 (1 for attemptLoad's own first frame, // 1 more for animate's first cycle) is proof the animation loop ran at - // all; invalidating gen and waiting for animStopped then guarantees no + // all; invalidating loadLifecycle and waiting for animStopped then guarantees no // further write can happen, at which point animFrame's final value is // stable and it's finally safe to read v.img.Image. waitForAnimFrame(t, v, 2) - v.gen.Add(1) + v.loadLifecycle.invalidate() waitForAnimStopped(t, v) // Frame 0 (red) is written on odd counts (attemptLoad's initial write @@ -2180,8 +2246,8 @@ func TestAttemptLoad_ToastsAndFallsBackToAStaticFrameForAnOversizedAnimation(t * if len(v.displayFrames) != 1 { t.Errorf("displayFrames = %d, want 1 - the animation should not have been composited", len(v.displayFrames)) } - if v.animStop != nil { - t.Error("animStop is armed, want no animation goroutine for a refused animation") + if v.animStopped != nil { + t.Error("animStopped is armed, want no animation goroutine for a refused animation") } if len(v.state.files) != 1 { t.Errorf("files = %v, want the file kept - it is valid, just too big to animate", v.state.files) @@ -2306,13 +2372,10 @@ func TestSetMaxImageCacheMBRetunesTheVectorRasterCeiling(t *testing.T) { // --- stage-2 stop signals -------------------------------------------------- -// TestStopAnimation_WakesAnimateImmediately parks animate in a frame-delay -// sleep far longer than the test (10s per frame) and checks stopAnimation -// wakes it right away: before the stop channel existed, animate could only -// notice a stale generation at its next frame tick, so a navigation away -// from a slow GIF left the goroutine sleeping out the rest of the delay. -// The 2s wait below times out if the wake-up doesn't work. -func TestStopAnimation_WakesAnimateImmediately(t *testing.T) { +// TestInvalidateLoad_WakesAnimateImmediately parks animate in a frame-delay +// sleep far longer than the test and checks lifecycle cancellation wakes it +// immediately rather than waiting for the next frame tick. +func TestInvalidateLoad_WakesAnimateImmediately(t *testing.T) { v := newTestViewer(t) animURI := storage.NewFileURI(uitest.WriteTempFile(t, "slow.gif", uitest.EncodeAnimatedGIF(t, 4, 4, @@ -2321,18 +2384,14 @@ func TestStopAnimation_WakesAnimateImmediately(t *testing.T) { dropAndWait(t, v, animURI) - if v.animStop == nil { - t.Fatal("loading an animated GIF should arm animStop") + if v.animStopped == nil { + t.Fatal("loading an animated GIF should arm animStopped") } - v.gen.Add(1) // supersede the animation, as every real stop site does - v.stopAnimation() + v.loadLifecycle.invalidate() waitForAnimStopped(t, v) - - if v.animStop != nil { - t.Error("stopAnimation should nil the channel so a second call is a no-op") - } + v.loadLifecycle.invalidate() // repeated invalidation must remain safe } // TestStartWindowPosPolling_TestDriverGetsNoopStop pins the stop-func diff --git a/internal/ui/lifecycle.go b/internal/ui/lifecycle.go new file mode 100644 index 0000000..266fc46 --- /dev/null +++ b/internal/ui/lifecycle.go @@ -0,0 +1,99 @@ +package ui + +import ( + "context" + "sync" + "sync/atomic" +) + +// revision is a monotonically increasing identity for state observed by +// background work. Its zero value is ready to use. +type revision struct { + value atomic.Uint64 +} + +func (r *revision) advance() uint64 { + return r.value.Add(1) +} + +func (r *revision) current() uint64 { + return r.value.Load() +} + +func (r *revision) matches(value uint64) bool { + return r.current() == value +} + +// requestLifecycle owns the cancellation and revision of one logical class +// of work. Starting or invalidating a request permanently supersedes the +// previous token. Its zero value is ready to use. +type requestLifecycle struct { + revision revision + + mu sync.Mutex + cancel context.CancelFunc +} + +// begin supersedes the previous request and returns the token for the new +// one. Descendant work should share this token rather than begin another +// request of its own. +func (l *requestLifecycle) begin() requestToken { + ctx, cancel := context.WithCancel(context.Background()) + + l.mu.Lock() + if l.cancel != nil { + l.cancel() + } + value := l.revision.advance() + l.cancel = cancel + l.mu.Unlock() + + return requestToken{ + ctx: ctx, + cancel: cancel, + lifecycle: l, + revision: value, + } +} + +// invalidate supersedes and cancels the current request without starting a +// replacement. It is safe before the first begin and safe to repeat. +func (l *requestLifecycle) invalidate() uint64 { + l.mu.Lock() + value := l.revision.advance() + if l.cancel != nil { + l.cancel() + l.cancel = nil + } + l.mu.Unlock() + + return value +} + +func (l *requestLifecycle) currentRevision() uint64 { + return l.revision.current() +} + +// requestToken is the immutable identity and context captured by one logical +// request. cancel only releases this token's context; it does not advance the +// lifecycle revision and therefore cannot supersede a newer request. +type requestToken struct { + ctx context.Context + cancel context.CancelFunc + lifecycle *requestLifecycle + revision uint64 +} + +func (t requestToken) context() context.Context { + return t.ctx +} + +func (t requestToken) current() bool { + return t.lifecycle != nil && t.ctx != nil && t.ctx.Err() == nil && t.lifecycle.revision.matches(t.revision) +} + +func (t requestToken) cancelContext() { + if t.cancel != nil { + t.cancel() + } +} diff --git a/internal/ui/lifecycle_test.go b/internal/ui/lifecycle_test.go new file mode 100644 index 0000000..87d09f6 --- /dev/null +++ b/internal/ui/lifecycle_test.go @@ -0,0 +1,106 @@ +package ui + +import ( + "sync" + "testing" +) + +func TestRevisionZeroValueAdvancesAndMatches(t *testing.T) { + var r revision + + if got := r.current(); got != 0 { + t.Fatalf("initial revision = %d, want 0", got) + } + first := r.advance() + if first != 1 || !r.matches(first) { + t.Fatalf("after advance: revision = %d, matches = %t; want 1, true", first, r.matches(first)) + } + if r.matches(0) { + t.Fatal("the zero revision must be stale after an advance") + } +} + +func TestRequestLifecycleBeginCancelsAndSupersedesPrevious(t *testing.T) { + var lifecycle requestLifecycle + + first := lifecycle.begin() + second := lifecycle.begin() + + if first.context().Err() == nil { + t.Fatal("begin should cancel the previous token's context") + } + if first.current() { + t.Fatal("the previous token must be stale after begin") + } + if !second.current() { + t.Fatal("the newly begun token should be current") + } + if second.revision != first.revision+1 { + t.Fatalf("revisions = %d then %d, want consecutive values", first.revision, second.revision) + } +} + +func TestRequestLifecycleInvalidateCancelsWithoutReplacement(t *testing.T) { + var lifecycle requestLifecycle + + beforeFirst := lifecycle.invalidate() + if beforeFirst != 1 { + t.Fatalf("first invalidate revision = %d, want 1", beforeFirst) + } + + token := lifecycle.begin() + invalidated := lifecycle.invalidate() + if token.context().Err() == nil || token.current() { + t.Fatal("invalidate should cancel and stale the current token") + } + if invalidated != token.revision+1 { + t.Fatalf("invalidate revision = %d, want %d", invalidated, token.revision+1) + } +} + +func TestRequestTokenCancelDoesNotSupersedeNewerRequest(t *testing.T) { + var lifecycle requestLifecycle + + first := lifecycle.begin() + second := lifecycle.begin() + first.cancelContext() + + if !second.current() { + t.Fatal("cancelling an older token must not supersede the newer request") + } + if got := lifecycle.currentRevision(); got != second.revision { + t.Fatalf("current revision = %d, want %d", got, second.revision) + } + + second.cancelContext() + if second.current() { + t.Fatal("a token whose own context was cancelled must not be current") + } + if got := lifecycle.currentRevision(); got != second.revision { + t.Fatalf("local cancellation advanced revision to %d, want it unchanged at %d", got, second.revision) + } +} + +func TestRequestLifecycleConcurrentBeginLeavesOneCurrentToken(t *testing.T) { + var lifecycle requestLifecycle + const count = 32 + + tokens := make([]requestToken, count) + var wg sync.WaitGroup + for i := range tokens { + wg.Go(func() { + tokens[i] = lifecycle.begin() + }) + } + wg.Wait() + + current := 0 + for _, token := range tokens { + if token.current() { + current++ + } + } + if current != 1 { + t.Fatalf("current tokens = %d, want exactly 1", current) + } +} diff --git a/internal/ui/load.go b/internal/ui/load.go index d2f53fc..ad9cb0f 100644 --- a/internal/ui/load.go +++ b/internal/ui/load.go @@ -3,7 +3,6 @@ package ui import ( - "context" "errors" "fmt" "image" @@ -25,11 +24,6 @@ func (v *viewer) ShowImage(i int) { return } - // A playing animation belongs to the image being navigated away from; - // stop it now, alongside the gen bump below that would make it stale - // anyway, so it exits immediately instead of at its next frame tick. - v.stopAnimation() - // Once an image is on screen we keep showing it until the new one is // ready, instead of blanking out to the drop-hint on every navigation. firstLoad := v.img.Image == nil @@ -53,48 +47,32 @@ func (v *viewer) ShowImage(i int) { } v.ForceRepaint() - // A new generation invalidates any decode/retry chain still in flight, + // A new request token invalidates any decode/retry chain still in flight, // so a slow load can never overwrite a newer selection. Every retry in // attemptLoad below - for a file that turns out to be broken - shares - // this one generation, this one done channel, and this one ctx: they're + // this one token and done channel: they're // all part of the same logical navigation, not independent ones, so a - // genuinely newer ShowImage() call (which bumps gen again, via - // invalidateLoad below) correctly invalidates the whole chain - and, - // via ctx, stops attemptLoad's/preloadOne's I/O instead of just + // genuinely newer ShowImage() call correctly invalidates the whole chain + // and, via the token's context, stops attemptLoad's/preloadOne's I/O instead of just // discarding a result they'd otherwise run to completion for - and a // waiter on done sees the chain as finished only once it truly settles // instead of racing whichever retry closes a channel first. - gen := v.invalidateLoad() - - ctx, cancel := context.WithCancel(context.Background()) - v.loadCancel = cancel + token := v.loadLifecycle.begin() done := make(chan struct{}) v.loadDone = done - v.attemptLoad(ctx, i, gen, done) + v.attemptLoad(token, i, done) } -// invalidateLoad bumps gen and, if a load's decode/preload work is -// currently in flight, cancels its context - mirroring invalidateSort -// (sort.go) for the load/preload generation instead of the sort one. This -// is what makes attemptLoad's and preloadOne's own ReadAndProbe/ -// DecodeLoaded calls notice and stop doing I/O for a superseded generation, -// instead of running to completion for a result the gen check they already -// make would only end up discarding anyway. Called by ShowImage (a fresh -// navigation) and every other site that already bumped gen directly before -// this existed: cancelScan, handleDrop (drop.go), and clearToDropzone -// (viewer.go). +// invalidateLoad cancels and permanently supersedes the current logical +// navigation, including its decode/retry chain, preloads, and animation. func (v *viewer) invalidateLoad() uint64 { - gen := v.gen.Add(1) - if v.loadCancel != nil { - v.loadCancel() - } - return gen + return v.loadLifecycle.invalidate() } // attemptLoad decodes and displays v.state.files[i] (wrapped into range), sharing -// gen, done, and ctx with the rest of its retry chain - see ShowImage's +// token and done with the rest of its retry chain - see ShowImage's // comment. It first reads the file and probes just its header // (imaging.ReadAndProbe), which is enough to reject an invalid file // instantly, without spending time on a full pixel decode that was only @@ -103,7 +81,7 @@ func (v *viewer) invalidateLoad() uint64 { // RemoveFile and retries at the same position, which now holds what used // to be the next file (or wraps around to the first, if i was the last); // once nothing is left it falls back to the empty-state error screen. -func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan struct{}) { +func (v *viewer) attemptLoad(token requestToken, i int, done chan struct{}) { n := len(v.state.files) i = ((i % n) + n) % n v.state.index = i @@ -115,12 +93,16 @@ func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan s // the UI goroutine that called ShowImage(). No fyne.Do hop is needed since // we're already on it. if loaded, ok := v.imgCache.Get(u.String()); ok { - v.finishLoad(ctx, i, u, loaded, gen, done) + if !token.current() { + close(done) + return + } + v.finishLoad(token, i, u, loaded, done) return } go func() { - data, bounds, err := imaging.ReadAndProbe(ctx, u) + data, bounds, err := imaging.ReadAndProbe(token.context(), u) if err == nil { fyne.Do(func() { @@ -134,7 +116,7 @@ func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan s // grid. Only reachable since the grid's batch delete, which // re-shows whatever takes a deleted file's place without // closing the grid first. - if gen == v.gen.Load() && !v.slides.Active() && !v.grid.Visible() { + if token.current() && !v.slides.Active() && !v.grid.Visible() { v.undoGridMaximize() resizeToImage(v.win, bounds, v.maxWinW, v.maxWinH) } @@ -147,7 +129,7 @@ func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan s // animation whose composited frames couldn't fit in the cache // at all is exactly the one not worth compositing, so this // needs no limit of its own. - loaded, err = imaging.DecodeLoaded(ctx, data, v.imgCache.Budget()) + loaded, err = imaging.DecodeLoaded(token.context(), data, v.imgCache.Budget()) if err == nil { loaded.FileSize = int64(len(data)) loaded.HasEXIF = !imaging.ReadMetadata(data).Empty() @@ -155,7 +137,7 @@ func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan s } fyne.Do(func() { - if gen != v.gen.Load() { + if !token.current() { close(done) // user already navigated elsewhere return } @@ -173,7 +155,7 @@ func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan s msg = fmt.Sprintf(lang.L("%q is too large to open"), u.Name()) } - v.retryAfterLoadFailure(ctx, msg, i, gen, done) + v.retryAfterLoadFailure(token, msg, i, done) return } @@ -181,7 +163,7 @@ func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan s if b.Dx() == 0 || b.Dy() == 0 { msg := fmt.Sprintf(lang.L("invalid image dimensions for %q"), u.Name()) - v.retryAfterLoadFailure(ctx, msg, i, gen, done) + v.retryAfterLoadFailure(token, msg, i, done) return } @@ -193,7 +175,7 @@ func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan s } v.imgCache.Add(u.String(), loaded) - v.finishLoad(ctx, i, u, loaded, gen, done) + v.finishLoad(token, i, u, loaded, done) }) }() } @@ -207,7 +189,7 @@ func (v *viewer) attemptLoad(ctx context.Context, i int, gen uint64, done chan s // test driver runs synchronously on whatever goroutine called it) and its // cache-hit path (called directly from attemptLoad, always on whichever // goroutine called ShowImage()). -func (v *viewer) finishLoad(ctx context.Context, _ int, u fyne.URI, loaded *imaging.LoadedImage, gen uint64, done chan struct{}) { +func (v *viewer) finishLoad(token requestToken, _ int, u fyne.URI, loaded *imaging.LoadedImage, done chan struct{}) { b := loaded.Frames[0].Bounds() v.displayFrames = loaded.Frames @@ -295,9 +277,9 @@ func (v *viewer) finishLoad(ctx context.Context, _ int, u fyne.URI, loaded *imag v.updateFileMenuState() // rotation just reset to 0 above, and loading has just cleared - see canSaveRotation v.ForceRepaint() - // Animated GIFs keep playing until a newer generation (a navigation or - // a fresh drop) supersedes this one; animate checks gen itself so no - // separate cancellation is needed. It's spawned only after + // Animated GIFs keep playing until a newer load request (a navigation or + // a fresh drop) supersedes this one; animate checks the shared token and + // waits on its context. It's spawned only after // ForceRepaint above has finished, not before via a defer: under the // real driver both go through the same serialized fyne.Do queue either // way, but the fyne test driver runs fyne.Do synchronously on the @@ -305,10 +287,8 @@ func (v *viewer) finishLoad(ctx context.Context, _ int, u fyne.URI, loaded *imag // Refresh race with this goroutine's still-running ForceRepaint. if len(loaded.Frames) > 1 { stopped := make(chan struct{}) - stop := make(chan struct{}) v.animStopped = stopped - v.animStop = stop - go v.animate(gen, loaded.Frames, loaded.Delays, stop, stopped) + go v.animate(token, loaded.Frames, loaded.Delays, stopped) } // Must run - and finish reading v.state.files/v.state.index - before done closes @@ -316,10 +296,10 @@ func (v *viewer) finishLoad(ctx context.Context, _ int, u fyne.URI, loaded *imag // future navigation) synchronizes on to know this call is finished // touching viewer state. Under the fyne test driver, this whole // function already runs on whatever goroutine called fyne.Do rather - // than a dedicated UI goroutine (see attemptLoad's comment on gen), so + // than a dedicated UI goroutine (see attemptLoad's token comment), so // closing done first would let a waiter go on to mutate v.state.files - via // reset() or a fresh drop - concurrently with this read. - v.preloadNeighbors(ctx, gen) + v.preloadNeighbors(token) close(done) } @@ -329,11 +309,11 @@ func (v *viewer) finishLoad(ctx context.Context, _ int, u fyne.URI, loaded *imag // cache hit instead of a fresh disk read + decode. Always called from // finishLoad before done closes - see its comment - so reading // v.state.files/v.state.index here can't race a waiter that's about to mutate them. -// ctx is the same one ShowImage created for this generation - the -// preloads it starts belong to the generation that's now on screen, so +// token is the same one ShowImage created for this navigation - the +// preloads it starts belong to the request that's now on screen, so // they get cancelled alongside its own decode the moment a newer // navigation or drop supersedes it (see invalidateLoad). -func (v *viewer) preloadNeighbors(ctx context.Context, gen uint64) { +func (v *viewer) preloadNeighbors(token requestToken) { n := len(v.state.files) if n < 2 { return @@ -342,9 +322,9 @@ func (v *viewer) preloadNeighbors(ctx context.Context, gen uint64) { next := ((v.state.index+1)%n + n) % n prev := ((v.state.index-1)%n + n) % n - v.preloadOne(ctx, v.state.files[next], gen) + v.preloadOne(token, v.state.files[next]) if prev != next { - v.preloadOne(ctx, v.state.files[prev], gen) + v.preloadOne(token, v.state.files[prev]) } } @@ -354,13 +334,13 @@ const preloadConcurrency = 2 // preloadOne decodes u in the background and adds it to imgCache, unless // it's already cached or another preload of the same URI is already in -// flight. gen is checked before and after the decode so a preload started +// flight. The token is checked before and after the decode so a preload started // for a set of files that's since been replaced by a fresh drop doesn't -// keep working, or land a stale result, after the fact; ctx backs that up +// keep working, or land a stale result, after the fact; its context backs that up // by making ReadAndProbe/DecodeLoaded themselves stop doing I/O partway // through, for a preload that goes stale while it's actually running // rather than while still queued behind preloadSem. -func (v *viewer) preloadOne(ctx context.Context, u fyne.URI, gen uint64) { +func (v *viewer) preloadOne(token requestToken, u fyne.URI) { key := u.String() // Contains, not Get: a presence test on a speculative path shouldn't @@ -380,14 +360,18 @@ func (v *viewer) preloadOne(ctx context.Context, u fyne.URI, gen uint64) { // preloadNeighbors only ever asks for two files per settled image, // but rapid navigation could otherwise stack an unbounded number // of these full-size decode goroutines. - v.preloadSem <- struct{}{} + select { + case v.preloadSem <- struct{}{}: + case <-token.context().Done(): + return + } defer func() { <-v.preloadSem }() - if gen != v.gen.Load() { + if !token.current() { return } - data, bounds, err := imaging.ReadAndProbe(ctx, u) + data, bounds, err := imaging.ReadAndProbe(token.context(), u) if err != nil { return } @@ -407,7 +391,7 @@ func (v *viewer) preloadOne(ctx context.Context, u fyne.URI, gen uint64) { return } - loaded, err := imaging.DecodeLoaded(ctx, data, budget) + loaded, err := imaging.DecodeLoaded(token.context(), data, budget) if err != nil { return } @@ -419,7 +403,7 @@ func (v *viewer) preloadOne(ctx context.Context, u fyne.URI, gen uint64) { return } - if gen != v.gen.Load() { + if !token.current() { return } @@ -431,25 +415,12 @@ func (v *viewer) preloadOne(ctx context.Context, u fyne.URI, gen uint64) { }) } -// stopAnimation wakes the current animate goroutine, if any, out of its -// frame-delay sleep so it exits right away. Called wherever a gen bump -// supersedes a possibly-playing animation (ShowImage, clearToDropzone, -// handleDrop, cancelScan); the nil-out keeps it idempotent, and it only -// ever runs on the UI goroutine so the field swap needs no -// synchronization. animStopped still signals the actual exit. -func (v *viewer) stopAnimation() { - if v.animStop != nil { - close(v.animStop) - v.animStop = nil - } -} - // retryAfterLoadFailure reports msg, drops v.state.files[i], and either continues // the retry chain via attemptLoad or, if that emptied the set, falls back // to the empty-state error screen and finalizes done. See show/attemptLoad -// for why gen, done, and ctx are threaded through unchanged rather than +// for why token and done are threaded through unchanged rather than // starting a fresh chain. -func (v *viewer) retryAfterLoadFailure(ctx context.Context, msg string, i int, gen uint64, done chan struct{}) { +func (v *viewer) retryAfterLoadFailure(token requestToken, msg string, i int, done chan struct{}) { v.RemoveFile(i) if len(v.state.files) == 0 { @@ -459,37 +430,33 @@ func (v *viewer) retryAfterLoadFailure(ctx context.Context, msg string, i int, g } v.ShowToast(msg) - v.attemptLoad(ctx, i, gen, done) + v.attemptLoad(token, i, done) } // animate cycles an animated GIF's frames on their own goroutine, sleeping // between frames for each one's delay and updating the canvas image via -// fyne.Do. It stops on its own once gen no longer matches the viewer's -// current generation, the same staleness check ShowImage's decode goroutine -// uses, so a navigation or a fresh drop ends the previous animation without -// any extra cancellation plumbing. stopped is closed right before it +// fyne.Do. It stops once its load token is cancelled or superseded, the same +// staleness contract ShowImage's decode goroutine uses, so a navigation or a +// fresh drop wakes the previous animation immediately. stopped is closed right before it // returns, and animFrame is bumped after every frame write, so tests can // wait on those instead of reading v.img.Image from another goroutine - see // the animFrame/animStopped comment on the viewer struct. -func (v *viewer) animate(gen uint64, frames []image.Image, delays []time.Duration, stop, stopped chan struct{}) { +func (v *viewer) animate(token requestToken, frames []image.Image, delays []time.Duration, stopped chan struct{}) { + defer close(stopped) + idx := 0 for { select { case <-time.After(delays[idx]): - case <-stop: - // stopAnimation woke us mid-delay: a navigation or reset has - // already superseded this animation, so exit right away instead - // of sleeping out the rest of the frame delay just to discover - // the stale generation below. - close(stopped) + case <-token.context().Done(): return } stale := false fyne.Do(func() { - if gen != v.gen.Load() { + if !token.current() { stale = true return } @@ -500,7 +467,6 @@ func (v *viewer) animate(gen uint64, frames []image.Image, delays []time.Duratio }) if stale { - close(stopped) return } } diff --git a/internal/ui/menu_test.go b/internal/ui/menu_test.go index cef4cb9..21b5823 100644 --- a/internal/ui/menu_test.go +++ b/internal/ui/menu_test.go @@ -272,7 +272,7 @@ func TestCloseFiles_NeverClosesTheWindow(t *testing.T) { func TestCloseFiles_CancelsScanInProgress(t *testing.T) { v := newTestViewer(t) - v.gen.Add(1) + v.scanLifecycle.begin() v.scanning = true v.scanSpinner.Show() v.scanLabel.Show() diff --git a/internal/ui/run.go b/internal/ui/run.go index 7ebbf8c..fc81b9f 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -62,7 +62,10 @@ func Run(application fyne.App, initial []fyne.URI) { view.stopWinPosPoll() view.settings.StopTracking() view.exif.StopTracking() - close(view.vectorStop) + view.scanLifecycle.invalidate() + view.loadLifecycle.invalidate() + view.sortLifecycle.invalidate() + view.vectorLifecycle.invalidate() session.Save(application, view.state.unsortedFiles) preferences.Save(application, view.currentPreferences()) diff --git a/internal/ui/sort.go b/internal/ui/sort.go index acf1280..2af9d03 100644 --- a/internal/ui/sort.go +++ b/internal/ui/sort.go @@ -1,8 +1,6 @@ package ui import ( - "context" - "fyne.io/fyne/v2" "fyne.io/fyne/v2/lang" @@ -56,22 +54,21 @@ func (v *viewer) SetSortMode(m filesort.Mode) { }) } -// invalidateSort bumps sortGen and, if a reorder is currently in flight, +// invalidateSort advances sortLifecycle and, if a reorder is currently in flight, // cancels its context so filesort.Order's per-file stat/Exif loop notices // and stops promptly instead of running to completion for a result that's -// already guaranteed to be discarded - see sortGen's own field comment for +// already guaranteed to be discarded - see sortLifecycle's field comment for // every caller (a newer sort superseding an older one, Escape via -// cancelSort, RemoveFile, clearToDropzone). Returns the new generation, for -// startSort's own use as the gen its freshly-started sort should report -// under - nothing else could have bumped sortGen again between this call -// and that one, since both run synchronously on the UI goroutine. +// cancelSort, RemoveFile, clearToDropzone). It returns the new revision for +// tests and diagnostics. func (v *viewer) invalidateSort() uint64 { - gen := v.sortGen.Add(1) + revision := v.sortLifecycle.invalidate() if v.sorting { v.sorting = false - v.sortCancel() + v.sortSpinner.Hide() + v.sortLabel.Hide() } - return gen + return revision } // startSort reorders unsorted under mode in the background, showing the sort @@ -80,20 +77,17 @@ func (v *viewer) invalidateSort() uint64 { // potentially large file set: its capture-date/modified/size modes stat or // Exif-read every file, which freezes the UI for as long as that takes if // done inline on the UI goroutine (see filesort.Order's own doc comment). -// Any sort already in flight is cancelled first via invalidateSort, rather +// Any sort already in flight is cancelled by sortLifecycle.begin, rather // than left to keep computing a result this call already supersedes - so // pressing S repeatedly cycles straight through modes instead of queuing up // wasted background work behind whichever one happened to be slowest. -// onDone runs once, and only if this call's generation is still current once -// the reorder finishes - see sortGen's field comment for every way it can be +// onDone runs once, and only if this call's token is still current once +// the reorder finishes - see sortLifecycle's field comment for every way it can be // superseded. func (v *viewer) startSort(mode filesort.Mode, unsorted []fyne.URI, onDone func(ordered []fyne.URI)) { - gen := v.invalidateSort() + token := v.sortLifecycle.begin() v.sorting = true - ctx, cancel := context.WithCancel(context.Background()) - v.sortCancel = cancel - sortDone := make(chan struct{}) v.sortDone = sortDone @@ -105,9 +99,9 @@ func (v *viewer) startSort(mode filesort.Mode, unsorted []fyne.URI, onDone func( v.ForceRepaint() go func() { - ordered := filesort.Order(ctx, mode, unsorted) + ordered := filesort.Order(token.context(), mode, unsorted) fyne.Do(func() { - v.finishSort(gen, ordered, sortDone, cancel, onDone) + v.finishSort(token, ordered, sortDone, onDone) }) }() } @@ -115,37 +109,33 @@ func (v *viewer) startSort(mode filesort.Mode, unsorted []fyne.URI, onDone func( // finishSort is startSort's completion step, shaped like drop.go's // applyScanResult: it must run on the UI goroutine (startSort's goroutine // wraps it in fyne.Do), always closes sortDone (honoring that channel's -// contract even when a newer generation has made this result stale), and -// always releases cancel - the context.CancelFunc for *this* generation's -// own ctx, captured by the goroutine that's calling in, not read back -// through v.sortCancel (which may already point at a newer generation's -// cancel func by the time this runs). -func (v *viewer) finishSort(gen uint64, ordered []fyne.URI, sortDone chan struct{}, cancel context.CancelFunc, onDone func([]fyne.URI)) { +// contract even when a newer request has made this result stale), and always +// releases this invocation's own token context. +func (v *viewer) finishSort(token requestToken, ordered []fyne.URI, sortDone chan struct{}, onDone func([]fyne.URI)) { defer close(sortDone) - defer cancel() + defer token.cancelContext() - v.sortSpinner.Hide() - v.sortLabel.Hide() - - // Superseded either by a newer sort (another startSort call bumped - // sortGen again) or by something else that changed + // Superseded either by a newer sort or by something else that changed // v.state.files/v.state.unsortedFiles while this one was still computing // (Shift+Delete, or Escape/File>Close - see those call sites' own // invalidateSort call). Applying ordered in either case would silently // clobber newer state, so just drop it. - if gen != v.sortGen.Load() { + if !token.current() { return } - // v.sorting is cleared here, inside the staleness check, rather than - // unconditionally like the spinner/label above: if two sorts overlap (a + // v.sorting and the progress widgets are finalized here, inside the + // staleness check: if two sorts overlap (a // second large first-drop landing before the first one's reorder // finishes, say), the earlier, stale one's finishSort must not report // "no sort in flight" while the current one is still computing - that // would reopen the Escape-quits-mid-reorder bug v.sorting exists to - // close, just for a narrower window. Only the generation that's still + // close, just for a narrower window. Only the token that's still // current when it finishes gets to clear it. v.sorting = false + v.sortSpinner.Hide() + v.sortLabel.Hide() + v.fileSetRevision.advance() onDone(ordered) } diff --git a/internal/ui/state.go b/internal/ui/state.go new file mode 100644 index 0000000..36612d9 --- /dev/null +++ b/internal/ui/state.go @@ -0,0 +1,68 @@ +package ui + +import ( + "fyne.io/fyne/v2" + + "github.com/frathe/picfetch/internal/filesort" +) + +type appState struct { + files []fyne.URI + unsortedFiles []fyne.URI + index int + sortMode filesort.Mode + mergeMode bool +} + +func newAppState(sortMode filesort.Mode, mergeMode bool) appState { + return appState{sortMode: sortMode, mergeMode: mergeMode} +} + +func (s *appState) SortMode() filesort.Mode { + return s.sortMode +} + +func (s *appState) SetSortMode(mode filesort.Mode) { + s.sortMode = mode +} + +func (s *appState) MergeMode() bool { + return s.mergeMode +} + +func (s *appState) SetMergeMode(on bool) { + s.mergeMode = on +} + +func (s *appState) setFiles(unsorted, files []fyne.URI) { + s.unsortedFiles = append([]fyne.URI(nil), unsorted...) + s.files = append([]fyne.URI(nil), files...) +} + +func (s *appState) replaceFiles(unsorted, files []fyne.URI) { + s.setFiles(unsorted, files) + s.index = 0 +} + +func (s *appState) clearFiles() { + s.files = nil + s.unsortedFiles = nil + s.index = 0 +} + +func (s *appState) removeFile(i int) fyne.URI { + target := s.files[i] + s.files = append(s.files[:i], s.files[i+1:]...) + if s.index >= len(s.files) { + s.index = len(s.files) - 1 + } + + for j, u := range s.unsortedFiles { + if u.String() == target.String() { + s.unsortedFiles = append(s.unsortedFiles[:j], s.unsortedFiles[j+1:]...) + break + } + } + + return target +} diff --git a/internal/ui/state_test.go b/internal/ui/state_test.go new file mode 100644 index 0000000..9a80fb2 --- /dev/null +++ b/internal/ui/state_test.go @@ -0,0 +1,98 @@ +package ui + +import ( + "slices" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/storage" + + "github.com/frathe/picfetch/internal/filesort" +) + +func TestAppStateModelPreferences(t *testing.T) { + state := newAppState(filesort.ByName, false) + + if got := state.SortMode(); got != filesort.ByName { + t.Errorf("SortMode() = %v, want ByName", got) + } + if state.MergeMode() { + t.Error("MergeMode() = true, want false") + } + + state.SetSortMode(filesort.BySize) + state.SetMergeMode(true) + + if got := state.SortMode(); got != filesort.BySize { + t.Errorf("SortMode() = %v, want BySize after SetSortMode", got) + } + if !state.MergeMode() { + t.Error("MergeMode() = false, want true after SetMergeMode") + } +} + +func TestAppStateReplaceFilesCopiesAndResetsIndex(t *testing.T) { + unsorted := []fyne.URI{storage.NewFileURI("/images/b.jpg"), storage.NewFileURI("/images/a.jpg")} + ordered := []fyne.URI{unsorted[1], unsorted[0]} + state := appState{index: 1} + + state.replaceFiles(unsorted, ordered) + unsorted[0] = storage.NewFileURI("/images/changed.jpg") + ordered[0] = storage.NewFileURI("/images/changed.jpg") + + if state.index != 0 { + t.Errorf("index = %d, want 0 after replacement", state.index) + } + if got, want := state.unsortedFiles[0].Name(), "b.jpg"; got != want { + t.Errorf("unsortedFiles[0] = %q, want %q", got, want) + } + if got, want := state.files[0].Name(), "a.jpg"; got != want { + t.Errorf("files[0] = %q, want %q", got, want) + } +} + +func TestAppStateRemoveFileRemovesOneMatchingUnsortedDuplicate(t *testing.T) { + a := storage.NewFileURI("/images/a.jpg") + b := storage.NewFileURI("/images/b.jpg") + state := appState{ + files: []fyne.URI{a, b, a}, + unsortedFiles: []fyne.URI{a, a, b}, + index: 2, + } + + removed := state.removeFile(2) + + if removed.String() != a.String() { + t.Errorf("removed = %q, want %q", removed, a) + } + if got, want := state.files, []fyne.URI{a, b}; !slices.EqualFunc(got, want, sameURI) { + t.Errorf("files = %v, want %v", got, want) + } + if got, want := state.unsortedFiles, []fyne.URI{a, b}; !slices.EqualFunc(got, want, sameURI) { + t.Errorf("unsortedFiles = %v, want %v", got, want) + } + if state.index != 1 { + t.Errorf("index = %d, want 1", state.index) + } +} + +func TestAppStateClearFilesResetsFileState(t *testing.T) { + state := appState{ + files: []fyne.URI{storage.NewFileURI("/images/a.jpg")}, + unsortedFiles: []fyne.URI{storage.NewFileURI("/images/a.jpg")}, + index: 4, + } + + state.clearFiles() + + if state.files != nil || state.unsortedFiles != nil { + t.Errorf("file slices = %v, %v, want nil", state.files, state.unsortedFiles) + } + if state.index != 0 { + t.Errorf("index = %d, want 0", state.index) + } +} + +func sameURI(a, b fyne.URI) bool { + return a.String() == b.String() +} diff --git a/internal/ui/vector.go b/internal/ui/vector.go index 353c950..64f2867 100644 --- a/internal/ui/vector.go +++ b/internal/ui/vector.go @@ -64,10 +64,10 @@ func (v *viewer) requestVectorRender(scale float32) { return } - gen := v.vectorGen.Add(1) + token := v.vectorLifecycle.begin() v.vectorPending.Add(1) - go v.rasterizeVector(v.vector, w, h, gen) + go v.rasterizeVector(v.vector, w, h, token) } // vectorNeedsRender is the hysteresis band described on the two ratio @@ -95,18 +95,19 @@ func vectorNeedsRender(have, want image.Point) bool { // Every early return costs nothing: a burst of twenty scale changes spawns // twenty of these and rasterizes once, because the other nineteen find the // generation moved on before allocating anything. -func (v *viewer) rasterizeVector(vec *imaging.Vector, w, h int, gen uint64) { +func (v *viewer) rasterizeVector(vec *imaging.Vector, w, h int, token requestToken) { defer v.vectorPending.Done() + defer token.cancelContext() if v.vectorDebounce > 0 { select { case <-v.vectorAfter(v.vectorDebounce): - case <-v.vectorStop: + case <-token.context().Done(): return } } - if v.vectorGen.Load() != gen { + if !token.current() { return } @@ -122,7 +123,7 @@ func (v *viewer) rasterizeVector(vec *imaging.Vector, w, h int, gen uint64) { fyne.Do(func() { // Re-checked on this side too: the generation can move between the // check above and this callback running. - if v.vectorGen.Load() != gen || v.vector != vec || len(v.displayFrames) == 0 { + if !token.current() || v.vector != vec || len(v.displayFrames) == 0 { return } @@ -147,6 +148,6 @@ func (v *viewer) clearVector() { v.vector = nil v.vectorLogical = fyne.Size{} v.vectorRaster = image.Point{} - v.vectorGen.Add(1) + v.vectorLifecycle.invalidate() v.zoom.SetLogicalSize(fyne.Size{}) } diff --git a/internal/ui/vector_test.go b/internal/ui/vector_test.go index c1d8131..5b85afd 100644 --- a/internal/ui/vector_test.go +++ b/internal/ui/vector_test.go @@ -18,10 +18,16 @@ import ( // internal/imaging cannot import internal/ui to say so. This is what stops // the two copies of that value from drifting apart silently. func TestVectorFloorMatchesStartWindowSize(t *testing.T) { - if startW != imaging.MinVectorWidth { + // Both sides are untyped constants, so comparing them directly lets the + // compiler (and the IDE) constant-fold the condition to a literal + // false/true - defeating the point of a test meant to catch a future + // drift. Assigning to a var first forces a real runtime comparison. + gotW, wantW := startW, float64(imaging.MinVectorWidth) + if gotW != wantW { t.Fatalf("startW = %v, imaging.MinVectorWidth = %v", startW, imaging.MinVectorWidth) } - if startH != imaging.MinVectorHeight { + gotH, wantH := startH, float64(imaging.MinVectorHeight) + if gotH != wantH { t.Fatalf("startH = %v, imaging.MinVectorHeight = %v", startH, imaging.MinVectorHeight) } } @@ -120,13 +126,13 @@ func TestZoomKeysDriveVectorRerenders(t *testing.T) { // could run concurrently with the earlier goroutine's write under the // fake test driver, which (unlike production) never marshals fyne.Do // onto a single goroutine, so nothing here would order the two. - before := v.vectorGen.Load() + before := v.vectorLifecycle.currentRevision() for range 12 { v.zoom.In() } v.vectorPending.Wait() - if v.vectorGen.Load() <= before { + if v.vectorLifecycle.currentRevision() <= before { t.Fatal("zooming must request re-renders") } if b := v.img.Image.Bounds(); b.Dx() != v.vectorRaster.X { @@ -175,10 +181,9 @@ func TestRasterizeVectorCoalescesABurst(t *testing.T) { } } -// TestRasterizeVectorStopsOnShutdownSignal exercises the vectorStop arm of -// rasterizeVector's debounce select: the close that cuts a parked goroutine -// out of its wait at shutdown - the one piece of this task that panics on -// misuse (a second close), so it must actually run. Most tests here leave +// TestRasterizeVectorStopsOnShutdownSignal exercises the lifecycle-context arm +// of rasterizeVector's debounce select: shutdown invalidation cuts a parked +// goroutine out of its wait. Most tests here leave // vectorDebounce at the zero newTestUI sets it to and skip the select // entirely; TestRasterizeVectorCoalescesABurst enters it too, but through // the vectorAfter seam rather than a real timer, and never touches the @@ -206,11 +211,11 @@ func TestRasterizeVectorStopsOnShutdownSignal(t *testing.T) { // goroutine actually exited: a sleep here would itself be racing the // 20ms debounce instead of deterministically observing the goroutine's // own exit. - close(v.vectorStop) + v.vectorLifecycle.invalidate() v.vectorPending.Wait() if v.vectorRaster != before { - t.Fatal("closing vectorStop must not let a rasterization land") + t.Fatal("invalidating vectorLifecycle must not let a rasterization land") } } diff --git a/internal/ui/viewer.go b/internal/ui/viewer.go index 220b079..449ff40 100644 --- a/internal/ui/viewer.go +++ b/internal/ui/viewer.go @@ -1,7 +1,6 @@ package ui import ( - "context" "image" "slices" "sync" @@ -114,27 +113,21 @@ type viewer struct { state appState - // gen is the load generation: it guards against out-of-order async - // loads. It's an atomic rather than a plain uint64 because animate's - // background goroutine reads it outside of fyne.Do's synchronization - - // under the test driver, fyne.Do runs its closure synchronously on the - // calling goroutine instead of handing it off to the UI goroutine, so - // that read would otherwise race ShowImage()'s write from a different - // goroutine. - gen atomic.Uint64 - - // loadCancel cancels the context.Context behind whichever decode/preload - // work v.gen's current generation owns - attemptLoad's own - // ReadAndProbe/DecodeLoaded calls and preloadOne's copies of the same, - // for both of ShowImage's neighbors. Set by ShowImage alongside gen's - // bump, mirroring sortCancel/sortGen (sort.go) for the load generation - // instead of the sort one; nil until the first ShowImage call. - // Cancelling it is what makes ReadAndProbe's read and DecodeLoaded's - // entry check notice and stop promptly instead of running to completion - // for a result invalidateLoad's gen bump has already guaranteed will be - // discarded - see invalidateLoad (load.go), the one place this is - // called. - loadCancel context.CancelFunc + // fileSetRevision is the identity of the index-to-URI mapping exposed to + // feature packages. Grid thumbnail and deletion work capture it through + // Generation and discard results after a drop, reorder, removal, or clear. + // It is deliberately independent of loadLifecycle: navigation changes the + // displayed index but not what any index means. + fileSetRevision revision + + // loadLifecycle owns a logical navigation and all of its descendants: + // probe/decode retries, neighbor preloads, and GIF animation. A newer + // navigation, drop, clear, or shutdown cancels and supersedes the token. + loadLifecycle requestLifecycle + + // scanLifecycle is independent of navigation, so browsing the existing + // set during a merge-mode folder scan cannot strand scan UI state. + scanLifecycle requestLifecycle // baseTitle is the window title without the "[merge] " prefix applyTitle // adds while merge mode is on, so toggling M can refresh the title @@ -182,7 +175,7 @@ type viewer struct { // polling widget state, which otherwise races with the fyne test // driver's synchronous fyne.Do under -race. Each call replaces the // field with a fresh channel before starting its async work; a stale - // generation's own channel still gets closed, it just leaves the + // request's own channel still gets closed, it just leaves the // shared state untouched. scanDone chan struct{} loadDone chan struct{} @@ -190,10 +183,10 @@ type viewer struct { sortSpinner *widget.ProgressBarInfinite sortLabel *widget.Label - // sorting is true while the current sortGen's reorder is still + // sorting is true while the current sortLifecycle request is still // meaningfully pending, set by startSort and cleared by whichever of // invalidateSort (a newer sort, Escape via cancelSort, RemoveFile, - // clearToDropzone) or finishSort landing that same generation notices + // clearToDropzone) or finishSort landing that same token notices // first - see sort.go's invalidateSort, which every one of those but // finishSort itself goes through - so it never gets stuck true once // whatever it was tracking has been superseded, cancelled, or @@ -205,37 +198,19 @@ type viewer struct { // reorder. sorting bool - // sortCancel cancels the context.Context behind the reorder v.sorting - // is currently tracking - always non-nil whenever v.sorting is true, - // since startSort sets both together. Cancelling it is what makes - // filesort.Order's per-file stat/Exif loop notice and stop promptly - // instead of running to completion in the background for a result - // that's already guaranteed to be discarded - see invalidateSort - // (sort.go), the one place this is called. - sortCancel context.CancelFunc - - // sortGen is a staleness counter dedicated to sort operations, kept - // separate from v.gen (the load/decode generation ShowImage/animate/ - // preload use). Bumping v.gen for a sort would call for pairing with - // stopAnimation() the way every other v.gen.Add(1) call site does, which - // would spuriously interrupt an unrelated playing GIF or in-flight - // preload for no reason connected to sorting - so this feature owns its - // own counter instead, the same way toast.gen (toast.go) does. Bumped - // by invalidateSort (sort.go) - every startSort call (a sort-mode - // change or a drop landing, which supersedes whatever it might still be - // computing) and everything else that reassigns v.state.files/v.state.unsortedFiles - // while a sort could be in flight (Escape, RemoveFile, clearToDropzone) - // - so a stale sort result can never clobber newer state. - sortGen atomic.Uint64 - - // sortDone is closed by finishSort once that generation's reorder has + // sortLifecycle owns the cancellable filesort.Order request. It stays + // separate from loadLifecycle so reordering cannot stop an unrelated + // decode, preload, or playing GIF. + sortLifecycle requestLifecycle + + // sortDone is closed by finishSort once that request's reorder has // finished applying (or been discarded as stale), mirroring // scanDone/loadDone so tests can wait on it deterministically. sortDone chan struct{} // animFrame counts every write to v.img.Image - attemptLoad's initial // frame plus each one animate cycles to afterwards - and animStopped is - // closed by animate once it notices its generation is stale and + // closed by animate once its load token is cancelled or stale and // returns. Both exist so tests can synchronize on frame changes and // animation shutdown via atomics and channel-close instead of reading // v.img.Image directly from another goroutine, which would race with @@ -245,15 +220,9 @@ type viewer struct { // closes has no happens-before edge against a concurrently running // animate call - only observing animFrame's new value does. Each // animate call gets its own captured animStopped (see attemptLoad), so - // a superseded generation's close can't be mistaken for a newer one's. - // animStop is the other direction: closing it (stopAnimation, called - // wherever gen bumps with an animation possibly running) wakes animate - // out of its frame-delay sleep so it exits immediately instead of up - // to one full frame delay later; the gen check stays as belt and - // braces. Only ever swapped on the UI goroutine. + // a superseded request's close can't be mistaken for a newer one's. animFrame atomic.Uint64 animStopped chan struct{} - animStop chan struct{} // displayFrames is the current image's decoded, EXIF-corrected frames // (loaded.Frames - unrotated), and displayFrameIdx which one of them is @@ -414,22 +383,15 @@ type viewer struct { // which requestVectorRender compares a new target against. vectorRaster image.Point - // vectorGen is the staleness guard for re-render goroutines: every - // request bumps it, and a goroutine that finds it moved on rasterizes - // nothing. Also bumped by clearVector, so work in flight when the - // image changes is discarded rather than landing on the new one. - vectorGen atomic.Uint64 + // vectorLifecycle owns debounce and rasterization for the latest SVG + // render request. A newer scale, image change, clear, or shutdown cancels + // the previous token and wakes it out of the debounce immediately. + vectorLifecycle requestLifecycle // vectorPending is waited on by the test suite's drain, per the // module's concurrency invariant. vectorPending sync.WaitGroup - // vectorStop is closed once, at shutdown, to release a goroutine - // parked on its debounce. Deliberately not closed by clearVector, - // which runs on every reset - closing a closed channel panics, and - // abandoning in-flight work is vectorGen's job. - vectorStop chan struct{} - // vectorDebounce coalesces a burst of scroll-driven scale changes into // one rasterization. A per-viewer field rather than a package var // (concurrency invariant: the viewer has no mutable package state), @@ -501,10 +463,11 @@ func (v *viewer) clearToDropzone() { v.resetFade() v.invalidateLoad() // invalidate any decode/preload or animation still in flight - v.stopAnimation() - v.invalidateSort() // cancel a sort still in flight - see sortGen's field comment + v.invalidateSort() // cancel a sort still in flight - see sortLifecycle's field comment + v.scanLifecycle.invalidate() v.state.clearFiles() + v.fileSetRevision.advance() // Purged, not left to age out: with no files open, every decode the // cache holds is of something unreachable, so keeping them just spends @@ -679,9 +642,10 @@ func (v *viewer) displayedFile() (fyne.URI, bool) { // no equivalent index to use, but any matching duplicate there is an // equally valid one to drop. func (v *viewer) RemoveFile(i int) { - v.invalidateSort() // cancel a sort still in flight - see sortGen's field comment + v.invalidateSort() // cancel a sort still in flight - see sortLifecycle's field comment target := v.state.removeFile(i) + v.fileSetRevision.advance() v.imgCache.Remove(target.String()) } @@ -756,9 +720,10 @@ func (v *viewer) CurrentIndex() int { return v.state.index } -// Generation is the current load generation - see the gen field. +// Generation is the current index-to-URI file-set revision. Navigation does +// not change it; replacement, reorder, removal, and clear operations do. func (v *viewer) Generation() uint64 { - return v.gen.Load() + return v.fileSetRevision.current() } // Unfocus releases Fyne's canvas focus. diff --git a/planed_refactoring/02_DONE_async_lifecycle_orchestration.md b/planed_refactoring/02_DONE_async_lifecycle_orchestration.md new file mode 100644 index 0000000..a3e7b76 --- /dev/null +++ b/planed_refactoring/02_DONE_async_lifecycle_orchestration.md @@ -0,0 +1,114 @@ +# (DONE) Refactoring Plan 2: Consolidate Async Lifecycle Management + +## Objective +Centralize the app’s lifecycle rules for stale work, cancellation, and async completion so load/sort/scan/vector jobs follow one consistent contract. + +## Why this is second +The project already has repeated patterns for stale-result protection: `gen`, `sortGen`, `vectorGen`, and multiple cancel functions like `scanCancel`, `loadCancel`, and `sortCancel`. That is a sign that lifecycle logic is duplicated in several places and is hard to reason about uniformly. + +## Target design +Create a small lifecycle/orchestration helper that standardizes: +- generation or revision tracking +- stale-request rejection +- cancellation context ownership +- completion-handling semantics +- irreversible invalidation when a newer request supersedes an older one + +The helper is package-local to `internal/ui` and has two layers: +- a zero-value revision primitive for capturing and comparing monotonically + increasing revisions +- a request lifecycle that starts one current request, cancels the previous + request's context, and returns a token combining that context with the + captured revision + +Load, scan, sort, and vector rendering each own a separate request lifecycle. +Decode retries, neighbor preloads, and GIF animation are descendants of one +load token rather than independent requests. A separate file-set revision is +exposed through `viewer.Generation` for grid thumbnail and deletion guards; +ordinary image navigation must not invalidate work whose indices still refer +to the same file set. + +## Async inventory and invalidation matrix + +| Owner | Work covered by one request | Superseded by | Must not be superseded by | +|---|---|---|---| +| Load | probe, decode, broken-file retry chain, neighbor preloads, GIF animation | newer navigation, a new drop, clearing files, shutdown | sort-only changes before they land; scan cancellation | +| Scan | direct-drop filtering or recursive directory walk and progress updates | newer drop, explicit scan cancellation, clearing files, shutdown | navigation within the existing set | +| Sort | `filesort.Order` and its state-writing callback | newer sort, file removal, clearing/replacing files, explicit sort cancellation, shutdown | navigation or vector rendering | +| Vector | debounce, SVG rasterization, and UI hand-off | newer render request, image/vector change, clearing files, shutdown | unrelated scan or sort work | +| File set | index-to-URI identity consumed by grid and deletion | replace/merge landing, reorder landing, removal, clear | navigation, decode retry before it removes a file, scan/sort merely starting | + +Toast, slideshow, grid filtering/cell recycling, thumbnail workers, chooser, +clipboard, deletion, favorites, wallpaper, and window-position polling retain +their existing feature-local lifecycle contracts. They have additional +semantics that do not fit a single-current-request abstraction and are outside +this refactoring. + +## Completion contract + +1. Every background operation captures a request token and checks it before + expensive work when practical and again immediately before applying a + result through `fyne.Do`. +2. Starting or invalidating a lifecycle irreversibly advances its revision and + cancels the previous token's context. Cancellation stops cooperative work; + revision comparison remains the final stale-result guard. +3. A stale completion may only settle resources owned by that invocation. It + must not hide a newer request's spinner, clear a newer in-flight flag, write + model/widget state, or invoke a state-writing callback. +4. Per-invocation done channels are closed exactly once on success, error, + cancellation, and stale completion. Existing WaitGroups call `Done` on + every return path, including cancellation during vector debounce and while + a preload is queued behind its semaphore. +5. Load descendants share the parent token and context. Finishing the visible + decode does not invalidate that token because its preloads and animation + remain legitimate until the next load invalidation. +6. UI-owned booleans such as `scanning` and `sorting` remain local presentation + state. Only the current token's completion may finalize them; explicit + cancellation finalizes them synchronously on the UI goroutine. + +## Delegation stages + +1. Introduce and unit-test the lifecycle and revision primitives. +2. Migrate load and scan together because they currently share `viewer.gen`; + split their invalidation while preserving the load descendant chain. +3. Migrate sort independently, including stale spinner ownership. +4. Migrate vector rendering and replace its shutdown-only stop channel with + lifecycle cancellation. +5. Remove legacy viewer fields, update test synchronization and + `ARCHITECTURE.md`, then run normal and race-enabled suites. + +## Specific steps +1. [x] Inventory all async jobs and their invalidation rules. +2. [x] Extract a common “request lifecycle” abstraction used by decode, sort, scan, and vector render. +3. [x] Replace ad hoc generation checks with a shared controller contract. +4. [x] Keep cancellation responsibilities explicit and local to the owning job. +5. [x] Ensure no work can finish into a superseded state without first checking validity. +6. [x] Add targeted tests for stale requests and out-of-order completion. + +## Implemented result + +- `internal/ui/lifecycle.go` owns the zero-value `revision`, + `requestLifecycle`, and `requestToken` primitives. +- `viewer` owns separate load, scan, sort, and vector lifecycle instances plus + a dedicated file-set revision returned by `Generation`. +- Load retries, preloads, and GIF animation share one token; cancellation wakes + animation delays and preloads queued behind the semaphore. +- Scan cancellation no longer interrupts navigation through an existing set. +- Only the current sort token may finalize progress UI or invoke its callback; + explicit invalidation finalizes its own UI synchronously. +- Vector invalidation replaces the shutdown-only stop channel and wakes + superseded debounce waits. +- Targeted lifecycle regressions, `make test`, and `go test -race ./...` pass. + +## Risks to watch +- Subtle races between UI goroutine and background decode goroutines. +- Cancellation semantics that silently skip legitimate finalization. +- Inconsistent stale-result checks across different features. + +## Best suited agent +`go-expert` + +## Success criteria +- One consistent invalidation model across all async loops. +- Fewer custom generation counters and cancellation wrappers. +- Less risk of stale UI updates after a newer file set or sort order is active. diff --git a/planed_refactoring/02_async_lifecycle_orchestration.md b/planed_refactoring/02_async_lifecycle_orchestration.md deleted file mode 100644 index 8f1a5cc..0000000 --- a/planed_refactoring/02_async_lifecycle_orchestration.md +++ /dev/null @@ -1,36 +0,0 @@ -# Refactoring Plan 2: Consolidate Async Lifecycle Management - -## Objective -Centralize the app’s lifecycle rules for stale work, cancellation, and async completion so load/sort/scan/vector jobs follow one consistent contract. - -## Why this is second -The project already has repeated patterns for stale-result protection: `gen`, `sortGen`, `vectorGen`, and multiple cancel functions like `scanCancel`, `loadCancel`, and `sortCancel`. That is a sign that lifecycle logic is duplicated in several places and is hard to reason about uniformly. - -## Target design -Create a small lifecycle/orchestration helper that standardizes: -- generation or revision tracking -- stale-request rejection -- cancellation context ownership -- completion-handling semantics -- irreversible invalidation when a newer request supersedes an older one - -## Specific steps -1. Inventory all async jobs and their invalidation rules. -2. Extract a common “request lifecycle” abstraction used by decode, sort, scan, and vector render. -3. Replace ad hoc generation checks with a shared controller contract. -4. Keep cancellation responsibilities explicit and local to the owning job. -5. Ensure no work can finish into a superseded state without first checking validity. -6. Add targeted tests for stale requests and out-of-order completion. - -## Risks to watch -- Subtle races between UI goroutine and background decode goroutines. -- Cancellation semantics that silently skip legitimate finalization. -- Inconsistent stale-result checks across different features. - -## Best suited agent -`go-expert` - -## Success criteria -- One consistent invalidation model across all async loops. -- Fewer custom generation counters and cancellation wrappers. -- Less risk of stale UI updates after a newer file set or sort order is active. From 9ab568dfdc03e35059a7aa30183ec26b8f086482 Mon Sep 17 00:00:00 2001 From: frathe Date: Wed, 19 Aug 2026 13:21:47 +0200 Subject: [PATCH 3/6] Refactoring 03: Separate App Assembly from App Behavior --- .claude/memory/go-expert.md | 35 -- AGENTS.md | 44 ++ ARCHITECTURE.md | 52 +- CLAUDE.md | 11 - FyneApp.toml | 2 +- README.md | 23 +- internal/ui/build.go | 526 ++---------------- internal/ui/components.go | 192 +++++++ internal/ui/e2e_test.go | 19 +- internal/ui/exifwin/exifwin.go | 4 +- internal/ui/features.go | 54 ++ internal/ui/features_test.go | 27 + internal/ui/library_test.go | 33 +- internal/ui/memlimits.go | 4 +- internal/ui/menu.go | 2 +- internal/ui/openfiles_test.go | 10 +- internal/ui/preferences_wiring_test.go | 188 ++++++- internal/ui/run.go | 21 +- internal/ui/save.go | 2 +- internal/ui/session_test.go | 12 +- internal/ui/settingswin/settingswin.go | 4 +- internal/ui/shortcuts.go | 152 +++++ internal/ui/startup.go | 83 +++ internal/ui/viewer.go | 30 +- internal/ui/widgets/singleton.go | 8 +- internal/ui/widgets/singleton_test.go | 6 +- internal/ui/windowtrack.go | 9 +- .../01_DONE_app_state_controller.md | 45 -- .../02_DONE_async_lifecycle_orchestration.md | 114 ---- .../03_build_assembly_cleanup.md | 38 -- todos.md | 53 +- 31 files changed, 914 insertions(+), 889 deletions(-) delete mode 100644 .claude/memory/go-expert.md create mode 100644 AGENTS.md delete mode 100644 CLAUDE.md create mode 100644 internal/ui/components.go create mode 100644 internal/ui/features.go create mode 100644 internal/ui/features_test.go create mode 100644 internal/ui/shortcuts.go create mode 100644 internal/ui/startup.go delete mode 100644 planed_refactoring/01_DONE_app_state_controller.md delete mode 100644 planed_refactoring/02_DONE_async_lifecycle_orchestration.md delete mode 100644 planed_refactoring/03_build_assembly_cleanup.md diff --git a/.claude/memory/go-expert.md b/.claude/memory/go-expert.md deleted file mode 100644 index 979c65e..0000000 --- a/.claude/memory/go-expert.md +++ /dev/null @@ -1,35 +0,0 @@ -# go-expert memory - -Persistent notes for the go-expert agent on this repo. Local-only (gitignored) — not shared via git. - -## Architecture decisions - -- (2026-08-19) Favorites are split into pure `internal/favstore` persistence and a Host-based `internal/ui/favorites` feature. Production storage is initialized explicitly from `ui.Run`, not during viewer construction, so tests never scan the user's real config directory. The first implementation stores only named file lists; disk-backed thumbnail caching remains a separate follow-up. -- (2026-08-19) Favorite accelerators are positional over the current case-insensitively sorted list: Cmd/Ctrl+1–9 open entries 1–9 and Cmd/Ctrl+0 opens entry 10. Register all ten handlers once in `buildViewer`; `favorites.Feature.Open` resolves a slot against names captured by the latest menu refresh, so add/remove never requires shortcut re-registration. -- (2026-08-15) A feature that reorders/replaces `v.files`/`v.unsortedFiles` off the UI goroutine gets its *own* staleness generation counter (e.g. `sortGen`), not `v.gen`. `v.gen` is specifically the load/decode/animation generation - every bump site pairs it with `stopAnimation()`, and reusing it for an unrelated async op would spuriously kill a playing GIF or in-flight preload. Precedent: `toast.gen`. When adding a new async op, grep for every existing writer of the state it will eventually overwrite (`grep -rn "v\.files\s*=\|v\.unsortedFiles\s*="` etc.) and bump the new op's own gen at each of those sites too, so a stale background result can't clobber state a *different* feature changed in the meantime - not just a newer instance of the same op. -- (2026-08-15) Async UI operations in this codebase (scan, sort, ...) all follow one shape: synchronous snapshot of inputs on the UI goroutine -> bump a dedicated gen counter -> show spinner/label + `ForceRepaint()` -> `go func() { compute; fyne.Do(applyResult) }()` -> `applyResult` does `defer close(doneChan)` first, unconditionally resets the spinner/loading flag, *then* checks the gen for staleness before touching shared state. Follow this shape for any future long-running operation instead of inventing a new one. Reference implementations: `sort.go`'s `startSort`/`finishSort` (the generic form, with an `onDone` callback - used by both `SetSortMode` and `drop.go`'s `applyScannedFiles`) and `drop.go`'s `handleDrop`/`applyScanResult`. -- (2026-08-15) A snapshot handed to one of those background goroutines must be a *defensive copy* when it comes from `v.files`/`v.unsortedFiles`, never a bare slice-header assignment: `RemoveFile` shifts those slices in place (`append(s[:j], s[j+1:]...)`), so an aliased snapshot is a genuine data race with whatever the goroutine reads. Same for merges - copy first, then append, so a spare-capacity append doesn't write into the shared array either. Confirmed reachable under `-race`, not theoretical; guarded by `TestSetSortMode_SnapshotDoesNotAliasUnsortedFiles`. -- (2026-08-15) When a background op eventually writes *both* `v.unsortedFiles` and `v.files`, write them together in the completion callback and never one early: `RemoveFile` documents them as holding the same set, and a half-applied update leaves `v.index` able to point past the end of a `v.files` some later-landing reorder replaced. Accepted consequence: a `SetSortMode` fired during a large drop's reorder window supersedes it, so that drop silently doesn't take (no crash, no corruption, user re-drops). Deliberately not solved with a coalescing/retry queue. - -## Conventions - -- Dedicated spinner/label widget pairs per async feature (not a shared one), even though visually similar - two concurrent async ops (e.g. a merge-mode scan still running when a sort-mode change is requested) must not fight over one pair of widgets. Build each pair via its own `newXUI()` in `build.go` next to `newScanUI`/`newSortUI`, wire into the content `Stack` alongside `scanContainer`. -- `*_test.go`: every background op gets a `waitForX(t, v)` helper next to `waitForScan`/`waitUntilLoaded` in `library_test.go`, and a `{"name", v.xDone}` entry in `drain`'s wait list, so tests can synchronize deterministically instead of polling widget state (which races the fyne test driver's synchronous `fyne.Do` under `-race`). - -## Conventions (testing) - -- `-race` regression tests for aliasing/ordering bugs are worth keeping even - though they only fail probabilistically: they can never fail spuriously - (the detector is the only assertion), so the worst case is a silent pass. - Size them so the background copy is still in flight when the mutation - starts (4000 `uitest.FakeURI`s + 200 `RemoveFile` calls reproduced 3/3 - fresh binaries; the detector dedupes repeats within one binary, so - `-count=N` only ever reports once). - -## Open questions - -- (2026-08-15) Escape during the post-drop background reorder hits - `keys.go`'s `len(v.files) == 0` branch (the scan is done, `v.files` not - set yet) and closes the window instead of cancelling. Logged in - `todos.md`; needs a "sort in flight" state Escape can see, which means - deciding whether that state belongs on the viewer or inside `startSort`. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9ec8fcf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# PicFetch Agent Guide + +## Start Here + +- Read `ARCHITECTURE.md` before code: it is the authoritative package map and “where to look for X” index. +- Update `ARCHITECTURE.md` in the same change when packages are added, removed, renamed, or files move between packages. +- Open work belongs in `todos.md`; do not add `TODO`/`FIXME` comments to source. +- Do not run `git commit`. End with a suggested commit message for the user. + +## Architecture and Data Flow + +- `main.go` only creates the Fyne app, embeds translations, converts CLI paths, and calls `internal/ui.Run`; keep package `main` thin. +- `internal/ui/appState` owns the current/unsorted file lists, index, sort mode, and merge mode. The unexported `viewer` is its Fyne-facing façade; `ui.Run` is the package’s only exported entry point. +- Feature packages such as `internal/ui/grid`, `deletion`, and `slideshow` own their widgets/state and declare narrow consumer-side `Host` interfaces. Do not pass them `appState` or invent a shared controller/registry. +- Cross-feature decisions stay in `internal/ui`: for example, `batch.go` joins grid selection to deletion/clipboard, and `togglePictureFrameMode` prevents grid/slideshow overlap. +- Feature construction order in `internal/ui/features.go` and overlay order in `build.go` are load-bearing; preserve explicit composition rather than auto-registration. +- Input flows through CLI/open/drop → `handleDrop` scan → `filesort.Order` → `ShowImage` → `internal/imaging` probe/decode/orient/cache → Fyne display. Reuse this path rather than creating parallel open/load logic. +- `internal/imaging` is viewer-independent. Full images and grid thumbnails use separate byte-budgeted caches; preserve `ByteCache.Add` (displayed image) versus `AddIfFits` (speculative preload) semantics. +- Session file sets use `internal/session`/Fyne cache; standing settings and geometry use `internal/preferences`/Fyne preferences. Startup wiring is in `internal/ui/startup.go`, shutdown persistence in `run.go`. +- OS integrations live behind dispatcher vars in `internal/{clipboard,filepicker,trash,wallpaper}` with build-tagged platform files. Tests must replace them via `internal/uitest` stubs, never touch the real desktop. +- Keep `appID` synchronized across `main.go`, `FyneApp.toml`, and `Makefile`; changing it disconnects existing preferences/session data. + +## Concurrency and Fyne + +- Scan, load, sort, and vector work each own a `requestLifecycle`; capture its token, check staleness before expensive work and before applying results, and marshal background UI updates through `fyne.Do`. +- Do not add mutable package-level test seams. Runtime/test-configurable values belong on `viewer` or the owning feature. +- Every goroutine needs cancellation/staleness handling plus an observable stop/done signal. If adding background work, add it to `newTestUI`’s `drain` cleanup in `internal/ui/library_test.go`. +- Fyne’s test driver runs `fyne.Do` inline. Use `dropAndWait`, `waitFor*`, feature `Settle`, and existing completion channels before assertions; never sleep to guess completion. + +## Project Conventions + +- 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. +- Report UI-boundary failures with `fyne.LogError`; viewer-independent packages return errors. Mark intentionally ignored errors explicitly (`_ =` or `_, _ =`) so IDE/`errcheck` inspections see intent. +- 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. + +## Build and Verification + +- Use the Makefile: `make run`, `make build`, `make test`, `make fmt`, `make vet`; `make help` lists packaging/security targets. +- Match CI before handoff: formatting, `go vet ./...`, `go build ./...`, then `go test -race ./...` from the repository root. +- 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. +- Packaging uses Fyne/Fyne-cross; macOS is native, while Windows/Linux cross-builds require Docker. `fyne package` may bump `FyneApp.toml`’s build number. + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 550637b..3d86c96 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,6 +28,11 @@ file-picker/save dialogs are likewise outside this boundary: `openfiles.go` and over `internal/filepicker`. Feature packages keep their existing narrow consumer-side `Host` interfaces; `appState` is not exported or passed to them as a broad controller. +Feature construction is centralized without becoming a registry: +`features.go`'s one explicit, ordered `registerFeatures` function assigns the eight feature modules directly to +`viewer`. The order stays visible because it is load-bearing; there is no generic feature interface, mutable registry, +or second controller layer. + `Run` is the package's only exported symbol. The `viewer` type never leaves the package; what the subpackages see of it is a set of exported methods on an unexported type (the "vocabulary" in `viewer.go`), each subpackage binding only to the few it declares in its own `Host` @@ -37,15 +42,19 @@ interface. | File(s) | Responsibility | |------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `run.go` | `Run` — the only exported symbol: builds the window, wires the startup drop (CLI arguments) and the shutdown save of session + preferences, then hands control to Fyne's event loop. The shutdown save stops all three position pollers first (the main window's, and the two secondary windows' if they're still open) and builds its `preferences.State` in `currentPreferences`, split out purely so a test can read it back — `SetOnStopped` itself only ever runs inside a live event loop. Also the app-level constants (`appTitle`, the drop-zone size floor `startW`/`startH` — the window-size *ceiling* is `defaultMaxWindowWidth`/`defaultMaxWindowHeight` in `load.go`, next to the code that enforces it) | -| `build.go` | `buildViewer` — constructs and wires the whole UI, composed from per-feature widget constructors (`newDropzoneUI`/`newScanUI`/`newInfoOverlayUI` here, `newToast` in toast.go, each returning a small widget-cluster struct/component); also the keyboard-shortcut wiring (`wireOpenShortcuts`/`wireFavoriteShortcuts`/`wireClipboardShortcuts`/`wireDeleteShortcut`/`wireSaveShortcut`/`wireSelectAllShortcut`). The window's overlay stack is built here too, and the tail of its order is load-bearing: the grid's backdrop is opaque, so the delete confirmation and the toast are stacked *above* it or they render where nobody can see them | +| `run.go` | `Run` — the only exported symbol and the explicit high-level runtime lifecycle: build the restored startup viewer, start runtime side effects with `favstore.DefaultDir`, register shutdown, register the CLI drop for `OnStarted`, then enter Fyne's event loop. Private `startViewerRuntime` initializes favorites from its caller-provided directory and starts main-window position polling; the directory parameter lets focused tests use temporary storage. Polling therefore cannot observe a nil slideshow or overwrite restored geometry. The shutdown save stops all three position pollers first (the main window's, and the two secondary windows' if they're still open) and builds its `preferences.State` in `currentPreferences`, split out purely so a test can read it back — `SetOnStopped` itself only ever runs inside a live event loop. Also the app-level constants (`appTitle`, the drop-zone size floor `startW`/`startH` — the window-size *ceiling* is `defaultMaxWindowWidth`/`defaultMaxWindowHeight` in `load.go`, next to the code that enforces it) | +| `build.go` | `buildViewer` — top-level window composition from an already loaded `startupState`; it neither reads persistence, restores geometry, starts runtime polling, nor initializes disk-backed favorites storage. It composes the app-owned widgets from `components.go`/`toast.go` with the modules assigned by `registerFeatures`. The explicit typed-key and typed-rune callbacks remain here beside the window assembly, while one call delegates ordered application-wide shortcut registration to `shortcuts.go`. The root overlay stack stays here too, and the tail of its order is load-bearing: the grid's backdrop is opaque, so the delete confirmation and the toast are stacked *above* it or they render where nobody can see them | +| `startup.go` | Startup inputs and restoration: private `startupState`, `loadStartupState` (the only UI-layer calls to `session.Load` and `preferences.Load`), cap-default normalization, and `restoreStartupGeometry` for the main and remembered secondary windows. `buildStartupViewer` is the single load → build → restore path shared by `Run` and test setup, so restoration always follows construction of the settings and EXIF windows. Normalization fills only the six positive caps; slideshow zero, size zeroes, position-set flags, and secondary geometry remain untouched because their zero values carry distinct “not chosen/saved” semantics | +| `components.go` | App-owned widget construction: `fixedHeightLayout` and the dropzone, scan, sort, and info-overlay structs/constructors that `buildViewer` composes. Each constructor returns the same small widget cluster whose fields land in `viewer`; the self-dismissing toast remains in `toast.go` with its lifecycle | +| `features.go` | The one explicit feature-construction sequence, `registerFeatures`: help, EXIF, zoom, grid, deletion, slideshow, settings, then favorites. Grid is assigned before its thumbnail-cache budget is applied; slideshow is assigned before `startViewerRuntime` can start the position poller whose skip callback reads it. It assigns concrete viewer fields directly rather than introducing a generic feature interface or registry | +| `shortcuts.go` | Application-wide modified-key shortcut registration: the narrow `shortcutAdder` test seam, one ordered `wireGlobalShortcuts` composition point, and the individual open, favorite, clipboard, delete, select-all, and save wiring functions that focused tests exercise directly. The Fyne driver-specific distinctions between built-in shortcuts and `desktop.CustomShortcut` live beside those registrations | | `windowtrack.go` | The app's two window-geometry bindings, one line each now that both mechanisms are shared with the secondary windows that remember their own geometry: `windowSizeTracker` (over `widgets.NewSizeTracker` — records the window's size on every layout pass) and `startWindowPosPolling` (over `winpos.Poll` — keeps `viewer.winPos` current, skipped while the slideshow is full-screen; returns a stop func `Run` calls at shutdown). Plus `widgetGeometry`/`prefGeometry`, the translation between `preferences.WindowGeometry` and `widgets.Geometry` — the same four values, owned separately so neither package has to import the other | | `testdata/` | Golden-master screenshots for the e2e suite (moved here with the code that reads them, since a relative path can't reach a parent directory) | | `state.go` | The unexported, package-local `appState`: the current files, raw scan/drop order, current index, sort mode, and merge mode. Its mutation helpers copy replacement lists, reset/clamp the index, and remove one corresponding raw-order duplicate, so the model cannot split its displayed and unsorted lists. It is intentionally a state model, not a feature-facing controller: only `viewer` accesses it | | `lifecycle.go` | The package-local async contract: zero-value `revision` and `requestLifecycle` plus immutable `requestToken`. Beginning or invalidating a request advances its revision and cancels the previous context; background work checks the token both before expensive work and before applying through `fyne.Do`. Load, scan, sort, and vector each own an instance rather than sharing invalidation accidentally | | `viewer.go` | The `viewer` façade and orchestration hub: Fyne/UI state, navigation, image cache, and the small methods that apply `appState` changes to the screen. It owns title handling, `clearToDropzone`/`reset`/`showWelcomeState`, `closeFiles` (the File menu's "Close Files" item — like `reset` but never closes the window), `undoGridMaximize` (undoes a grid-triggered `winpos.Maximize` — see `grid/`'s `ConsumeMaximized` — before a resize elsewhere tries to shrink the window back down), merge-mode toggle/get/set, `showFileIfPresent`, and the exported vocabulary the feature packages' narrow `Host` interfaces bind to (`CurrentFile`, `RemoveFile`, `RemoveFiles`, `ShowImage`, `ShowToast`, `ShowEmptyStateError`, `ForceRepaint`, `FileCount`, `FileAt`, `OpenFiles`, `CurrentIndex`, `Generation`, `Unfocus`, `Modifiers`, `Advance`). `Generation` is the dedicated index-to-URI file-set revision consumed by grid and deletion; navigation does not move it. `OpenFiles` sends a favorite's stored list through the existing drop/scan path; `RemoveFiles` is the batch form `internal/ui/deletion` binds to - descending, duplicate-tolerant, and the one place the grid is reconciled after the file set shrinks (`grid.FilesChanged`, plus `grid.Close` once nothing is left); plain `RemoveFile` stays for `load.go`'s failed-decode retry | | `keys.go` | `handleKeyEvent` — the single keyboard dispatcher every unmodified key press runs through — plus `handleTypedRune`, its typed-*character* twin (wired to `SetOnTypedRune` alongside it in `build.go`). The grid's filename search is the only consumer of actual characters rather than key names, so runes are delivered only while the grid is up and dropped everywhere else | -| `menu.go` | `buildMainMenu` — the window's menu bar: a File menu (Open Files…/Save Changes/Export as PNG…/Export as JPEG…/Set as Wallpaper/Close Files/Settings…, built here) composed with `help.Menu()`'s Help menu. The one place that decides how the two compose, per the cross-feature-composition rule below | +| `menu.go` | `buildMainMenu` — the window's menu bar: a File menu (Open Files…/Save Changes/Export as PNG…/Export as JPEG…/Set as Wallpaper/Close Files/Settings…, built here) composed with the Favorites and Help feature menus. The one place that decides how those menus compose, per the cross-feature-composition rule below | | `drop.go` | Drop handling and the recursive folder scan: `handleDrop`, its shared completion step `applyScanResult`, `applyScannedFiles` (merges or replaces the file set, then reorders and displays it via `sort.go`'s `startSort` instead of sorting inline on the UI goroutine), `cancelScan`, `realPathOf`, the `maxScan` cap and its `MaxScan`/`SetMaxScan` get/set (the settings window's binding). Scans own `viewer.scanLifecycle`, independent of navigation; a new drop or explicit cancellation supersedes them | | `memlimits.go` | The app's memory budget: the three limits bounding how much decoded-image memory can be held, and the settings window's getter/setter pairs for them (`MaxImageCacheMB`/`SetMaxImageCacheMB`, `MaxThumbCacheMB`/`SetMaxThumbCacheMB`, `MaxFileSizeMB`/`SetMaxFileSizeMB`), plus `bytesPerMB` and the shipped defaults derived from `internal/imaging`'s own. Grouped in one file rather than each sitting beside its consumer the way `MaxScan` (drop.go) and `MaxWindowWidth` (load.go) do, because they have no single consumer — the image cache is read in `load.go`, the thumbnail cache lives in `internal/ui/grid`, and the encoded-input ceiling is process-wide state in `internal/imaging` — while together they are one coherent thing. Each setter reaches through to what actually enforces the limit (`imgCache.SetBudget`, `grid.SetCacheBytes`, `imaging.SetMaxEncodedBytes`); `SetMaxImageCacheMB` additionally retunes the SVG re-render ceiling through `vectorRasterPixelsFor` + `imaging.SetMaxVectorRasterPixels`, since that raster is deliberately never charged to the cache and the derivation is how the user's one memory setting still bounds it | | `load.go` | Loading and displaying images: `ShowImage`/`attemptLoad`/`finishLoad`/`retryAfterLoadFailure`, neighbor preloading (which bails on the *header* when a neighbor's `imaging.EstimateDecodedBytes` exceeds half the cache budget, and writes with `AddIfFits` rather than `Add`, so a speculative decode can never displace the image on screen), the GIF `animate` loop, `resizeToImage` (takes its `maxW`/`maxH` cap as parameters rather than reading a package constant, so each call site passes the viewer's own `maxWinW`/`maxWinH`) plus `defaultMaxWindowWidth`/`defaultMaxWindowHeight` and the `MaxWindowWidth`/`SetMaxWindowWidth`/`MaxWindowHeight`/`SetMaxWindowHeight` get/set pairs (the settings window's binding). One `loadLifecycle` token spans a decode's retry chain, preloads, and animation; cancellation wakes semaphore/frame-delay waits promptly | @@ -54,7 +63,7 @@ interface. | `sort.go` | `toggleSort` (the `S` key) and `SetSortMode`/`SortMode` (the settings window's binding — jumps directly to a mode rather than cycling, safe to call before any files are loaded), plus `startSort`/`finishSort`: the shared background-reorder mechanism (own spinner/label, `sortLifecycle`, completion callback) used by both `SetSortMode` and `drop.go`'s `applyScannedFiles`, so neither freezes the UI on a large stat/Exif-heavy sort. Only a current token may hide progress, clear `sorting`, or invoke the callback; the orderings themselves live in `internal/filesort` | | `rotate.go` | View-only 90°-step image rotation (`rotateBy`/`resetRotation`/`redrawRotatedFrame`), composed with EXIF orientation at render time - not written to disk until the File > Save Changes action (see `save.go`) explicitly persists it. Stays here rather than becoming a package: it writes `img.Image` on the core load/animation path, which is the app's own side of the contract `zoom/` is written against. `applyRotationLayout` also hands `zoom` a local, axis-swapped *copy* of `v.vectorLogical` on a 90/270° turn - never the field itself, which stays unrotated since the re-render target in `vector.go` is built from it - since a vector's fit scale would otherwise be computed against the wrong axis. Both `rotateBy` and `resetRotation` call `updateFileMenuState` *before* `applyRotationLayout`, not after: the layout call's own resize can synchronously spawn a `vector.go` re-render goroutine (a real, single-goroutine-serialized non-issue in production, since `fyne.Do` there marshals onto the UI goroutine - but the fake test driver runs a `fyne.Do` callback inline instead, so `updateFileMenuState`'s read of `v.img.Image` afterward could otherwise race that goroutine's write under `-race`); ordering it first removes the race outright rather than narrowing it, since `updateFileMenuState` needs nothing `applyRotationLayout` computes | | `vector.go` | The debounced SVG re-render: how an image stays sharp as the display scale moves, once `internal/imaging` owns the parsing and rasterizing. `requestVectorRender` is `zoom`'s `onScaleChanged` handler - fired for a key, a scroll, or a fit-driven resize - and decides whether the new density is worth a fresh raster via `vectorNeedsRender`'s hysteresis band (`vectorSharpenRatio`/`vectorReleaseRatio`: over 1.05x denser to sharpen, under 0.5x to release memory on the way back out), so a slow scroll or a round-trip zoom doesn't re-render every frame. `rasterizeVector` runs off the UI goroutine under `vectorLifecycle`, whose cancellation coalesces a burst by waking superseded debounce waits; it checks its token before rasterizing and again on the `fyne.Do` hand-off. `clearVector` invalidates that lifecycle on every image change. The aliasing rule `finishLoad` also observes: a vector's displayed frame is replaced in place by every re-render, so it is given its own one-element `displayFrames` slice rather than sharing the cached `LoadedImage`'s backing array - writing through that would mutate the cache entry and invalidate the byte weight `ByteCache` already computed for it | -| `save.go` | `canSaveRotation`/`saveRotation`/`updateFileMenuState`: the File menu's "Save Changes" item (also Cmd/Ctrl+S, see `build.go`'s `wireSaveShortcut`) that persists `rotate.go`'s view-only rotation back to the file it came from, via `internal/imaging`'s `SaveRotated`/`CanEncode`. Disabled except when there's a loaded, non-animated, encodable-format image with a pending rotation and no load in flight - see `canSaveRotation`'s own doc comment for why each of those matters; a successful save folds the just-written pixels into `displayFrames` and resets `rotation` to 0 so nothing visibly changes. `updateFileMenuState` lives here but drives all four image-dependent File-menu items - `export.go`'s two and `wallpaper.go`'s one included - since every site that calls it can move both conditions at once | +| `save.go` | `canSaveRotation`/`saveRotation`/`updateFileMenuState`: the File menu's "Save Changes" item (also Cmd/Ctrl+S, see `shortcuts.go`'s `wireSaveShortcut`) that persists `rotate.go`'s view-only rotation back to the file it came from, via `internal/imaging`'s `SaveRotated`/`CanEncode`. Disabled except when there's a loaded, non-animated, encodable-format image with a pending rotation and no load in flight - see `canSaveRotation`'s own doc comment for why each of those matters; a successful save folds the just-written pixels into `displayFrames` and resets `rotation` to 0 so nothing visibly changes. `updateFileMenuState` lives here but drives all four image-dependent File-menu items - `export.go`'s two and `wallpaper.go`'s one included - since every site that calls it can move both conditions at once | | `export.go` | `canExport`/`exportAs`/`runExport`/`suggestedExportPath`/`exportDestination`: the File menu's "Export as PNG…"/"Export as JPEG…" items, which write the frame on screen to a **new** file via `internal/filepicker`'s `ChooseSave` and `internal/imaging`'s `Export`. `canExport` is deliberately far weaker than `save.go`'s `canSaveRotation` - no encodable source format, no single-frame requirement, no pending rotation - because an export picks the *destination's* format, which is how pixels get out of a WebP/HEIC (decode-only here) or out of one frame of an animation; only the `!v.loading.Load()` guard is shared, and for the same reason. `exportDestination` holds the rule that keeps a file's bytes matching its name: an extension the user typed that this module can encode wins over the menu item, otherwise the menu item's extension is appended. Reuses `chooserDone` rather than a channel of its own - both panels are app-modal, so the open chooser and the save panel are never in flight at once | | `wallpaper.go` | `canSetWallpaper`/`setAsWallpaper`/`applyWallpaper`/`writeWallpaperFile`/`sweepWallpapers`/`defaultWallpaperDir`: the File menu's "Set as Wallpaper" item, over `internal/wallpaper`. `canSetWallpaper` is `canExport` verbatim, for the same reasons - what this writes is a PNG of the frame on screen, so neither the source format nor an animation nor a pending rotation matters, while a load in flight still does. It writes that PNG into `viewer.wallpaperDir` (`os.UserCacheDir()/picfetch/wallpapers`, a `t.TempDir()` under test) rather than pointing the OS at the user's own file, because every platform in `internal/wallpaper` stores a *reference*: the user's file is one Shift+Delete away from leaving the desktop with a broken wallpaper, and the copy also carries the rotation, one frame of an animation, and any decode-only format. The name carries a timestamp because macOS caches the desktop picture by path; `sweepWallpapers` is what keeps that from accumulating a file per invocation, and it deliberately runs only after `wallpaper.Set` succeeds - a failed set removes just the file it wrote, since the previous one may still be the live wallpaper | | `slideshow.go` | `togglePictureFrameMode` — the five-line glue that closes the grid before handing over to `slideshow.Toggle`, i.e. the one thing the slideshow package must not know — plus `toggleSlideshowShuffle` and the `SlideShuffle`/`SetSlideShuffle`/`SlideInterval`/`SetSlideInterval` get/set pairs (the settings window's binding) | @@ -166,11 +175,11 @@ order, the folder-scan cap, the window-size cap, window size and position) acros |------------------|---------------------------------------------------------------------------------------------------------| | `preferences.go` | `Save`, `Load`, `State`, `WindowGeometry`, unexported preference keys and `saveGeometry`/`loadGeometry` | -Added 2026-08-14, mirroring `internal/session`'s shape. `internal/ui`'s -`buildViewer` loads the saved `State` to seed `sortMode`/`mergeMode`/the slideshow interval and the initial window size -and position, and `Run`'s -`SetOnStopped` saves the current values back, alongside the existing -`session.Save` call. `WindowPosX`/`WindowPosY`/`WindowPositionSet` +Added 2026-08-14, mirroring `internal/session`'s shape. `internal/ui/startup.go`'s +`buildStartupViewer` loads the saved `State`, normalizes only unset caps, hands it to `buildViewer` to seed standing +feature preferences, and then restores main and secondary window geometry. `Run` starts runtime position polling only +after that helper returns, and its `SetOnStopped` saves the current values back alongside the existing `session.Save` call. +`WindowPosX`/`WindowPosY`/`WindowPositionSet` were added 2026-08-14 for manual-window-move persistence — see `internal/winpos` for why reading a position back needs a whole package of its own where reading a size back didn't. `SortMode` (added 2026-08-14 for the multi-criteria sort feature) is a string, not an enum: it was originally a string @@ -184,7 +193,7 @@ zero-means-unset sentinel `WindowSize` does, since the viewer itself never accep `internal/ui/drop.go`'s `defaultMaxScannedFiles` and `internal/ui/load.go`'s `defaultMaxWindowWidth`/`defaultMaxWindowHeight` fallbacks in -`buildViewer`). `MaxImageCacheMB`/`MaxThumbCacheMB`/`MaxFileSizeMB` were added 2026-08-16 with the byte-bounded image +`startup.go`). `MaxImageCacheMB`/`MaxThumbCacheMB`/`MaxFileSizeMB` were added 2026-08-16 with the byte-bounded image memory work and follow the identical pattern (`internal/ui/memlimits.go` holds their defaults and setters). They are stored in megabytes rather than bytes on purpose: that is the unit the user typed into the Settings window, so it is the unit that round-trips, and the conversion to the byte budgets `internal/imaging` enforces happens in the setters. @@ -215,13 +224,14 @@ dependency on `viewer`. | `linux.go` | `platformPosition` — cgo/Xlib `XTranslateCoordinates`; reports `ok=false` on Wayland (no such handle exists there), matching `RequestPosition`'s own documented Wayland limitation. `platformMaximize`/`platformUnmaximize` — an EWMH `_NET_WM_STATE` `ClientMessage` adding/removing both maximized states, X11-only for the same reason | | `other.go` | `platformPosition` — always `ok=false`, for BSD/mobile/wasm/anything else. `platformMaximize`/`platformUnmaximize` — no-ops | -Added 2026-08-14 for the "restore the window where the user manually left it" feature: `internal/ui`'s `buildViewer` -seeds `viewer.winPos` from the saved preference and applies it to the window, and starts -`startWindowPosPolling` (`windowtrack.go`), a background goroutine that keeps the tracker current via `Capture` since — +Added 2026-08-14 for the "restore the window where the user manually left it" feature: `internal/ui`'s +`buildStartupViewer` uses `restoreStartupGeometry` to seed `viewer.winPos` from the saved preference and apply it to the +window, then `Run` calls `startViewerRuntime`, which starts `startWindowPosPolling` (`windowtrack.go`), a background +goroutine that keeps the tracker current via `Capture` since — unlike a resize — a pure window-drag triggers no layout pass for `windowSizeTracker` to piggyback on. The poller only ever runs against a real `driver.NativeWindow` (checked once up front), so the fyne test driver's windows — every test -in -`internal/ui`, via `buildViewer`/`newTestViewer` — never get a poller goroutine at all. `internal/ui/slideshow` captures +in `internal/ui`, including the focused runtime-start test — receive a no-op stop callback instead of a poller +goroutine. `internal/ui/slideshow` captures and restores the same tracker around full-screen, so leaving picture-frame mode puts the window back at the manually-placed position instead of wherever the OS chose to un-full-screen it to. The `Tracker` (added 2026-08-14, stage 8) is what gives those three consumers one place to share that state, rather than a set of atomics on the viewer @@ -432,7 +442,7 @@ Use `errcheck` command to check for unhadled errors. - "How is EXIF orientation handled?" → `internal/imaging/exif.go` + `orientation.go` - "How does drag-and-drop / folder scanning work?" → `drop.go`'s `handleDrop` - "How is an image shown/preloaded/animated once loaded?" → `load.go` -- "Which keys do what?" → `keys.go`'s `handleKeyEvent` (key names) and `handleTypedRune` (typed characters, grid search only) +- "Which keys do what?" → `keys.go`'s `handleKeyEvent` (key names) and `handleTypedRune` (typed characters, grid search only) + `shortcuts.go` (application-wide modified-key shortcuts) - "How do I find one file by name in a big drop?" → `internal/ui/grid`'s `HandleRune`/`applyFilter`/`fileIndex` (the `/` search, the display→host index mapping it needs, and the search bar) + `keys.go`'s `handleTypedRune` (how a typed character reaches it) @@ -461,7 +471,7 @@ Use `errcheck` command to check for unhadled errors. - "How does the slideshow / picture-frame mode work?" → `internal/ui/slideshow` (the mode itself) + `slideshow.go` (the grid guard around it) - "How does delete work?" → `internal/ui/deletion` (the flow, single and batch) + `internal/trash` (per-OS - move-to-Trash) + `build.go`'s `wireDeleteShortcut` and `batch.go`'s `requestDelete` (how Shift+Delete reaches it, + move-to-Trash) + `shortcuts.go`'s `wireDeleteShortcut` and `batch.go`'s `requestDelete` (how Shift+Delete reaches it, and how it picks between the file on screen and the grid's selection) - "How are native file dialogs implemented?" → `internal/filepicker/` (per-OS open chooser and save panel) + `openfiles.go` and `export.go` (the `*viewer` glue for each) @@ -474,13 +484,13 @@ Use `errcheck` command to check for unhadled errors. Files/Settings…, composed with `help.Menu()`) + `internal/ui/settingswin` (the Settings window itself) + `viewer.go`'s `closeFiles` (what "Close Files" runs) - "How are preferences (sort order, merge mode, slideshow interval/shuffle, folder-scan cap, window-size cap, window - size/position) persisted?" → `internal/preferences/preferences.go` (persistence) + `internal/ui`'s `build.go` - (`buildViewer`) and `windowtrack.go` (the size/position trackers) and `run.go` (`currentPreferences`, the shutdown - save) + size/position) persisted?" → `internal/preferences/preferences.go` (persistence) + `internal/ui/startup.go` + (load/default normalization/geometry restoration) + `features.go` (applying loaded feature preferences) + + `windowtrack.go` (the size/position trackers) + `run.go` (`currentPreferences`, the shutdown save) - "How do the Settings and EXIF windows come back where I left them?" → `internal/ui/widgets`'s `Singleton.Remember`/ `Geometry`/`StopTracking` and `NewSizeTracker` (the mechanism, shared by both windows) + `winpos.Poll` (the position half) + `preferences.WindowGeometry` (the storage) + `windowtrack.go`'s `widgetGeometry`/`prefGeometry` (the - translation), seeded in `buildViewer` and read back in `currentPreferences`. A `Singleton` nobody calls `Remember` on + translation), seeded by `restoreStartupGeometry` and read back in `currentPreferences`. A `Singleton` nobody calls `Remember` on — the manual, the About box — is unaffected and keeps no geometry at all - "How is the window's on-screen position read back, since Fyne has no getter for it?" → `internal/winpos/` (per-OS native handle read + the `Tracker` that remembers the last good one + `Poll`, the background sampler that keeps a diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b224d4d..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,11 +0,0 @@ -# PicFetch — notes for Claude - -- **Start with `ARCHITECTURE.md`** — accurate package map and "where to look for X" index. - Update it in the same change whenever the package structure changes (its own rule). -- Open work lives in `todos.md`; do not add TODO/FIXME comments to code. -- Build/test via the Makefile: `make test` (or `go test -race ./...` from this directory), - `make run`, `make build`. Golden-image e2e tests live in `testdata/` and are - machine/Fyne-version specific — failures land in `testdata/failed/` for comparison. -- User-visible strings go through `lang.L`; add the key to every bundle in - `translations/` (`main_test.go` fails if a locale drifts). -- Never run `git commit` — print a suggested commit message; the user commits themselves. diff --git a/FyneApp.toml b/FyneApp.toml index 704bc52..d1c3a5b 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -2,7 +2,7 @@ Name = "PicFetch" ID = "io.github.frathe.picfetch" Version = "0.1.7" -Build = 320 +Build = 325 [Migrations] fyneDo = true diff --git a/README.md b/README.md index 44ff984..fcb4532 100644 --- a/README.md +++ b/README.md @@ -194,12 +194,14 @@ seams — live in `internal/uitest`. ### End-to-end suite (`internal/ui/e2e_test.go`) Rather than a hand-copied replica of the UI that could drift out of sync, -the e2e tests drive the *real* app: `buildViewer(application fyne.App)` in -[internal/ui/build.go](internal/ui/build.go) is the exact widget/handler -wiring `main()` runs live, factored out so tests can call it too. Every -test in the package builds a fresh window through it (`newTestUI`), drives -it the way a user would — `handleDrop` for a drop, `handleKeyEvent` for a -key press — and checks two things: +the e2e tests drive the *real* app: `buildViewer(application fyne.App, +startup startupState)` in [internal/ui/build.go](internal/ui/build.go) is +the exact top-level widget/handler wiring `Run` uses, including the ordered +feature construction in [internal/ui/features.go](internal/ui/features.go), after +[internal/ui/startup.go](internal/ui/startup.go) loads startup state. Every +test in the package mirrors that load/build/geometry-restoration path +through `newTestUI`, then drives it the way a user would — `handleDrop` for +a drop, `handleKeyEvent` for a key press — and checks two things: - **State** — `v.files`, `v.index`, and widget visibility (`.Visible()`). Fast, exact, and portable; this is the real regression guard. @@ -250,11 +252,16 @@ for a drop. Add the matching wait if you add a scenario that starts one. ```sh main.go Entry point: app setup, translations, CLI arguments internal/ui/ The application - the viewer core and the key dispatcher - run.go Run(): builds the window, wires startup/shutdown - build.go buildViewer(): the whole widget tree, in one place + run.go Run(): explicit startup/runtime/shutdown lifecycle + startup.go Loads startup state, normalizes defaults, restores geometry + build.go buildViewer(): top-level window/overlay composition + components.go App-owned widget clusters and fixed-height layout + features.go Explicit ordered construction of all eight feature modules + shortcuts.go Ordered global modified-key shortcut registration zoom/ grid/ One package per feature that owns its own state, slideshow/ help/ each declaring only what it needs from the app deletion/ exifwin/ + settingswin/ favorites/ widgets/ Shared viewer-free UI mechanics assets/ Placeholder/welcome art, embedded at build time help/manual.md End-user manual, embedded at build time diff --git a/internal/ui/build.go b/internal/ui/build.go index 0a2e249..1c352c7 100644 --- a/internal/ui/build.go +++ b/internal/ui/build.go @@ -1,220 +1,31 @@ -// Construction and wiring: buildViewer and the per-feature widget -// constructors it composes, plus the keyboard-shortcut wiring. Each -// new*UI constructor builds one feature's widget cluster and returns it as -// a small struct - the widgets themselves still land in the viewer's flat -// fields for now. +// Application assembly and wiring: buildViewer composes app-owned +// components with the registered feature widgets, builds the root overlay +// stack, and installs the window's input handlers. package ui import ( "fmt" "image" - "image/color" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/frathe/picfetch/internal/filesort" "github.com/frathe/picfetch/internal/imaging" - "github.com/frathe/picfetch/internal/preferences" - "github.com/frathe/picfetch/internal/session" - "github.com/frathe/picfetch/internal/ui/assets" - "github.com/frathe/picfetch/internal/ui/deletion" - "github.com/frathe/picfetch/internal/ui/exifwin" - "github.com/frathe/picfetch/internal/ui/favorites" - "github.com/frathe/picfetch/internal/ui/grid" - "github.com/frathe/picfetch/internal/ui/help" - "github.com/frathe/picfetch/internal/ui/settingswin" - "github.com/frathe/picfetch/internal/ui/slideshow" - "github.com/frathe/picfetch/internal/ui/widgets" - "github.com/frathe/picfetch/internal/ui/zoom" ) -// fixedHeightLayout wraps a single object, forcing its MinSize height to a -// fixed value instead of the object's natural (themed) size, while the -// object still fills whatever size it's ultimately resized to. -type fixedHeightLayout struct { - height float32 -} - -func (f fixedHeightLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { - var w float32 - for _, o := range objects { - w = fyne.Max(w, o.MinSize().Width) - } - return fyne.NewSize(w, f.height) -} - -func (f fixedHeightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { - for _, o := range objects { - o.Resize(size) - o.Move(fyne.NewPos(0, 0)) - } -} - -// dropzoneUI is the empty-state drop zone: the rounded border box, the -// "Drop images here" hint, the restore-session link, the welcome and -// empty-state art, all inside one tappable area (root) that doubles as an -// "open files" button. -type dropzoneUI struct { - hint *widget.Label - restoreLink *widget.Hyperlink - welcomeArt *canvas.Image - emptyStateArt *canvas.Image - art *widgets.TappableArea - root *fyne.Container -} - -// newDropzoneUI builds the drop zone. onOpen runs when the zone is tapped -// (the "open files" fallback for users who never drag-and-drop - see -// openFileDialog in openfiles.go); onRestore when the restore-session link -// is. Both callbacks are invoked only ever on a later tap, so buildViewer -// can hand in closures over a viewer variable that isn't assigned yet. -func newDropzoneUI(onOpen, onRestore func()) dropzoneUI { - border := canvas.NewRectangle(color.Transparent) - border.StrokeColor = widgets.DropzoneBorderColor - border.StrokeWidth = widgets.DropzoneBorderWidth - border.CornerRadius = widgets.DropzoneBorderRadius - - hint := widget.NewLabelWithStyle(lang.L("Drop images here"), - fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) - - // restoreLink offers to reload the file set saved when the window last - // closed (see session.go). Shown only if a saved session actually - // exists - buildViewer sets its text and visibility once savedSession - // is known. - restoreLink := widget.NewHyperlink("", nil) - restoreLink.Hide() - restoreLink.OnTapped = onRestore - - // welcomeArt greets the user on first launch; handleDrop hides it for - // good the moment the first drop happens. emptyStateArt is shown only - // once an error subsequently leaves the drop zone empty (see ShowToast - // call sites in drop.go/load.go). Both share one min size so they occupy - // the exact same box on the right of the drop zone, and ImageFillContain - // scales their (much larger) source art down to fit inside it. - welcomeArt := canvas.NewImageFromResource(fyne.NewStaticResource("welcome.webp", assets.WelcomeWebP)) - welcomeArt.FillMode = canvas.ImageFillContain - welcomeArt.ScaleMode = canvas.ImageScaleSmooth - welcomeArt.SetMinSize(fyne.NewSize(widgets.WelcomeArtSize, widgets.WelcomeArtSize)) - - emptyStateArt := canvas.NewImageFromResource(fyne.NewStaticResource("placeholder.webp", assets.PlaceholderWebP)) - emptyStateArt.FillMode = canvas.ImageFillContain - emptyStateArt.ScaleMode = canvas.ImageScaleSmooth - emptyStateArt.SetMinSize(fyne.NewSize(widgets.WelcomeArtSize, widgets.WelcomeArtSize)) - emptyStateArt.Hide() - - // Tappable so the whole drop zone - not just the art - doubles as an - // "open files" button. restoreLink still gets its own taps: Fyne - // resolves a tap to the deepest matching Tappable under the pointer, so - // tapping restoreLink itself reaches its own OnTapped rather than this - // wrapper's, even though it's nested inside it. - art := widgets.NewTappableArea(container.NewBorder(nil, nil, nil, - container.NewStack(welcomeArt, emptyStateArt), - container.NewCenter(container.NewVBox(hint, restoreLink))), onOpen) - art.OnHover = func(hovering bool) { - if hovering { - border.StrokeColor = widgets.DropzoneHoverColor - } else { - border.StrokeColor = widgets.DropzoneBorderColor - } - border.Refresh() - } - - return dropzoneUI{ - hint: hint, - restoreLink: restoreLink, - welcomeArt: welcomeArt, - emptyStateArt: emptyStateArt, - art: art, - root: container.NewStack(border, art), - } -} - -// scanUI is the folder-scan progress indicator: an infinite spinner over a -// "Scanning... N images" counter, both hidden until handleDrop shows them. -type scanUI struct { - spinner *widget.ProgressBarInfinite - label *widget.Label -} - -func newScanUI() scanUI { - spinner := widget.NewProgressBarInfinite() - label := widget.NewLabelWithStyle(lang.L("Scanning... 0 images"), fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) - spinner.Hide() - label.Hide() - - return scanUI{spinner: spinner, label: label} -} - -// sortUI is the background-reorder progress indicator: an infinite spinner -// over a static "Sorting..." label, both hidden until startSort (sort.go) -// shows them - for a sort-mode change or for the reorder a finished drop -// hands over. A dedicated pair rather than reusing scanUI's - a background -// scan (a merge-mode drop) can still be in flight when a sort-mode change is -// requested, since handleKeyEvent's S-key guard only checks -// len(v.state.files)<2/v.loading, not v.scanning, and the two would otherwise -// fight over one pair of widgets. Unlike scanUI's label, this one's text -// never changes: the ask is only to show that a sort is running, not to -// track its progress the way the scan counter does. -type sortUI struct { - spinner *widget.ProgressBarInfinite - label *widget.Label -} - -func newSortUI() sortUI { - spinner := widget.NewProgressBarInfinite() - label := widget.NewLabelWithStyle(lang.L("Sorting..."), fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) - spinner.Hide() - label.Hide() - - return sortUI{spinner: spinner, label: label} -} - -// infoUI is the persistent info overlay (I key, see toggleInfoOverlay in -// info.go) - unlike the toast it never auto-hides itself, and it's several -// distinct lines rather than one centered message, so it uses the theme's -// own overlay-background/foreground pairing (the same one dialogs use) -// instead of the toast's fixed, deliberately loud warning colors - legible -// in both light and dark themes without hardcoding either. -type infoUI struct { - text *widget.Label - exifLink *widget.Hyperlink - card *fyne.Container -} - -// newInfoOverlayUI builds the info card. onShowExif backs the "Show EXIF -// data" link right below the card's own text (the click equivalent of the E -// key, see internal/ui/exifwin); like newDropzoneUI's callbacks it -// only ever runs on a later tap, so it may close over a not-yet-assigned -// viewer variable. -func newInfoOverlayUI(onShowExif func()) infoUI { - bg := canvas.NewRectangle(theme.Color(theme.ColorNameOverlayBackground)) - bg.CornerRadius = widgets.CardRadius - text := widget.NewLabel("") - text.Alignment = fyne.TextAlignLeading - - exifLink := widget.NewHyperlink(lang.L("Show EXIF data"), nil) - exifLink.OnTapped = onShowExif - - card := container.NewStack(bg, container.NewPadded(container.NewVBox(text, exifLink))) - card.Hide() - - return infoUI{text: text, exifLink: exifLink, card: card} -} - // buildViewer wires up every widget, the drop handler, and the key handler -// for a fresh window - exactly what main() runs live. Tests call it the -// same way, so e2e coverage exercises the real construction path instead -// of a hand-copied replica that can drift out of sync with it. -func buildViewer(application fyne.App) (*viewer, fyne.Window) { +// for a fresh window from inputs already loaded by loadStartupState. Tests +// call the same assembly path, so e2e coverage cannot drift from Run. +func buildViewer(application fyne.App, startup startupState) (*viewer, fyne.Window) { window := application.NewWindow(appTitle) + savedSession := startup.savedSession + prefs := startup.prefs // Declared ahead of the constructors below so their tap/click callbacks // can close over it: a callback only ever runs on a later interaction, @@ -240,84 +51,37 @@ func buildViewer(application fyne.App) (*viewer, fyne.Window) { loadingBar := widget.NewProgressBarInfinite() loadingBar.Hide() - // Loaded now, ahead of the struct literal below, so savedSession is - // ready the moment view exists - restoreLink's own visibility is set - // right after, once view.restoreSession has somewhere to close over. - savedSession := session.Load(application) - - // Loaded alongside savedSession so sortMode/mergeMode start from the - // previous run's standing preferences instead of the shipped defaults - - // see internal/preferences. prefs.SlideInterval and prefs.WindowSize are - // applied further below, once view/window exist to apply them to. - prefs := preferences.Load(application) - - // maxScan falls back to the shipped default when nothing was ever - // saved (prefs.MaxScanFiles's zero value - see preferences.State's own - // comment on that field), the same zero-means-unset pattern - // prefs.SlideInterval already uses below. maxWinW/maxWinH do the same - // for the window-size cap resizeToImage (load.go) enforces. - maxScan := defaultMaxScannedFiles - if prefs.MaxScanFiles > 0 { - maxScan = prefs.MaxScanFiles - } - maxWinW := float32(defaultMaxWindowWidth) - if prefs.MaxWindowWidth > 0 { - maxWinW = prefs.MaxWindowWidth - } - maxWinH := float32(defaultMaxWindowHeight) - if prefs.MaxWindowHeight > 0 { - maxWinH = prefs.MaxWindowHeight - } - - // The three memory limits (memlimits.go) fall back the same way. The - // image cache's budget is applied in the literal below, since the cache - // is built there; the other two need view/grid to exist first and are - // applied through their setters further down. - imgCacheMB := defaultMaxImageCacheMB - if prefs.MaxImageCacheMB > 0 { - imgCacheMB = prefs.MaxImageCacheMB - } - thumbCacheMB := defaultMaxThumbCacheMB - if prefs.MaxThumbCacheMB > 0 { - thumbCacheMB = prefs.MaxThumbCacheMB - } - maxFileMB := defaultMaxFileSizeMB - if prefs.MaxFileSizeMB > 0 { - maxFileMB = prefs.MaxFileSizeMB - } - view = &viewer{ - app: application, - win: window, - img: img, - hint: dz.hint, - dropzone: dz.root, - dropzoneArt: dz.art, - welcomeArt: dz.welcomeArt, - emptyStateArt: dz.emptyStateArt, - restoreLink: dz.restoreLink, - savedSession: savedSession, - loadingBar: loadingBar, - scanSpinner: scan.spinner, - scanLabel: scan.label, - sortSpinner: sortUIC.spinner, - sortLabel: sortUIC.label, - toast: toastComp, - infoText: info.text, - infoCard: info.card, - exifLink: info.exifLink, - state: newAppState(filesort.FromPref(prefs.SortMode), prefs.MergeMode), - baseTitle: appTitle, - help: help.New(application, appTitle, assets.WelcomeWebP), - exif: exifwin.New(application, func() (fyne.URI, bool) { return view.displayedFile() }), - imgCache: imaging.NewImgCache(int64(imgCacheMB) * bytesPerMB), - preloadSem: make(chan struct{}, preloadConcurrency), - maxScan: maxScan, - maxWinW: maxWinW, - maxWinH: maxWinH, - imgCacheMB: imgCacheMB, - wallpaperDir: defaultWallpaperDir(), - keyModifiers: defaultKeyModifiers, + app: application, + win: window, + img: img, + hint: dz.hint, + dropzone: dz.root, + dropzoneArt: dz.art, + welcomeArt: dz.welcomeArt, + emptyStateArt: dz.emptyStateArt, + restoreLink: dz.restoreLink, + savedSession: savedSession, + loadingBar: loadingBar, + scanSpinner: scan.spinner, + scanLabel: scan.label, + sortSpinner: sortUIC.spinner, + sortLabel: sortUIC.label, + toast: toastComp, + infoText: info.text, + infoCard: info.card, + exifLink: info.exifLink, + state: newAppState(filesort.FromPref(prefs.SortMode), prefs.MergeMode), + baseTitle: appTitle, + imgCache: imaging.NewImgCache(int64(prefs.MaxImageCacheMB) * bytesPerMB), + preloadSem: make(chan struct{}, preloadConcurrency), + maxScan: prefs.MaxScanFiles, + maxWinW: prefs.MaxWindowWidth, + maxWinH: prefs.MaxWindowHeight, + imgCacheMB: prefs.MaxImageCacheMB, + wallpaperDir: defaultWallpaperDir(), + keyModifiers: defaultKeyModifiers, + stopWinPosPoll: noPollerStop, } view.vectorDebounce = defaultVectorDebounce @@ -328,65 +92,14 @@ func buildViewer(application fyne.App) (*viewer, fyne.Window) { // applies the saved preference directly rather than through the // setter. Every later change goes through SetMaxImageCacheMB, which // keeps the two in step. - imaging.SetMaxVectorRasterPixels(vectorRasterPixelsFor(imgCacheMB)) + imaging.SetMaxVectorRasterPixels(vectorRasterPixelsFor(prefs.MaxImageCacheMB)) if n := len(savedSession); n > 0 { dz.restoreLink.SetText(fmt.Sprintf(lang.L("Restore last session (%d images)"), n)) dz.restoreLink.Show() } - // The zoom/pan view: its widget goes into the content Stack below in - // place of img itself, so it can override Stack's usual fill-container - // layout and so dragging the image pans it. Both funcs are wrapped in - // closures rather than passed as method values, so they resolve - // against the viewer at call time - which is what lets tests swap - // keyModifiers on an already-built viewer and have the scroll handler - // see the new one. - view.zoom = zoom.New(img, - func() { view.updateInfoOverlay() }, - func() fyne.KeyModifier { return view.keyModifiers() }, - view.requestVectorRender) - - // The full-window thumbnail grid (G key), built now for the same - // reason the zoom view is: grid.New takes the viewer as its Host. Also - // takes window directly, to maximize it on open - see grid.New. - view.grid = grid.New(view, window) - - // The thumbnail cache's budget and the encoded-input ceiling, applied - // through the same setters the settings window uses (memlimits.go) - - // the first needs view.grid to exist, and the second writes - // process-wide state in internal/imaging rather than a viewer field. - view.SetMaxThumbCacheMB(thumbCacheMB) - view.SetMaxFileSizeMB(maxFileMB) - - // The delete-confirmation flow, same reason again: deletion.New takes - // the viewer as its Host, so it can only be built once view exists. - view.deletion = deletion.New(view) - - // Picture-frame mode (P key), same reason once more - plus the window - // and the position tracker it captures and restores around - // full-screen, which is the same tracker startWindowPosPolling below - // keeps current the rest of the time. Built before the poller starts, - // since the poller reads its Active() on every tick. - view.slides = slideshow.New(view, window, &view.winPos) - if prefs.SlideInterval > 0 { - view.slides.SetInterval(prefs.SlideInterval) - } - view.slides.SetShuffle(prefs.SlideShuffle) - - // The settings window (File > Settings…), same reason once more: - // settingswin.New takes the viewer as its Host, so it can only be built - // once view exists. - view.settings = settingswin.New(application, view) - view.favorites = favorites.New(view, window) - - // Both secondary windows remember where the user last put them and how - // large they left them, the same way the main window does below - see - // widgets.Singleton, which owns the mechanism, and windowtrack.go for - // the translation. Seeded before either can be opened; Run's - // SetOnStopped reads the current values back out at shutdown. - view.settings.RestoreGeometry(widgetGeometry(prefs.SettingsWindow)) - view.exif.RestoreGeometry(widgetGeometry(prefs.ExifWindow)) + registerFeatures(view, application, window, prefs) // The bar lives in its own overlay layer on top of the stack, pinned to // the top edge by the VBox layout, so showing/hiding it never resizes @@ -421,30 +134,6 @@ func buildViewer(application fyne.App) (*viewer, fyne.Window) { view.grid.Overlay(), view.deletion.Overlay(), toastOverlay)) window.SetMainMenu(buildMainMenu(view)) - // The saved window size (see internal/preferences) is only ever the - // empty-dropzone size in practice: as soon as a file loads, - // resizeToImage takes over fitting the window to each image, the same - // as it always has. - initialSize := fyne.NewSize(startW, startH) - if prefs.WindowSize.Width > 0 && prefs.WindowSize.Height > 0 { - initialSize = prefs.WindowSize - } - window.Resize(initialSize) - - // The saved window position (see internal/preferences/internal/winpos) - // is applied the same way the saved size is above: RequestPosition - // before the window is actually shown just primes the coordinates the - // glfw driver's own window-creation path applies once it does run. - // Stored into the tracker first, and not only applied, so a shutdown - // before startWindowPosPolling's background poller (below) ever gets a - // fresh reading still has last launch's good value to hand - // preferences.Save, rather than losing it to a zero reading. - if prefs.WindowPositionSet { - view.winPos.Store(prefs.WindowPosX, prefs.WindowPosY) - view.winPos.Restore(window) - } - view.stopWinPosPoll = startWindowPosPolling(view, window) - window.SetOnDropped(func(_ fyne.Position, uris []fyne.URI) { view.handleDrop(uris) }) @@ -465,140 +154,7 @@ func buildViewer(application fyne.App) (*viewer, fyne.Window) { view.handleTypedRune(r) }) - wireOpenShortcuts(window.Canvas(), view) - wireFavoriteShortcuts(window.Canvas(), view.favorites.Open) - wireClipboardShortcuts(window.Canvas(), view) - wireDeleteShortcut(window.Canvas(), view) - wireSelectAllShortcut(window.Canvas(), view) - wireSaveShortcut(window.Canvas(), view) + wireGlobalShortcuts(window.Canvas(), view) return view, window } - -// shortcutAdder is the one method the shortcut wiring needs from fyne.Canvas, -// narrow enough that a bare *fyne.ShortcutHandler satisfies it too - -// so tests can drive the exact same wiring against that handler directly -// and then fire it via its own TypedShortcut, instead of going through a -// full canvas. That detour is load-bearing, not a style choice: Fyne's test -// driver canvas (fyne.io/fyne/v2/test) embeds software.WindowlessCanvas by -// interface, which doesn't include TypedShortcut, so a real Ctrl+O key -// event can never be simulated through it - only the production glfw -// driver's canvas has that method reachable at all (see -// internal/driver/glfw/window.go's triggersShortcut, which is what turns a -// real key-plus-modifier press into this call). -type shortcutAdder interface { - AddShortcut(shortcut fyne.Shortcut, handler func(shortcut fyne.Shortcut)) -} - -// wireOpenShortcuts binds Cmd/Ctrl+O and Cmd/Ctrl+Shift+O to the same -// native file/folder browser tapping the drop zone already opens -// (openFileDialog in openfiles.go). There's only one such dialog - it -// combines files and folders in one go, see internal/filepicker - so the -// second, modified binding isn't a second dialog, just a second way to -// reach the first one. Modified key combos never reach handleKeyEvent's -// SetOnTypedKey dispatch at all: Fyne's desktop driver intercepts them as -// shortcuts before TypedKey ever fires, which is why this needs -// AddShortcut instead of another case there. -func wireOpenShortcuts(c shortcutAdder, view *viewer) { - openShortcut := func(fyne.Shortcut) { view.openFileDialog() } - c.AddShortcut(&desktop.CustomShortcut{ - KeyName: fyne.KeyO, - Modifier: fyne.KeyModifierShortcutDefault, - }, openShortcut) - c.AddShortcut(&desktop.CustomShortcut{ - KeyName: fyne.KeyO, - Modifier: fyne.KeyModifierShortcutDefault | fyne.KeyModifierShift, - }, openShortcut) -} - -// wireFavoriteShortcuts binds the first ten sorted favorites to Cmd/Ctrl+1 -// through 9, then Cmd/Ctrl+0. The handlers stay registered while Feature.Open -// resolves each slot against the latest menu refresh after an add or removal. -func wireFavoriteShortcuts(c shortcutAdder, open func(index int)) { - for i := 0; i < favorites.ShortcutCount; i++ { - index := i - c.AddShortcut(favorites.ShortcutForIndex(index), func(fyne.Shortcut) { - open(index) - }) - } -} - -// wireClipboardShortcuts binds Cmd/Ctrl+C to copy the current image and -// Cmd/Ctrl+Shift+C to copy its file path (clipboard.go). Both need -// AddShortcut rather than handleKeyEvent's plain SetOnTypedKey dispatch, for -// the same reason wireOpenShortcuts does: modified key combos never reach -// TypedKey at all. Deliberately not gated behind handleKeyEvent's -// len(v.state.files)<2 navigation guard - both work fine with a single file -// loaded, and copyImageToClipboard/copyPathToClipboard already no-op safely -// when nothing is loaded yet. -// -// The plain Cmd/Ctrl+C binding is *not* a desktop.CustomShortcut, unlike -// every other shortcut in this file - that was the bug that shipped -// initially. Fyne's glfw driver special-cases the bare default-modifier -// forms of Z/Y/V/C/Insert/X/A (undo/redo/paste/copy/.../cut/select-all) into -// its own built-in fyne.Shortcut types *before* it ever considers building a -// desktop.CustomShortcut - see triggersShortcut in -// internal/driver/glfw/window.go, where that switch runs first and only -// falls through to a CustomShortcut when it didn't match. So a -// CustomShortcut registered for {KeyC, KeyModifierShortcutDefault} is -// simply never reachable by a real Cmd/Ctrl+C press; the driver dispatches a -// &fyne.ShortcutCopy{} instead, which needs its own AddShortcut entry to be -// caught. Shift+Cmd/Ctrl+C isn't one of the driver's special-cased combos, -// so it still becomes a CustomShortcut and needs no such treatment. -func wireClipboardShortcuts(c shortcutAdder, view *viewer) { - c.AddShortcut(&fyne.ShortcutCopy{}, func(fyne.Shortcut) { view.copySelection() }) - c.AddShortcut(&desktop.CustomShortcut{ - KeyName: fyne.KeyC, - Modifier: fyne.KeyModifierShortcutDefault | fyne.KeyModifierShift, - }, func(fyne.Shortcut) { view.copyPathToClipboard() }) -} - -// wireSelectAllShortcut binds Cmd/Ctrl+A to the grid's select-all -// (batch.go's selectAllInGrid). A third instance of the same driver quirk -// wireClipboardShortcuts documents: A is one of the bare combos -// triggersShortcut special-cases into a built-in fyne.Shortcut type -// (&fyne.ShortcutSelectAll{}) before it would ever build a -// desktop.CustomShortcut, so a CustomShortcut for {KeyA, -// KeyModifierShortcutDefault} could never be reached by a real key press. -func wireSelectAllShortcut(c shortcutAdder, view *viewer) { - c.AddShortcut(&fyne.ShortcutSelectAll{}, func(fyne.Shortcut) { view.selectAllInGrid() }) -} - -// wireDeleteShortcut binds Shift+Delete to open the permanent-delete -// confirmation card (deletion.Confirmer.Request). Same bug shape as -// Cmd/Ctrl+C above, different special case: triggersShortcut special-cases -// bare Shift+Delete into &fyne.ShortcutCut{Secondary: true} (its "alternative -// cut" binding, mirroring Shift+Insert for paste) *before* it would ever -// consider a desktop.CustomShortcut - and unlike the Ctrl+key cases, that -// function's CustomShortcut fallback explicitly skips building one whenever -// the modifier is bare Shift at all, so a CustomShortcut{KeyDelete, -// KeyModifierShift} registration wouldn't just be shadowed here, it could -// never be reached by any bare-Shift combo. So this needs an AddShortcut -// entry for &fyne.ShortcutCut{} instead - see deletion.ShortcutHandler -// (deletion.go) for how it tells a real Shift+Delete apart from a genuine -// Ctrl/Cmd+X (which reaches the same handler, Secondary false, and is -// correctly ignored: this app has no cut action). -// -// What it runs is batch.go's requestDelete rather than Confirmer.Request -// directly: the same key means the grid's selection while the overview is up -// and the file on screen otherwise, and deciding that is this package's job, -// not either feature package's. It used to be gated behind a `blocked` check -// that dropped the shortcut entirely while the grid was showing - there was -// nothing then for it to act on there, and the card would have opened hidden -// behind the grid's backdrop. Both of those are now handled instead of -// avoided (see the window stack in buildViewer). -func wireDeleteShortcut(c shortcutAdder, view *viewer) { - c.AddShortcut(&fyne.ShortcutCut{}, deletion.ShortcutHandler(view.requestDelete)) -} - -// wireSaveShortcut binds Cmd/Ctrl+S to saveRotation (save.go). S isn't one -// of the driver's specially-cased bare shortcuts (only Z/Y/V/C/Insert/X/A -// are - see wireClipboardShortcuts' comment), so a plain desktop. -// CustomShortcut reaches it the same way Cmd/Ctrl+O reaches -// wireOpenShortcuts. -func wireSaveShortcut(c shortcutAdder, view *viewer) { - c.AddShortcut(&desktop.CustomShortcut{ - KeyName: fyne.KeyS, - Modifier: fyne.KeyModifierShortcutDefault, - }, func(fyne.Shortcut) { view.saveRotation() }) -} diff --git a/internal/ui/components.go b/internal/ui/components.go new file mode 100644 index 0000000..db8ff88 --- /dev/null +++ b/internal/ui/components.go @@ -0,0 +1,192 @@ +// App-owned component construction: the fixed-height layout and the small +// widget clusters buildViewer composes into the window. Each new*UI +// constructor builds one cluster and returns it as a small struct - the +// widgets themselves still land in the viewer's flat fields for now. +package ui + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/frathe/picfetch/internal/ui/assets" + "github.com/frathe/picfetch/internal/ui/widgets" +) + +// fixedHeightLayout wraps a single object, forcing its MinSize height to a +// fixed value instead of the object's natural (themed) size, while the +// object still fills whatever size it's ultimately resized to. +type fixedHeightLayout struct { + height float32 +} + +func (f fixedHeightLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + var w float32 + for _, o := range objects { + w = fyne.Max(w, o.MinSize().Width) + } + return fyne.NewSize(w, f.height) +} + +func (f fixedHeightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + for _, o := range objects { + o.Resize(size) + o.Move(fyne.NewPos(0, 0)) + } +} + +// dropzoneUI is the empty-state drop zone: the rounded border box, the +// "Drop images here" hint, the restore-session link, the welcome and +// empty-state art, all inside one tappable area (root) that doubles as an +// "open files" button. +type dropzoneUI struct { + hint *widget.Label + restoreLink *widget.Hyperlink + welcomeArt *canvas.Image + emptyStateArt *canvas.Image + art *widgets.TappableArea + root *fyne.Container +} + +// newDropzoneUI builds the drop zone. onOpen runs when the zone is tapped +// (the "open files" fallback for users who never drag-and-drop - see +// openFileDialog in openfiles.go); onRestore when the restore-session link +// is. Both callbacks are invoked only ever on a later tap, so buildViewer +// can hand in closures over a viewer variable that isn't assigned yet. +func newDropzoneUI(onOpen, onRestore func()) dropzoneUI { + border := canvas.NewRectangle(color.Transparent) + border.StrokeColor = widgets.DropzoneBorderColor + border.StrokeWidth = widgets.DropzoneBorderWidth + border.CornerRadius = widgets.DropzoneBorderRadius + + hint := widget.NewLabelWithStyle(lang.L("Drop images here"), + fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) + + // restoreLink offers to reload the file set saved when the window last + // closed (see session.go). Shown only if a saved session actually + // exists - buildViewer sets its text and visibility once savedSession + // is known. + restoreLink := widget.NewHyperlink("", nil) + restoreLink.Hide() + restoreLink.OnTapped = onRestore + + // welcomeArt greets the user on first launch; handleDrop hides it for + // good the moment the first drop happens. emptyStateArt is shown only + // once an error subsequently leaves the drop zone empty (see ShowToast + // call sites in drop.go/load.go). Both share one min size so they occupy + // the exact same box on the right of the drop zone, and ImageFillContain + // scales their (much larger) source art down to fit inside it. + welcomeArt := canvas.NewImageFromResource(fyne.NewStaticResource("welcome.webp", assets.WelcomeWebP)) + welcomeArt.FillMode = canvas.ImageFillContain + welcomeArt.ScaleMode = canvas.ImageScaleSmooth + welcomeArt.SetMinSize(fyne.NewSize(widgets.WelcomeArtSize, widgets.WelcomeArtSize)) + + emptyStateArt := canvas.NewImageFromResource(fyne.NewStaticResource("placeholder.webp", assets.PlaceholderWebP)) + emptyStateArt.FillMode = canvas.ImageFillContain + emptyStateArt.ScaleMode = canvas.ImageScaleSmooth + emptyStateArt.SetMinSize(fyne.NewSize(widgets.WelcomeArtSize, widgets.WelcomeArtSize)) + emptyStateArt.Hide() + + // Tappable so the whole drop zone - not just the art - doubles as an + // "open files" button. restoreLink still gets its own taps: Fyne + // resolves a tap to the deepest matching Tappable under the pointer, so + // tapping restoreLink itself reaches its own OnTapped rather than this + // wrapper's, even though it's nested inside it. + art := widgets.NewTappableArea(container.NewBorder(nil, nil, nil, + container.NewStack(welcomeArt, emptyStateArt), + container.NewCenter(container.NewVBox(hint, restoreLink))), onOpen) + art.OnHover = func(hovering bool) { + if hovering { + border.StrokeColor = widgets.DropzoneHoverColor + } else { + border.StrokeColor = widgets.DropzoneBorderColor + } + border.Refresh() + } + + return dropzoneUI{ + hint: hint, + restoreLink: restoreLink, + welcomeArt: welcomeArt, + emptyStateArt: emptyStateArt, + art: art, + root: container.NewStack(border, art), + } +} + +// scanUI is the folder-scan progress indicator: an infinite spinner over a +// "Scanning... N images" counter, both hidden until handleDrop shows them. +type scanUI struct { + spinner *widget.ProgressBarInfinite + label *widget.Label +} + +func newScanUI() scanUI { + spinner := widget.NewProgressBarInfinite() + label := widget.NewLabelWithStyle(lang.L("Scanning... 0 images"), fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) + spinner.Hide() + label.Hide() + + return scanUI{spinner: spinner, label: label} +} + +// sortUI is the background-reorder progress indicator: an infinite spinner +// over a static "Sorting..." label, both hidden until startSort (sort.go) +// shows them - for a sort-mode change or for the reorder a finished drop +// hands over. A dedicated pair rather than reusing scanUI's - a background +// scan (a merge-mode drop) can still be in flight when a sort-mode change is +// requested, since handleKeyEvent's S-key guard only checks +// len(v.state.files)<2/v.loading, not v.scanning, and the two would otherwise +// fight over one pair of widgets. Unlike scanUI's label, this one's text +// never changes: the ask is only to show that a sort is running, not to +// track its progress the way the scan counter does. +type sortUI struct { + spinner *widget.ProgressBarInfinite + label *widget.Label +} + +func newSortUI() sortUI { + spinner := widget.NewProgressBarInfinite() + label := widget.NewLabelWithStyle(lang.L("Sorting..."), fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) + spinner.Hide() + label.Hide() + + return sortUI{spinner: spinner, label: label} +} + +// infoUI is the persistent info overlay (I key, see toggleInfoOverlay in +// info.go) - unlike the toast it never auto-hides itself, and it's several +// distinct lines rather than one centered message, so it uses the theme's +// own overlay-background/foreground pairing (the same one dialogs use) +// instead of the toast's fixed, deliberately loud warning colors - legible +// in both light and dark themes without hardcoding either. +type infoUI struct { + text *widget.Label + exifLink *widget.Hyperlink + card *fyne.Container +} + +// newInfoOverlayUI builds the info card. onShowExif backs the "Show EXIF +// data" link right below the card's own text (the click equivalent of the E +// key, see internal/ui/exifwin); like newDropzoneUI's callbacks it +// only ever runs on a later tap, so it may close over a not-yet-assigned +// viewer variable. +func newInfoOverlayUI(onShowExif func()) infoUI { + bg := canvas.NewRectangle(theme.Color(theme.ColorNameOverlayBackground)) + bg.CornerRadius = widgets.CardRadius + text := widget.NewLabel("") + text.Alignment = fyne.TextAlignLeading + + exifLink := widget.NewHyperlink(lang.L("Show EXIF data"), nil) + exifLink.OnTapped = onShowExif + + card := container.NewStack(bg, container.NewPadded(container.NewVBox(text, exifLink))) + card.Hide() + + return infoUI{text: text, exifLink: exifLink, card: card} +} diff --git a/internal/ui/e2e_test.go b/internal/ui/e2e_test.go index 4982540..5a68a79 100644 --- a/internal/ui/e2e_test.go +++ b/internal/ui/e2e_test.go @@ -1,7 +1,6 @@ -// e2e_test.go drives the real app the way a user would: buildViewer is the -// exact constructor main() calls, so these tests exercise the production -// wiring (widgets, drop handler, key dispatch) instead of a hand-copied -// replica that could quietly drift out of sync with it. +// e2e_test.go drives the real app the way a user would: these tests use the +// same startup load, buildViewer assembly, and geometry restoration as Run, +// so the production wiring cannot drift into a hand-copied test replica. // // Each scenario checks both state (files/visibility/index - fast, exact, // portable) and a full-window screenshot compared against a golden master @@ -57,9 +56,9 @@ func TestE2E_InitialLaunchShowsWelcome(t *testing.T) { } // TestE2E_HoveringDropzoneHighlightsBorderThenReverts exercises -// dropzoneArt's onHover wiring (build.go) - the border around the drop zone -// should brighten while the pointer is over it and return to exactly the -// initial-launch look once it leaves, confirmed against the same golden +// dropzoneArt's onHover wiring (components.go) - the border around the drop +// zone should brighten while the pointer is over it and return to exactly +// the initial-launch look once it leaves, confirmed against the same golden // master rather than a second one. func TestE2E_HoveringDropzoneHighlightsBorderThenReverts(t *testing.T) { v, win, _ := newTestUI(t) @@ -170,7 +169,7 @@ func TestE2E_LaunchWithSavedSessionShowsRestoreLink(t *testing.T) { b := uitest.TempJPEGURI(t, "b.jpg", 40, 30, color.RGBA{B: 200, A: 255}) session.Save(application, []fyne.URI{a, b}) - v, win := buildViewer(application) + v, win := buildStartupViewer(application) defer win.Close() if !v.restoreLink.Visible() { @@ -195,7 +194,7 @@ func TestE2E_LaunchWithSavedSessionShowsRestoreLink(t *testing.T) { } // TestE2E_TappingRestoreLinkRestoresNotFileDialog guards dropzoneArt's -// bigger tap target (build.go): restoreLink is now nested inside it, and +// bigger tap target (components.go): restoreLink is now nested inside it, and // Fyne's hit-testing must still resolve a tap on restoreLink's own rendered // position to restoreLink rather than to the wrapping dropzoneArt - or // tapping "Restore last session" would silently open the file chooser @@ -206,7 +205,7 @@ func TestE2E_TappingRestoreLinkRestoresNotFileDialog(t *testing.T) { saved := uitest.TempJPEGURI(t, "saved.jpg", 40, 30, color.RGBA{G: 200, A: 255}) session.Save(application, []fyne.URI{saved}) - v, win := buildViewer(application) + v, win := buildStartupViewer(application) defer win.Close() if !v.restoreLink.Visible() { diff --git a/internal/ui/exifwin/exifwin.go b/internal/ui/exifwin/exifwin.go index 928ba75..17aaadc 100644 --- a/internal/ui/exifwin/exifwin.go +++ b/internal/ui/exifwin/exifwin.go @@ -110,8 +110,8 @@ func (w *Window) Open() bool { } // RestoreGeometry makes the panel remember where and how large it was, -// seeded with what the last run left it at. Called once at construction -// (internal/ui's buildViewer); the app reads the current values back out of +// seeded with what the last run left it at. Called once during internal/ui's +// startup restoration; the app reads the current values back out of // Geometry at shutdown. Without it the panel opens at exifW x exifH // wherever the OS puts it, which is what it always did. func (w *Window) RestoreGeometry(g widgets.Geometry) { diff --git a/internal/ui/features.go b/internal/ui/features.go new file mode 100644 index 0000000..a15203f --- /dev/null +++ b/internal/ui/features.go @@ -0,0 +1,54 @@ +package ui + +import ( + "fyne.io/fyne/v2" + + "github.com/frathe/picfetch/internal/preferences" + "github.com/frathe/picfetch/internal/ui/assets" + "github.com/frathe/picfetch/internal/ui/deletion" + "github.com/frathe/picfetch/internal/ui/exifwin" + "github.com/frathe/picfetch/internal/ui/favorites" + "github.com/frathe/picfetch/internal/ui/grid" + "github.com/frathe/picfetch/internal/ui/help" + "github.com/frathe/picfetch/internal/ui/settingswin" + "github.com/frathe/picfetch/internal/ui/slideshow" + "github.com/frathe/picfetch/internal/ui/zoom" +) + +// registerFeatures constructs every feature in dependency order. It only +// assigns the viewer's feature fields; build.go still decides how their +// widgets compose, and menu.go still decides how their menus compose. +func registerFeatures(view *viewer, application fyne.App, window fyne.Window, prefs preferences.State) { + view.help = help.New(application, appTitle, assets.WelcomeWebP) + view.exif = exifwin.New(application, func() (fyne.URI, bool) { + return view.displayedFile() + }) + + // Resolve these callbacks against the viewer at call time so tests can + // replace keyModifiers after construction. + view.zoom = zoom.New( + view.img, + func() { view.updateInfoOverlay() }, + func() fyne.KeyModifier { return view.keyModifiers() }, + view.requestVectorRender, + ) + + // The thumbnail-cache setter reaches into the grid, so the grid must be + // registered before saved cache limits are applied. + view.grid = grid.New(view, window) + view.SetMaxThumbCacheMB(prefs.MaxThumbCacheMB) + view.SetMaxFileSizeMB(prefs.MaxFileSizeMB) + + view.deletion = deletion.New(view) + + // Run starts the position poller only after buildViewer returns. Register + // the slideshow first because the poller's skip callback reads Active. + view.slides = slideshow.New(view, window, &view.winPos) + if prefs.SlideInterval > 0 { + view.slides.SetInterval(prefs.SlideInterval) + } + view.slides.SetShuffle(prefs.SlideShuffle) + + view.settings = settingswin.New(application, view) + view.favorites = favorites.New(view, window) +} diff --git a/internal/ui/features_test.go b/internal/ui/features_test.go new file mode 100644 index 0000000..44b9f15 --- /dev/null +++ b/internal/ui/features_test.go @@ -0,0 +1,27 @@ +package ui + +import "testing" + +func TestBuildViewer_RegistersAllEightFeatures(t *testing.T) { + view := newTestViewer(t) + + features := []struct { + name string + registered bool + }{ + {name: "help", registered: view.help != nil}, + {name: "EXIF", registered: view.exif != nil}, + {name: "zoom", registered: view.zoom != nil}, + {name: "grid", registered: view.grid != nil}, + {name: "deletion", registered: view.deletion != nil}, + {name: "slideshow", registered: view.slides != nil}, + {name: "settings", registered: view.settings != nil}, + {name: "favorites", registered: view.favorites != nil}, + } + + for _, feature := range features { + if !feature.registered { + t.Errorf("%s feature was not registered", feature.name) + } + } +} diff --git a/internal/ui/library_test.go b/internal/ui/library_test.go index ce5384a..b202c21 100644 --- a/internal/ui/library_test.go +++ b/internal/ui/library_test.go @@ -31,9 +31,9 @@ import ( // Sharing is safe because nothing in the viewer writes persistent state // during a test: preferences.Save and session.Save are only ever called // from main()'s SetOnStopped. The tests that do assert on persistence -// build their own app (test.NewApp) and hand it to buildViewer directly - -// keep doing that rather than saving into this one, which every other -// test expects to find empty. +// build their own app (test.NewApp) and run the startup/build path with it - +// keep doing that rather than saving into this one, which every other test +// expects to find empty. var testApp fyne.App func TestMain(m *testing.M) { @@ -122,11 +122,9 @@ func TestSetMaxWindowSize_FloorsAtTheDropZoneSize(t *testing.T) { // --- test viewer construction ---------------------------------------------- -// newTestUI builds a fresh app and window through buildViewer - the same -// constructor main() uses - so tests exercise the real construction path -// instead of a hand-copied replica. (Until stage 3 of refactoring.md there -// were two: this one, and a 75-line clone that had already drifted from -// production in four fields.) +// newTestUI builds a fresh app and window through the same startup load, +// assembly, and geometry restoration Run uses, without starting runtime +// polling or touching production favorites storage. // // It tracks whether the window has already been closed (e.g. by Escape, // mid-test) so cleanup never closes it a second time: Fyne's test driver's @@ -145,7 +143,7 @@ func newTestUI(t *testing.T) (v *viewer, win fyne.Window, closed func() bool) { // buildViewer was handed. Cheap, unlike test.NewApp - see testApp. fyne.SetCurrentApp(testApp) - v, win = buildViewer(testApp) + v, win = buildStartupViewer(testApp) // The auto-hide timer must never fire on its own mid-suite: its inline // fyne.Do (under the test driver) would write widgets concurrently with @@ -2403,9 +2401,26 @@ func TestInvalidateLoad_WakesAnimateImmediately(t *testing.T) { func TestStartWindowPosPolling_TestDriverGetsNoopStop(t *testing.T) { v := newTestViewer(t) + if v.stopWinPosPoll == nil { + t.Fatal("buildStartupViewer should initialize stopWinPosPoll") + } + v.stopWinPosPoll() // safe before Run replaces it with the live poller's stop + stop := startWindowPosPolling(v, v.win) if stop == nil { t.Fatal("startWindowPosPolling should never return a nil stop func") } stop() // must not panic or block } + +func TestStartWindowPosPolling_PanicsWithoutConstructedSlideshow(t *testing.T) { + const want = "ui: startWindowPosPolling called before slideshow construction" + + defer func() { + if got := recover(); got != want { + t.Fatalf("panic = %v, want %q", got, want) + } + }() + + startWindowPosPolling(&viewer{}, nil) +} diff --git a/internal/ui/memlimits.go b/internal/ui/memlimits.go index 8149c8f..f79f357 100644 --- a/internal/ui/memlimits.go +++ b/internal/ui/memlimits.go @@ -25,8 +25,8 @@ const bytesPerMB = 1 << 20 // The shipped defaults, derived from internal/imaging's own so there is // exactly one place each number is chosen - see DefaultImgCacheBytes, // DefaultThumbCacheBytes and DefaultMaxEncodedBytes for why each is what it -// is. Used by buildViewer when nothing was ever saved, the same -// zero-means-unset fallback maxScan and maxWinW/maxWinH already use. +// is. Used by startup preference normalization when nothing was ever saved, +// the same zero-means-unset fallback maxScan and maxWinW/maxWinH use. const ( defaultMaxImageCacheMB = imaging.DefaultImgCacheBytes / bytesPerMB defaultMaxThumbCacheMB = imaging.DefaultThumbCacheBytes / bytesPerMB diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 777e73a..ea97898 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -15,7 +15,7 @@ import ( func buildMainMenu(view *viewer) *fyne.MainMenu { open := fyne.NewMenuItem(lang.L("Open Files…"), func() { view.openFileDialog() }) // Display-only: the Cmd/Ctrl+O binding itself is wireOpenShortcuts's - // AddShortcut call in build.go. This just shows the same accelerator + // AddShortcut call in shortcuts.go. This just shows the same accelerator // as a hint next to the menu item. open.Shortcut = &desktop.CustomShortcut{ KeyName: fyne.KeyO, diff --git a/internal/ui/openfiles_test.go b/internal/ui/openfiles_test.go index 1e299d4..d0d999d 100644 --- a/internal/ui/openfiles_test.go +++ b/internal/ui/openfiles_test.go @@ -142,11 +142,11 @@ func TestOpenFileDialog_RunsChooserInBackground(t *testing.T) { settleChooser(t, v) } -// TestOpenShortcuts_InvokeFileDialog checks that wireOpenShortcuts (build.go) -// binds Cmd/Ctrl+O and Cmd/Ctrl+Shift+O to openFileDialog. It drives a bare -// *fyne.ShortcutHandler through the real wiring function rather than a full -// window/canvas: Fyne's test driver canvas (fyne.io/fyne/v2/test) embeds -// software.WindowlessCanvas by interface, which doesn't include +// TestOpenShortcuts_InvokeFileDialog checks that wireOpenShortcuts +// (shortcuts.go) binds Cmd/Ctrl+O and Cmd/Ctrl+Shift+O to openFileDialog. It +// drives a bare *fyne.ShortcutHandler through the real wiring function rather +// than a full window/canvas: Fyne's test driver canvas (fyne.io/fyne/v2/test) +// embeds software.WindowlessCanvas by interface, which doesn't include // TypedShortcut, so a real key-plus-modifier press can't be simulated // through it - only the production glfw driver's canvas exposes that // method (see wireOpenShortcuts's own comment). A bare ShortcutHandler is diff --git a/internal/ui/preferences_wiring_test.go b/internal/ui/preferences_wiring_test.go index 243a15d..bde232e 100644 --- a/internal/ui/preferences_wiring_test.go +++ b/internal/ui/preferences_wiring_test.go @@ -1,30 +1,35 @@ -// preferences_wiring_test.go covers the build.go/run.go glue around -// internal/preferences: buildViewer applying a previously saved State to a -// fresh viewer/window, and windowSizeTracker keeping viewer.windowSize -// current so main() has something accurate to save at shutdown. The -// persistence logic itself (Save/Load round-tripping, zero-value guards) is -// covered directly in internal/preferences. +// preferences_wiring_test.go covers the startup/build/run glue around +// internal/preferences: loading and normalizing a previously saved State, +// applying it to a fresh viewer/window, and keeping geometry current for +// shutdown. Persistence round trips and zero-value write guards are covered +// directly in internal/preferences. package ui import ( + "reflect" + "runtime" "testing" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/test" + "github.com/frathe/picfetch/internal/favstore" "github.com/frathe/picfetch/internal/filesort" "github.com/frathe/picfetch/internal/imaging" "github.com/frathe/picfetch/internal/preferences" ) -func TestBuildViewer_LoadsSavedPreferences(t *testing.T) { +func TestStartup_LoadsSavedPreferencesIntoViewer(t *testing.T) { application := test.NewApp() preferences.Save(application, preferences.State{ SortMode: preferences.SortBySize, MergeMode: true, SlideInterval: 7 * time.Second, SlideShuffle: true, + MaxScanFiles: 5000, + MaxWindowWidth: 1800, + MaxWindowHeight: 1100, MaxImageCacheMB: 384, MaxThumbCacheMB: 192, MaxFileSizeMB: 256, @@ -34,7 +39,7 @@ func TestBuildViewer_LoadsSavedPreferences(t *testing.T) { WindowPositionSet: true, }) - v, win := buildViewer(application) + v, win := buildStartupViewer(application) defer win.Close() t.Cleanup(func() { imaging.SetMaxEncodedBytes(0) }) // process-wide - see memlimits.go @@ -53,6 +58,15 @@ func TestBuildViewer_LoadsSavedPreferences(t *testing.T) { if got, want := win.Canvas().Size(), fyne.NewSize(700, 500); got != want { t.Errorf("initial window size = %v, want %v", got, want) } + if got, want := v.MaxScan(), 5000; got != want { + t.Errorf("MaxScan() = %d, want %d (from saved preferences)", got, want) + } + if got, want := v.MaxWindowWidth(), float32(1800); got != want { + t.Errorf("MaxWindowWidth() = %v, want %v (from saved preferences)", got, want) + } + if got, want := v.MaxWindowHeight(), float32(1100); got != want { + t.Errorf("MaxWindowHeight() = %v, want %v (from saved preferences)", got, want) + } // The three memory limits reach three different places (memlimits.go): // the image cache's own budget, the grid's, and process-wide state in @@ -83,7 +97,7 @@ func TestBuildViewer_LoadsSavedPreferences(t *testing.T) { } } -func TestBuildViewer_LoadsSavedSecondaryWindowGeometry(t *testing.T) { +func TestStartup_LoadsSavedSecondaryWindowGeometry(t *testing.T) { application := test.NewApp() preferences.Save(application, preferences.State{ SettingsWindow: preferences.WindowGeometry{ @@ -94,7 +108,7 @@ func TestBuildViewer_LoadsSavedSecondaryWindowGeometry(t *testing.T) { }, }) - v, win := buildViewer(application) + v, win := buildStartupViewer(application) defer win.Close() t.Cleanup(func() { imaging.SetMaxEncodedBytes(0) }) // process-wide - see memlimits.go @@ -117,13 +131,14 @@ func TestBuildViewer_LoadsSavedSecondaryWindowGeometry(t *testing.T) { // The shutdown save (Run's SetOnStopped) is what has to carry both windows' // geometry back out again - a round trip that is only worth anything if -// what buildViewer seeded above survives to the State that gets written. +// what startup restoration seeded above survives to the State that gets +// written. func TestCurrentPreferences_CarriesSecondaryWindowGeometry(t *testing.T) { application := test.NewApp() saved := preferences.WindowGeometry{X: 70, Y: 80, PositionSet: true, Size: fyne.NewSize(500, 400)} preferences.Save(application, preferences.State{SettingsWindow: saved, ExifWindow: saved}) - v, win := buildViewer(application) + v, win := buildStartupViewer(application) defer win.Close() t.Cleanup(func() { imaging.SetMaxEncodedBytes(0) }) // process-wide - see memlimits.go @@ -136,10 +151,10 @@ func TestCurrentPreferences_CarriesSecondaryWindowGeometry(t *testing.T) { } } -func TestBuildViewer_NoSavedPreferencesUsesShippedDefaults(t *testing.T) { +func TestStartup_OmittedPreferencesUseShippedDefaults(t *testing.T) { application := test.NewApp() - v, win := buildViewer(application) + v, win := buildStartupViewer(application) defer win.Close() if v.state.SortMode() != filesort.ByName { @@ -154,6 +169,15 @@ func TestBuildViewer_NoSavedPreferencesUsesShippedDefaults(t *testing.T) { if v.slides.Shuffle() { t.Error("slides.Shuffle() = true, want false (the shipped default)") } + if got, want := v.MaxScan(), defaultMaxScannedFiles; got != want { + t.Errorf("MaxScan() = %d, want %d (the shipped default)", got, want) + } + if got, want := v.MaxWindowWidth(), float32(defaultMaxWindowWidth); got != want { + t.Errorf("MaxWindowWidth() = %v, want %v (the shipped default)", got, want) + } + if got, want := v.MaxWindowHeight(), float32(defaultMaxWindowHeight); got != want { + t.Errorf("MaxWindowHeight() = %v, want %v (the shipped default)", got, want) + } if got, want := v.MaxImageCacheMB(), defaultMaxImageCacheMB; got != want { t.Errorf("MaxImageCacheMB() = %d, want %d (the shipped default)", got, want) } @@ -171,10 +195,144 @@ func TestBuildViewer_NoSavedPreferencesUsesShippedDefaults(t *testing.T) { } } +func TestNormalizePreferenceDefaults(t *testing.T) { + defaults := preferences.State{ + MaxScanFiles: defaultMaxScannedFiles, + MaxWindowWidth: defaultMaxWindowWidth, + MaxWindowHeight: defaultMaxWindowHeight, + MaxImageCacheMB: defaultMaxImageCacheMB, + MaxThumbCacheMB: defaultMaxThumbCacheMB, + MaxFileSizeMB: defaultMaxFileSizeMB, + } + custom := preferences.State{ + MaxScanFiles: 1, + MaxWindowWidth: 1, + MaxWindowHeight: 1, + MaxImageCacheMB: 1, + MaxThumbCacheMB: 1, + MaxFileSizeMB: 1, + } + negative := preferences.State{ + MaxScanFiles: -1, + MaxWindowWidth: -1, + MaxWindowHeight: -1, + MaxImageCacheMB: -1, + MaxThumbCacheMB: -1, + MaxFileSizeMB: -1, + } + sentinels := preferences.State{ + WindowSize: fyne.NewSize(0, 500), + WindowPosX: 17, + WindowPosY: -23, + WindowPositionSet: false, + SettingsWindow: preferences.WindowGeometry{ + PositionSet: true, + }, + ExifWindow: preferences.WindowGeometry{ + X: 31, Y: 32, Size: fyne.NewSize(430, 0), + }, + } + sentinelsWithDefaults := sentinels + sentinelsWithDefaults.MaxScanFiles = defaultMaxScannedFiles + sentinelsWithDefaults.MaxWindowWidth = defaultMaxWindowWidth + sentinelsWithDefaults.MaxWindowHeight = defaultMaxWindowHeight + sentinelsWithDefaults.MaxImageCacheMB = defaultMaxImageCacheMB + sentinelsWithDefaults.MaxThumbCacheMB = defaultMaxThumbCacheMB + sentinelsWithDefaults.MaxFileSizeMB = defaultMaxFileSizeMB + + for _, tc := range []struct { + name string + input preferences.State + want preferences.State + }{ + {name: "zero caps use defaults", want: defaults}, + {name: "negative caps use defaults", input: negative, want: defaults}, + {name: "positive caps survive", input: custom, want: custom}, + {name: "non-cap sentinels survive", input: sentinels, want: sentinelsWithDefaults}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := normalizePreferenceDefaults(tc.input); got != tc.want { + t.Errorf("normalizePreferenceDefaults() = %+v, want %+v", got, tc.want) + } + }) + } +} + +func funcName(f func()) string { + if f == nil { + return "" + } + return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() +} + +func TestStartViewerRuntime_ReplacesConstructionStopAfterGeometryRestoration(t *testing.T) { + application := test.NewApp() + settingsGeometry := preferences.WindowGeometry{ + X: 210, Y: 220, PositionSet: true, Size: fyne.NewSize(600, 520), + } + exifGeometry := preferences.WindowGeometry{ + X: 310, Y: 320, PositionSet: true, Size: fyne.NewSize(430, 370), + } + preferences.Save(application, preferences.State{ + WindowSize: fyne.NewSize(700, 500), + WindowPosX: 120, + WindowPosY: 340, + WindowPositionSet: true, + SettingsWindow: settingsGeometry, + ExifWindow: exifGeometry, + }) + + favoritesDir := t.TempDir() + if err := favstore.Save(favoritesDir, "Runtime Favorite", nil); err != nil { + t.Fatalf("save temporary favorite: %v", err) + } + + v, win := buildStartupViewer(application) + t.Cleanup(win.Close) + t.Cleanup(func() { imaging.SetMaxEncodedBytes(0) }) // process-wide - see memlimits.go + + if v.stopWinPosPoll == nil { + t.Fatal("buildStartupViewer left stopWinPosPoll nil") + } + noPollerStopName := funcName(noPollerStop) + if got := funcName(v.stopWinPosPoll); got != noPollerStopName { + t.Fatalf("buildStartupViewer stop callback = %s, want noPollerStop %s", got, noPollerStopName) + } + + if got, want := win.Canvas().Size(), fyne.NewSize(700, 500); got != want { + t.Errorf("main window size = %v, want restored %v", got, want) + } + x, y, positionSet := v.winPos.Get() + if !positionSet || x != 120 || y != 340 { + t.Errorf("main window position = (%d, %d, set=%v), want restored (120, 340, set=true)", x, y, positionSet) + } + if got := prefGeometry(v.settings.Geometry()); got != settingsGeometry { + t.Errorf("settings geometry = %+v, want restored %+v", got, settingsGeometry) + } + if got := prefGeometry(v.exif.Geometry()); got != exifGeometry { + t.Errorf("EXIF geometry = %+v, want restored %+v", got, exifGeometry) + } + + startViewerRuntime(v, win, favoritesDir) + runtimeStop := v.stopWinPosPoll + if runtimeStop == nil { + t.Fatal("startViewerRuntime left stopWinPosPoll nil") + } + t.Cleanup(runtimeStop) + if got := funcName(runtimeStop); got == noPollerStopName { + t.Fatalf("startViewerRuntime left noPollerStop installed (%s)", got) + } + + items := v.favorites.Menu().Items + if len(items) != 5 || items[2].Label != "Runtime Favorite" { + t.Errorf("favorites menu items = %+v, want the temporary favorite", items) + } +} + func TestWindowSizeTracker_RecordsResizes(t *testing.T) { application := test.NewApp() - v, win := buildViewer(application) + v, win := buildStartupViewer(application) defer win.Close() win.Resize(fyne.NewSize(900, 650)) diff --git a/internal/ui/run.go b/internal/ui/run.go index fc81b9f..e46bc55 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -27,8 +27,9 @@ const ( // file set to open on startup (command-line arguments, resolved to URIs by // the caller); empty for a plain launch. func Run(application fyne.App, initial []fyne.URI) { - view, window := buildViewer(application) - view.favorites.SetDir(favstore.DefaultDir()) + view, window := buildStartupViewer(application) + startViewerRuntime(view, window, favstore.DefaultDir()) + registerShutdown(application, view) // Deferred to SetOnStarted rather than called right away: it ends up // calling handleDrop, which touches widgets directly (no fyne.Do) the @@ -40,6 +41,20 @@ func Run(application fyne.App, initial []fyne.URI) { }) } + window.ShowAndRun() +} + +// Runtime side effects start only after feature construction and geometry +// restoration, so polling cannot observe a nil slideshow or replace a saved +// position before it has been applied. +func startViewerRuntime(view *viewer, window fyne.Window, favoritesDir string) { + view.favorites.SetDir(favoritesDir) + view.stopWinPosPoll = startWindowPosPolling(view, window) +} + +// registerShutdown installs the save while the Fyne event loop is still +// available to synchronously flush preferences. +func registerShutdown(application fyne.App, view *viewer) { // Wired via SetOnStopped, not run after ShowAndRun returns: Fyne's own // app.Preferences() schedules its on-disk flush through a debounced // change listener (app.newPreferences in fyne itself) that, once @@ -70,8 +85,6 @@ func Run(application fyne.App, initial []fyne.URI) { session.Save(application, view.state.unsortedFiles) preferences.Save(application, view.currentPreferences()) }) - - window.ShowAndRun() } // currentPreferences is everything worth remembering about this run, ready diff --git a/internal/ui/save.go b/internal/ui/save.go index 158341c..98ea7cb 100644 --- a/internal/ui/save.go +++ b/internal/ui/save.go @@ -38,7 +38,7 @@ func (v *viewer) canSaveRotation() bool { } // saveRotation is the File menu's "Save Changes" action (also Cmd/Ctrl+S, -// see wireSaveShortcut in build.go): it writes the currently displayed, +// see wireSaveShortcut in shortcuts.go): it writes the currently displayed, // already-rotated frame back to the file it came from, in that file's own // format. A no-op unless canSaveRotation() is currently true - re-checked // here rather than trusted from the menu item's Disabled state, since the diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go index 1dd810b..1df3f59 100644 --- a/internal/ui/session_test.go +++ b/internal/ui/session_test.go @@ -24,7 +24,7 @@ import ( func TestBuildViewer_NoSavedSessionHidesRestoreLink(t *testing.T) { app := test.NewApp() - v, win := buildViewer(app) + v, win := buildStartupViewer(app) defer win.Close() if v.restoreLink.Visible() { @@ -39,7 +39,7 @@ func TestBuildViewer_SavedSessionShowsRestoreLink(t *testing.T) { storage.NewFileURI("/tmp/b.jpg"), }) - v, win := buildViewer(app) + v, win := buildStartupViewer(app) defer win.Close() if !v.restoreLink.Visible() { @@ -62,7 +62,7 @@ func TestRestoreSession_LoadsSavedFilesAndHidesLink(t *testing.T) { b := uitest.TempJPEGURI(t, "b.jpg", 4, 4, color.White) session.Save(app, []fyne.URI{a, b}) - v, win := buildViewer(app) + v, win := buildStartupViewer(app) defer win.Close() if !v.restoreLink.Visible() { @@ -93,7 +93,7 @@ func TestHandleDrop_HidesRestoreLinkEvenWithoutUsingIt(t *testing.T) { saved := uitest.TempJPEGURI(t, "saved.jpg", 4, 4, color.White) session.Save(app, []fyne.URI{saved}) - v, win := buildViewer(app) + v, win := buildStartupViewer(app) defer win.Close() if !v.restoreLink.Visible() { @@ -120,7 +120,7 @@ func TestViewerReset_ReshowsRestoreLinkWhenSessionUnconsumed(t *testing.T) { saved := uitest.TempJPEGURI(t, "saved.jpg", 4, 4, color.White) session.Save(app, []fyne.URI{saved}) - v, win := buildViewer(app) + v, win := buildStartupViewer(app) defer win.Close() dropped := uitest.TempJPEGURI(t, "dropped.jpg", 4, 4, color.White) @@ -139,7 +139,7 @@ func TestViewerReset_DoesNotReshowRestoreLinkOnceConsumed(t *testing.T) { saved := uitest.TempJPEGURI(t, "saved.jpg", 4, 4, color.White) session.Save(app, []fyne.URI{saved}) - v, win := buildViewer(app) + v, win := buildStartupViewer(app) defer win.Close() v.restoreSession() diff --git a/internal/ui/settingswin/settingswin.go b/internal/ui/settingswin/settingswin.go index 36358ca..b2c9c30 100644 --- a/internal/ui/settingswin/settingswin.go +++ b/internal/ui/settingswin/settingswin.go @@ -123,8 +123,8 @@ func (w *Window) Open() bool { } // RestoreGeometry makes the window remember where and how large it was, -// seeded with what the last run left it at. Called once at construction -// (internal/ui's buildViewer); the app reads the current values back out of +// seeded with what the last run left it at. Called once during internal/ui's +// startup restoration; the app reads the current values back out of // Geometry at shutdown. Without it the window opens at windowW x windowH // wherever the OS puts it, which is what it always did. func (w *Window) RestoreGeometry(g widgets.Geometry) { diff --git a/internal/ui/shortcuts.go b/internal/ui/shortcuts.go new file mode 100644 index 0000000..cd3657d --- /dev/null +++ b/internal/ui/shortcuts.go @@ -0,0 +1,152 @@ +// Application-wide shortcut registration. The individual wiring functions +// remain separate so focused tests can exercise the production bindings +// directly through Fyne's shortcut handler. +package ui + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver/desktop" + + "github.com/frathe/picfetch/internal/ui/deletion" + "github.com/frathe/picfetch/internal/ui/favorites" +) + +// shortcutAdder is the one method the shortcut wiring needs from fyne.Canvas, +// narrow enough that a bare *fyne.ShortcutHandler satisfies it too - +// so tests can drive the exact same wiring against that handler directly +// and then fire it via its own TypedShortcut, instead of going through a +// full canvas. That detour is load-bearing, not a style choice: Fyne's test +// driver canvas (fyne.io/fyne/v2/test) embeds software.WindowlessCanvas by +// interface, which doesn't include TypedShortcut, so a real Ctrl+O key +// event can never be simulated through it - only the production glfw +// driver's canvas has that method reachable at all (see +// internal/driver/glfw/window.go's triggersShortcut, which is what turns a +// real key-plus-modifier press into this call). +type shortcutAdder interface { + AddShortcut(shortcut fyne.Shortcut, handler func(shortcut fyne.Shortcut)) +} + +// wireGlobalShortcuts keeps the application-wide shortcut groups composed in +// one visible sequence. This is the same order buildViewer used before the +// registration moved out of the top-level assembly. +func wireGlobalShortcuts(c shortcutAdder, view *viewer) { + wireOpenShortcuts(c, view) + wireFavoriteShortcuts(c, view.favorites.Open) + wireClipboardShortcuts(c, view) + wireDeleteShortcut(c, view) + wireSelectAllShortcut(c, view) + wireSaveShortcut(c, view) +} + +// wireOpenShortcuts binds Cmd/Ctrl+O and Cmd/Ctrl+Shift+O to the same +// native file/folder browser tapping the drop zone already opens +// (openFileDialog in openfiles.go). There's only one such dialog - it +// combines files and folders in one go, see internal/filepicker - so the +// second, modified binding isn't a second dialog, just a second way to +// reach the first one. Modified key combos never reach handleKeyEvent's +// SetOnTypedKey dispatch at all: Fyne's desktop driver intercepts them as +// shortcuts before TypedKey ever fires, which is why this needs +// AddShortcut instead of another case there. +func wireOpenShortcuts(c shortcutAdder, view *viewer) { + openShortcut := func(fyne.Shortcut) { view.openFileDialog() } + c.AddShortcut(&desktop.CustomShortcut{ + KeyName: fyne.KeyO, + Modifier: fyne.KeyModifierShortcutDefault, + }, openShortcut) + c.AddShortcut(&desktop.CustomShortcut{ + KeyName: fyne.KeyO, + Modifier: fyne.KeyModifierShortcutDefault | fyne.KeyModifierShift, + }, openShortcut) +} + +// wireFavoriteShortcuts binds the first ten sorted favorites to Cmd/Ctrl+1 +// through 9, then Cmd/Ctrl+0. The handlers stay registered while Feature.Open +// resolves each slot against the latest menu refresh after an add or removal. +func wireFavoriteShortcuts(c shortcutAdder, open func(index int)) { + for i := 0; i < favorites.ShortcutCount; i++ { + index := i + c.AddShortcut(favorites.ShortcutForIndex(index), func(fyne.Shortcut) { + open(index) + }) + } +} + +// wireClipboardShortcuts binds Cmd/Ctrl+C to copy the current image and +// Cmd/Ctrl+Shift+C to copy its file path (clipboard.go). Both need +// AddShortcut rather than handleKeyEvent's plain SetOnTypedKey dispatch, for +// the same reason wireOpenShortcuts does: modified key combos never reach +// TypedKey at all. Deliberately not gated behind handleKeyEvent's +// len(v.state.files)<2 navigation guard - both work fine with a single file +// loaded, and copyImageToClipboard/copyPathToClipboard already no-op safely +// when nothing is loaded yet. +// +// The plain Cmd/Ctrl+C binding is *not* a desktop.CustomShortcut, unlike +// every other shortcut in this file - that was the bug that shipped +// initially. Fyne's glfw driver special-cases the bare default-modifier +// forms of Z/Y/V/C/Insert/X/A (undo/redo/paste/copy/.../cut/select-all) into +// its own built-in fyne.Shortcut types *before* it ever considers building a +// desktop.CustomShortcut - see triggersShortcut in +// internal/driver/glfw/window.go, where that switch runs first and only +// falls through to a CustomShortcut when it didn't match. So a +// CustomShortcut registered for {KeyC, KeyModifierShortcutDefault} is +// simply never reachable by a real Cmd/Ctrl+C press; the driver dispatches a +// &fyne.ShortcutCopy{} instead, which needs its own AddShortcut entry to be +// caught. Shift+Cmd/Ctrl+C isn't one of the driver's special-cased combos, +// so it still becomes a CustomShortcut and needs no such treatment. +func wireClipboardShortcuts(c shortcutAdder, view *viewer) { + c.AddShortcut(&fyne.ShortcutCopy{}, func(fyne.Shortcut) { view.copySelection() }) + c.AddShortcut(&desktop.CustomShortcut{ + KeyName: fyne.KeyC, + Modifier: fyne.KeyModifierShortcutDefault | fyne.KeyModifierShift, + }, func(fyne.Shortcut) { view.copyPathToClipboard() }) +} + +// wireSelectAllShortcut binds Cmd/Ctrl+A to the grid's select-all +// (batch.go's selectAllInGrid). A third instance of the same driver quirk +// wireClipboardShortcuts documents: A is one of the bare combos +// triggersShortcut special-cases into a built-in fyne.Shortcut type +// (&fyne.ShortcutSelectAll{}) before it would ever build a +// desktop.CustomShortcut, so a CustomShortcut for {KeyA, +// KeyModifierShortcutDefault} could never be reached by a real key press. +func wireSelectAllShortcut(c shortcutAdder, view *viewer) { + c.AddShortcut(&fyne.ShortcutSelectAll{}, func(fyne.Shortcut) { view.selectAllInGrid() }) +} + +// wireDeleteShortcut binds Shift+Delete to open the permanent-delete +// confirmation card (deletion.Confirmer.Request). Same bug shape as +// Cmd/Ctrl+C above, different special case: triggersShortcut special-cases +// bare Shift+Delete into &fyne.ShortcutCut{Secondary: true} (its "alternative +// cut" binding, mirroring Shift+Insert for paste) *before* it would ever +// consider a desktop.CustomShortcut - and unlike the Ctrl+key cases, that +// function's CustomShortcut fallback explicitly skips building one whenever +// the modifier is bare Shift at all, so a CustomShortcut{KeyDelete, +// KeyModifierShift} registration wouldn't just be shadowed here, it could +// never be reached by any bare-Shift combo. So this needs an AddShortcut +// entry for &fyne.ShortcutCut{} instead - see deletion.ShortcutHandler +// (deletion.go) for how it tells a real Shift+Delete apart from a genuine +// Ctrl/Cmd+X (which reaches the same handler, Secondary false, and is +// correctly ignored: this app has no cut action). +// +// What it runs is batch.go's requestDelete rather than Confirmer.Request +// directly: the same key means the grid's selection while the overview is up +// and the file on screen otherwise, and deciding that is this package's job, +// not either feature package's. It used to be gated behind a `blocked` check +// that dropped the shortcut entirely while the grid was showing - there was +// nothing then for it to act on there, and the card would have opened hidden +// behind the grid's backdrop. Both of those are now handled instead of +// avoided (see the window stack in buildViewer). +func wireDeleteShortcut(c shortcutAdder, view *viewer) { + c.AddShortcut(&fyne.ShortcutCut{}, deletion.ShortcutHandler(view.requestDelete)) +} + +// wireSaveShortcut binds Cmd/Ctrl+S to saveRotation (save.go). S isn't one +// of the driver's specially-cased bare shortcuts (only Z/Y/V/C/Insert/X/A +// are - see wireClipboardShortcuts' comment), so a plain desktop. +// CustomShortcut reaches it the same way Cmd/Ctrl+O reaches +// wireOpenShortcuts. +func wireSaveShortcut(c shortcutAdder, view *viewer) { + c.AddShortcut(&desktop.CustomShortcut{ + KeyName: fyne.KeyS, + Modifier: fyne.KeyModifierShortcutDefault, + }, func(fyne.Shortcut) { view.saveRotation() }) +} diff --git a/internal/ui/startup.go b/internal/ui/startup.go new file mode 100644 index 0000000..26c6c94 --- /dev/null +++ b/internal/ui/startup.go @@ -0,0 +1,83 @@ +// Startup assembly loads persisted inputs once, constructs the complete +// viewer, and only then restores geometry. Runtime side effects remain in +// run.go and start after this sequence returns. +package ui + +import ( + "fyne.io/fyne/v2" + + "github.com/frathe/picfetch/internal/preferences" + "github.com/frathe/picfetch/internal/session" +) + +// startupState is the persisted input snapshot consumed by buildViewer and +// geometry restoration. +type startupState struct { + savedSession []fyne.URI + prefs preferences.State +} + +// loadStartupState reads persistence and fills only preference defaults that +// have no distinct zero-value meaning. +func loadStartupState(application fyne.App) startupState { + return startupState{ + savedSession: session.Load(application), + prefs: normalizePreferenceDefaults(preferences.Load(application)), + } +} + +// buildStartupViewer is the shared load, construct, then restore entry point. +// It leaves noPollerStop installed for startViewerRuntime to replace. +func buildStartupViewer(application fyne.App) (*viewer, fyne.Window) { + startup := loadStartupState(application) + view, window := buildViewer(application, startup) + restoreStartupGeometry(view, window, startup) + return view, window +} + +// normalizePreferenceDefaults fills only caps. The other zero values remain +// meaningful: an unset slideshow interval is chosen on first use, geometry +// flags distinguish unsaved positions, and zero secondary geometry uses each +// window's built-in placement and size. +func normalizePreferenceDefaults(prefs preferences.State) preferences.State { + if prefs.MaxScanFiles <= 0 { + prefs.MaxScanFiles = defaultMaxScannedFiles + } + if prefs.MaxWindowWidth <= 0 { + prefs.MaxWindowWidth = defaultMaxWindowWidth + } + if prefs.MaxWindowHeight <= 0 { + prefs.MaxWindowHeight = defaultMaxWindowHeight + } + if prefs.MaxImageCacheMB <= 0 { + prefs.MaxImageCacheMB = defaultMaxImageCacheMB + } + if prefs.MaxThumbCacheMB <= 0 { + prefs.MaxThumbCacheMB = defaultMaxThumbCacheMB + } + if prefs.MaxFileSizeMB <= 0 { + prefs.MaxFileSizeMB = defaultMaxFileSizeMB + } + + return prefs +} + +// restoreStartupGeometry runs after feature construction so the settings and +// EXIF windows exist before their remembered geometry is applied. +func restoreStartupGeometry(view *viewer, window fyne.Window, startup startupState) { + prefs := startup.prefs + + view.settings.RestoreGeometry(widgetGeometry(prefs.SettingsWindow)) + view.exif.RestoreGeometry(widgetGeometry(prefs.ExifWindow)) + + initialSize := fyne.NewSize(startW, startH) + if prefs.WindowSize.Width > 0 && prefs.WindowSize.Height > 0 { + initialSize = prefs.WindowSize + } + window.Resize(initialSize) + + if prefs.WindowPositionSet { + view.winPos.Store(prefs.WindowPosX, prefs.WindowPosY) + view.winPos.Restore(window) + } +} diff --git a/internal/ui/viewer.go b/internal/ui/viewer.go index 449ff40..affd8b1 100644 --- a/internal/ui/viewer.go +++ b/internal/ui/viewer.go @@ -74,7 +74,7 @@ type viewer struct { // exif is the EXIF metadata panel - see internal/ui/exifwin, which // reaches back only through the "which file is on screen" accessor - // buildViewer hands it. finishLoad calls its Refresh so navigating + // registerFeatures hands it. finishLoad calls its Refresh so navigating // while it's open keeps it in sync. exif *exifwin.Window @@ -150,11 +150,10 @@ type viewer struct { winPos winpos.Tracker // stopWinPosPoll stops startWindowPosPolling's background ticker - // goroutine; wired by buildViewer and called from Run's SetOnStopped - // just before the final preferences save (winPos keeps its last - // reading, so the save still has a value). Always non-nil after - // buildViewer - a no-op func when the window isn't a - // driver.NativeWindow and no poller ever started. + // goroutine; initialized to noPollerStop by buildViewer, replaced by + // Run after startup geometry is restored, and called from SetOnStopped + // just before the final preferences save (winPos keeps its last reading, + // so the save still has a value). stopWinPosPoll func() scanSpinner *widget.ProgressBarInfinite @@ -271,7 +270,7 @@ type viewer struct { // container" layout. It needs no Host: the app and that package share // img on a single-writer-per-field contract (the app owns img.Image, // zoom owns img's size and position), and the only reach back is the - // updateInfoOverlay callback buildViewer hands it. + // updateInfoOverlay callback registerFeatures hands it. zoom *zoom.Zoom // infoVisible is a standing preference toggled by I, mirroring @@ -283,13 +282,14 @@ type viewer struct { // tracks the undecoded size. infoVisible bool infoText *widget.Label - // exifLink is the "Show EXIF data" link inside infoCard - see build.go's - // wiring. Kept as its own field only so tests can trigger it directly - // (OnTapped) the same way e2e_test.go does for restoreLink, without a - // real click through the widget tree. It is only shown for a file that - // actually has metadata to show (currentHasEXIF, carried the same way - // currentFileSize is): the link is a promise, and offering it for a file - // with no Exif at all can only ever open a panel saying so. + // exifLink is the "Show EXIF data" link inside infoCard - see + // components.go's construction and build.go's callback wiring. Kept as + // its own field only so tests can trigger it directly (OnTapped) the + // same way e2e_test.go does for restoreLink, without a real click through + // the widget tree. It is only shown for a file that actually has metadata + // to show (currentHasEXIF, carried the same way currentFileSize is): the + // link is a promise, and offering it for a file with no Exif at all can + // only ever open a panel saying so. exifLink *widget.Hyperlink infoCard *fyne.Container currentFileSize int64 @@ -367,7 +367,7 @@ type viewer struct { // defaultKeyModifiers (keys.go) in production, stubbed by tests (the // fyne test driver can't synthesize modifier state at all). Read by // handleKeyEvent's Shift+R, and by the zoom view's Shift+scroll pan - // through the closure buildViewer hands it. + // through the closure registerFeatures hands it. keyModifiers func() fyne.KeyModifier // vector is the parsed SVG behind the image on screen, nil for every diff --git a/internal/ui/widgets/singleton.go b/internal/ui/widgets/singleton.go index f095946..e7fa83e 100644 --- a/internal/ui/widgets/singleton.go +++ b/internal/ui/widgets/singleton.go @@ -146,10 +146,10 @@ func (s *Singleton) Show(app fyne.App, title string, size fyne.Size, build func( s.win = win - // Applied before Show for the reason internal/ui's buildViewer applies - // the main window's saved position before its own: RequestPosition on a - // window that isn't up yet just primes the coordinates the glfw driver's - // window-creation path uses once it does run. + // Applied before Show for the reason internal/ui's startup restoration + // applies the main window's saved position before that window is shown: + // RequestPosition on a window that isn't up yet just primes the + // coordinates the glfw driver's window-creation path uses once it does run. if s.remember { s.pos.Restore(win) } diff --git a/internal/ui/widgets/singleton_test.go b/internal/ui/widgets/singleton_test.go index 9594af7..5c3e1e8 100644 --- a/internal/ui/widgets/singleton_test.go +++ b/internal/ui/widgets/singleton_test.go @@ -71,9 +71,9 @@ func TestSingleton_GeometryTracksResizes(t *testing.T) { // The seeded position has to survive until a live reading replaces it - // the same rule the main window's own tracker follows (internal/ui's -// buildViewer stores the saved position before the poller ever runs), so a -// launch that never gets a fresh reading still saves last launch's good -// value instead of losing it to a zero. +// startup restoration stores the saved position before Run starts the +// poller), so a launch that never gets a fresh reading still saves last +// launch's good value instead of losing it to a zero. func TestSingleton_RememberedPositionSurvivesWithoutALiveReading(t *testing.T) { app := test.NewApp() diff --git a/internal/ui/windowtrack.go b/internal/ui/windowtrack.go index 185baed..ef09838 100644 --- a/internal/ui/windowtrack.go +++ b/internal/ui/windowtrack.go @@ -33,6 +33,10 @@ func windowSizeTracker(v *viewer, win fyne.Window) fyne.Layout { return widgets.NewSizeTracker(win, &v.windowSize) } +// noPollerStop marks the construction-time state before Run starts runtime +// position polling. +func noPollerStop() {} + // startWindowPosPolling keeps v.winPos current for the lifetime of the app - // the position equivalent of windowSizeTracker above, and the app's binding // of winpos.Poll, which owns the loop itself and every reason it has to be @@ -48,6 +52,9 @@ func windowSizeTracker(v *viewer, win fyne.Window) fyne.Layout { // winding down (the tracker keeps its last reading, so the save still has // a value). A no-op func, never nil, when no poller started. func startWindowPosPolling(v *viewer, win fyne.Window) (stop func()) { + if v.slides == nil { + panic("ui: startWindowPosPolling called before slideshow construction") + } return winpos.Poll(win, &v.winPos, v.slides.Active) } @@ -58,7 +65,7 @@ func startWindowPosPolling(v *viewer, win fyne.Window) (stop func()) { // one shared type: internal/preferences would otherwise have to import a UI // package, or widgets a persistence one, and this package is already where // every other preference is translated (filesort.FromPref, the zero-means- -// default caps in buildViewer). +// default caps in startup.go). func widgetGeometry(g preferences.WindowGeometry) widgets.Geometry { return widgets.Geometry{X: g.X, Y: g.Y, PositionSet: g.PositionSet, Size: g.Size} } diff --git a/planed_refactoring/01_DONE_app_state_controller.md b/planed_refactoring/01_DONE_app_state_controller.md deleted file mode 100644 index eecdb4e..0000000 --- a/planed_refactoring/01_DONE_app_state_controller.md +++ /dev/null @@ -1,45 +0,0 @@ -# (DONE) Refactoring Plan 1: Extract an App-State Controller - -## Objective -Separate the root application state from the concrete Fyne widgets so the app has a clear controller layer instead of one large `viewer` object owning both UI and domain state. - -## Why this is first -`internal/ui/viewer.go` still owns too many responsibilities at once: the current file set, index, loading state, merge/sort preference state, window geometry, menu state, cached image references, and feature interactions. That makes it difficult to reason about lifecycle and makes new features more fragile. - -## Accepted boundary -The working implementation establishes an unexported, package-local `appState` in `internal/ui`, not an exported -application-wide controller. It owns the loaded/displayed file lists, current index, sort mode, and merge mode. -`viewer` remains its façade and orchestration hub: it translates events into model changes and renders their effects -through the Fyne widgets. - -This boundary intentionally excludes asynchronous scan/load/sort lifecycle (including generation and cancellation), -window geometry/session wiring, menu enablement/visibility, image/display cache state, and rendering. Those concerns -remain on `viewer` because they coordinate Fyne widgets or asynchronous work rather than describing the current file -model. Native file-picker and save-dialog glue is also out of scope; it remains in the existing `viewer` wrappers over -`internal/filepicker`. - -Feature packages continue to declare their own narrow consumer-side `Host` interfaces (and `exifwin` its one callback). -No broad `Controller` interface is introduced, and `appState` is never handed to feature packages. - -## Specific steps -1. Identify the true ownership boundary for the “viewer state” versus UI-only widgets. -2. Extract a small state struct for the app’s current model. -3. Move file/index behavior and mode toggles out of `viewer` and into the controller. -4. Keep `viewer` as a composition hub that renders the state and forwards events. -5. [x] Verify the existing narrow Host interfaces for `grid`, `settingswin`, `exifwin`, favorites, slideshow, and - deletion remain the accepted consumer boundaries; no broad controller interface is required by the completed - package-local state extraction. -6. Ensure menu enablement and other derived UI state come from controller state instead of ad hoc viewer fields. - -## Risks to watch -- Accidental split-brain state if widget code and controller code both mutate the same fields. -- Hidden feature behaviors that rely on direct access to viewer internals. -- Conflicting lifecycle assumptions between asynchronous loads and immediate UI updates. - -## Best suited agent -`refactor-planner` - -## Success criteria -- `viewer.go` is no longer the single source of truth for everything. -- Feature packages rely on explicit interfaces instead of a giant object. -- State transitions are easier to test in isolation. diff --git a/planed_refactoring/02_DONE_async_lifecycle_orchestration.md b/planed_refactoring/02_DONE_async_lifecycle_orchestration.md deleted file mode 100644 index a3e7b76..0000000 --- a/planed_refactoring/02_DONE_async_lifecycle_orchestration.md +++ /dev/null @@ -1,114 +0,0 @@ -# (DONE) Refactoring Plan 2: Consolidate Async Lifecycle Management - -## Objective -Centralize the app’s lifecycle rules for stale work, cancellation, and async completion so load/sort/scan/vector jobs follow one consistent contract. - -## Why this is second -The project already has repeated patterns for stale-result protection: `gen`, `sortGen`, `vectorGen`, and multiple cancel functions like `scanCancel`, `loadCancel`, and `sortCancel`. That is a sign that lifecycle logic is duplicated in several places and is hard to reason about uniformly. - -## Target design -Create a small lifecycle/orchestration helper that standardizes: -- generation or revision tracking -- stale-request rejection -- cancellation context ownership -- completion-handling semantics -- irreversible invalidation when a newer request supersedes an older one - -The helper is package-local to `internal/ui` and has two layers: -- a zero-value revision primitive for capturing and comparing monotonically - increasing revisions -- a request lifecycle that starts one current request, cancels the previous - request's context, and returns a token combining that context with the - captured revision - -Load, scan, sort, and vector rendering each own a separate request lifecycle. -Decode retries, neighbor preloads, and GIF animation are descendants of one -load token rather than independent requests. A separate file-set revision is -exposed through `viewer.Generation` for grid thumbnail and deletion guards; -ordinary image navigation must not invalidate work whose indices still refer -to the same file set. - -## Async inventory and invalidation matrix - -| Owner | Work covered by one request | Superseded by | Must not be superseded by | -|---|---|---|---| -| Load | probe, decode, broken-file retry chain, neighbor preloads, GIF animation | newer navigation, a new drop, clearing files, shutdown | sort-only changes before they land; scan cancellation | -| Scan | direct-drop filtering or recursive directory walk and progress updates | newer drop, explicit scan cancellation, clearing files, shutdown | navigation within the existing set | -| Sort | `filesort.Order` and its state-writing callback | newer sort, file removal, clearing/replacing files, explicit sort cancellation, shutdown | navigation or vector rendering | -| Vector | debounce, SVG rasterization, and UI hand-off | newer render request, image/vector change, clearing files, shutdown | unrelated scan or sort work | -| File set | index-to-URI identity consumed by grid and deletion | replace/merge landing, reorder landing, removal, clear | navigation, decode retry before it removes a file, scan/sort merely starting | - -Toast, slideshow, grid filtering/cell recycling, thumbnail workers, chooser, -clipboard, deletion, favorites, wallpaper, and window-position polling retain -their existing feature-local lifecycle contracts. They have additional -semantics that do not fit a single-current-request abstraction and are outside -this refactoring. - -## Completion contract - -1. Every background operation captures a request token and checks it before - expensive work when practical and again immediately before applying a - result through `fyne.Do`. -2. Starting or invalidating a lifecycle irreversibly advances its revision and - cancels the previous token's context. Cancellation stops cooperative work; - revision comparison remains the final stale-result guard. -3. A stale completion may only settle resources owned by that invocation. It - must not hide a newer request's spinner, clear a newer in-flight flag, write - model/widget state, or invoke a state-writing callback. -4. Per-invocation done channels are closed exactly once on success, error, - cancellation, and stale completion. Existing WaitGroups call `Done` on - every return path, including cancellation during vector debounce and while - a preload is queued behind its semaphore. -5. Load descendants share the parent token and context. Finishing the visible - decode does not invalidate that token because its preloads and animation - remain legitimate until the next load invalidation. -6. UI-owned booleans such as `scanning` and `sorting` remain local presentation - state. Only the current token's completion may finalize them; explicit - cancellation finalizes them synchronously on the UI goroutine. - -## Delegation stages - -1. Introduce and unit-test the lifecycle and revision primitives. -2. Migrate load and scan together because they currently share `viewer.gen`; - split their invalidation while preserving the load descendant chain. -3. Migrate sort independently, including stale spinner ownership. -4. Migrate vector rendering and replace its shutdown-only stop channel with - lifecycle cancellation. -5. Remove legacy viewer fields, update test synchronization and - `ARCHITECTURE.md`, then run normal and race-enabled suites. - -## Specific steps -1. [x] Inventory all async jobs and their invalidation rules. -2. [x] Extract a common “request lifecycle” abstraction used by decode, sort, scan, and vector render. -3. [x] Replace ad hoc generation checks with a shared controller contract. -4. [x] Keep cancellation responsibilities explicit and local to the owning job. -5. [x] Ensure no work can finish into a superseded state without first checking validity. -6. [x] Add targeted tests for stale requests and out-of-order completion. - -## Implemented result - -- `internal/ui/lifecycle.go` owns the zero-value `revision`, - `requestLifecycle`, and `requestToken` primitives. -- `viewer` owns separate load, scan, sort, and vector lifecycle instances plus - a dedicated file-set revision returned by `Generation`. -- Load retries, preloads, and GIF animation share one token; cancellation wakes - animation delays and preloads queued behind the semaphore. -- Scan cancellation no longer interrupts navigation through an existing set. -- Only the current sort token may finalize progress UI or invoke its callback; - explicit invalidation finalizes its own UI synchronously. -- Vector invalidation replaces the shutdown-only stop channel and wakes - superseded debounce waits. -- Targeted lifecycle regressions, `make test`, and `go test -race ./...` pass. - -## Risks to watch -- Subtle races between UI goroutine and background decode goroutines. -- Cancellation semantics that silently skip legitimate finalization. -- Inconsistent stale-result checks across different features. - -## Best suited agent -`go-expert` - -## Success criteria -- One consistent invalidation model across all async loops. -- Fewer custom generation counters and cancellation wrappers. -- Less risk of stale UI updates after a newer file set or sort order is active. diff --git a/planed_refactoring/03_build_assembly_cleanup.md b/planed_refactoring/03_build_assembly_cleanup.md deleted file mode 100644 index 7e0b86c..0000000 --- a/planed_refactoring/03_build_assembly_cleanup.md +++ /dev/null @@ -1,38 +0,0 @@ -# Refactoring Plan 3: Separate App Assembly from App Behavior - -## Objective -Reduce the size and cohesion of `internal/ui/build.go` by separating widget construction and app wiring from the actual behavior logic of the application. - -## Why this is third -`build.go` is still doing a lot of assembly work in one place, which makes the app harder to evolve without touching a central wiring file. The project already has several feature packages; the next step is to make the assembly layer more declarative and less monolithic. - -## Target design -- Keep `build.go` focused on composing the window and feature modules. -- Introduce a small assembly layer or feature registry that wires together: - - app controller - - feature windows - - host interfaces - - global shortcuts - - shared UI overlays -- Move behavior-specific logic out of the central builder where it is not strictly infrastructure. - -## Specific steps -1. Identify the pure assembly responsibilities in `build.go`. -2. Separate feature wiring from behavior logic. -3. Define a narrow “feature registration” pattern for options such as grid, slideshow, favorites, settings, help, and EXIF windows. -4. Reduce cross-feature coupling in construction time. -5. Keep startup and shutdown behavior explicit rather than buried inside widget setup. -6. Validate that the app still builds and renders the same way via the existing e2e test path. - -## Risks to watch -- Over-abstracting the assembly layer and adding indirection with no gain. -- Reintroducing hidden dependencies between features when they are assembled. -- Creating a second source of initialization logic that drifts from runtime behavior. - -## Best suited agent -`general-purpose` - -## Success criteria -- `build.go` is smaller and easier to follow. -- New features can be added by composing modules rather than editing one large builder. -- Behavior and construction responsibilities are cleaner and less entangled. diff --git a/todos.md b/todos.md index 66d3deb..80d599c 100644 --- a/todos.md +++ b/todos.md @@ -9,57 +9,8 @@ ## TODO -- hideConsoleWindow is duplicated 4 times across clipboard, - filepicker, trash, wallpaper. Let's refactor this. - -- The viewer struct is a "god struct" (~803 lines) - Group related fields into small composition types. For example: - ```golang - type menuState struct { - saveItem *fyne.MenuItem - exportPNGItem *fyne.MenuItem - exportJPEGItem *fyne.MenuItem - wallpaperItem *fyne.MenuItem - closeFilesItem *fyne.MenuItem - } - - type navigationState struct { - files []fyne.URI - index int - unsortedFiles []fyne.URI - sortMode filesort.Mode - mergeMode bool - baseTitle string - gen atomic.Uint64 - loadCancel context.CancelFunc - } - ``` - -- preferences.go is becoming a config god-type - Split State into domain-specific sub-types that compose: - ```golang - type State struct { - Sort SortState - Merge bool - Slideshow SlideshowState - Scan ScanState - Window WindowState - Cache CacheState - Geometry GeometryState - } - - type SlideshowState struct { - Interval time.Duration - Shuffle bool - } - - type CacheState struct { - MaxImageCacheMB int - MaxThumbCacheMB int - MaxFileSizeMB int - } - ``` - + - When in Galery mode and hovering over a preview image the index for the + keyboard position should lso be updated to that element. - favorites disk thumbnail cache Generate preview images in the background under each favorite's cache directory. When a favorite is loaded, let the grid read those previews and From 6eca5c3ec8e733e896531e50856f10007269ca29 Mon Sep 17 00:00:00 2001 From: frathe Date: Wed, 19 Aug 2026 14:51:13 +0200 Subject: [PATCH 4/6] Bugfix: grid hover moves the keyboard position --- ARCHITECTURE.md | 2 +- FyneApp.toml | 2 +- internal/ui/grid/grid.go | 59 ++++++++++++----- internal/ui/grid/grid_test.go | 118 ++++++++++++++++++++++++++++++++++ todos.md | 6 +- 5 files changed, 166 insertions(+), 21 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3d86c96..73b293b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,7 +83,7 @@ one. Sizes below are that interface, which is the honest measure of how coupled | Package | Responsibility | Reaches back via | |----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `zoom/` | The zoom/pan view of the displayed image (0/1/+/-, click-drag pan, scroll-to-zoom anchored at the pointer), and the widget that lays it out in place of the image itself. Measures against a *logical* size rather than `img.Image.Bounds()` directly — `SetLogicalSize`/unexported `native`, with `LogicalSize` as a test-only reader, the same role `Fitting` plays — because an SVG's raster is re-rendered at a different pixel count as the display scale moves, so the pixel count is no longer the size the image should be drawn at; a raster format never calls `SetLogicalSize` and behaves exactly as before | Three callbacks now, not zero: `onChanged` (a zoom/pan change, to redraw the info overlay), `modifiers` (an accessor for which keys are held, for Shift+scroll pan), and `onScaleChanged` (the effective display scale moved — from a key, a scroll, or a fit-driven resize — which `internal/ui/vector.go`'s `requestVectorRender` uses to re-rasterize a vector). The single-writer contract itself is unchanged: it still shares one `*canvas.Image` where the app owns `img.Image` (the pixels) and this package owns only its size and position. What changed is that this package stopped assuming the pixel count *is* the image's size, not who writes what — zoom still never writes a pixel | -| `grid/` | The full-window thumbnail overview (`G` key): a virtualized `widget.GridWrap` over every loaded image, owning its own thumbnail cache (a separate *byte* budget from `imgCache`, so neither evicts the other) and the bounded worker pool that fills it. `SetCacheBytes` is its one setter, retuning that budget while the app runs — the same shape `slideshow.Controller.SetInterval` already has. Also owns the filename search (`/`, fed by `HandleRune`): a grid-local filter that renumbers the cells it draws while leaving the app's file set untouched — `matches` maps display index → host index, and everything crossing that boundary (`ShowImage`, `FileAt`) goes through `fileIndex`. `filterGen` is the staleness guard it adds alongside the host generation and cell recycling, for the one thing neither can see: a keystroke changes neither the file set nor a cell's id, only what that id *means*. Also owns the **multi-select** (`selection.go`): Cmd/Ctrl+click toggles a cell, Shift+click extends a range, Space and Cmd/Ctrl+A do the same from the keyboard, and `Targets()` is what a batch action acts on — the selection, or the highlighted cell alone when there isn't one. The set holds *host* indices, not the display indices actually clicked, so it survives a filter change; `displayIndex` is `fileIndex`'s inverse, needed to walk a Shift+click's range in display space. Escape stages (selection → search → grid), and both selection gestures call `Host.Unfocus` themselves, since GridWrap grabs canvas focus on every tap and only `Close` used to hand it back. `FilesChanged` is how the app resyncs it after a batch delete shrinks the file set under it | 8-method `Host` — the search added none of them and multi-select added exactly one (`Modifiers`, since a Fyne tap carries no modifier state); knows nothing about the slideshow, and nothing about deletion or the clipboard — see below | +| `grid/` | The full-window thumbnail overview (`G` key): a virtualized `widget.GridWrap` over every loaded image, owning its own thumbnail cache (a separate *byte* budget from `imgCache`, so neither evicts the other) and the bounded worker pool that fills it. `SetCacheBytes` is its one setter, retuning that budget while the app runs — the same shape `slideshow.Controller.SetInterval` already has. Also owns the filename search (`/`, fed by `HandleRune`): a grid-local filter that renumbers the cells it draws while leaving the app's file set untouched — `matches` maps display index → host index, and everything crossing that boundary (`ShowImage`, `FileAt`) goes through `fileIndex`. `filterGen` is the staleness guard it adds alongside the host generation and cell recycling, for the one thing neither can see: a keystroke changes neither the file set nor a cell's id, only what that id *means*. Also owns the **multi-select** (`selection.go`): Cmd/Ctrl+click toggles a cell, Shift+click extends a range, Space and Cmd/Ctrl+A do the same from the keyboard, and `Targets()` is what a batch action acts on — the selection, or the highlighted cell alone when there isn't one. The set holds *host* indices, not the display indices actually clicked, so it survives a filter change; `displayIndex` is `fileIndex`'s inverse, needed to walk a Shift+click's range in display space. Escape stages (selection → search → grid), and both selection gestures call `Host.Unfocus` themselves, since GridWrap grabs canvas focus on every tap and only `Close` used to hand it back. `FilesChanged` is how the app resyncs it after a batch delete shrinks the file set under it. The ring and GridWrap's *own* keyboard cursor are two positions, and GridWrap moves the latter only for the arrow keys it handles itself — so every move of the ring goes through `setHighlight`, which drives both; `OnHighlighted` (fired by hover *and* by arrow keys) delegates to it behind an `id == g.highlight` guard, which is also what stops `wrap.Highlight`'s re-entry through that callback from recursing | 8-method `Host` — the search added none of them and multi-select added exactly one (`Modifiers`, since a Fyne tap carries no modifier state); knows nothing about the slideshow, and nothing about deletion or the clipboard — see below | | `deletion/` | The Shift+Delete confirmation flow: its own card and button selection state, followed by a recoverable `trash.Move`. Takes a **set** of `Target`s (`RequestFiles`), not just the file on screen — `Request` is now the one-target wrapper over it, and worded identically for one file so the existing golden masters still hold. A batch's moves run one after another on the single background goroutine, collecting failures rather than aborting, so one file the OS refuses to move costs neither the rest of the batch nor the truth of the toast (`moved 9 of 12 files…`); only what actually moved is removed from the file set | 7-method `Host` (`CurrentFile`, `RemoveFiles`, `ShowImage`, `ShowEmptyStateError`, `ShowToast`, `ForceRepaint`, `Generation`) — the first of the consumer-side interfaces the split is built on. `RemoveFiles` takes a slice because removing a batch one index at a time would shift every later index out from under the list already captured | | `slideshow/` | Picture-frame mode (`P` key): the full-screen switch, the auto-advance goroutine, the interval `Up`/`Down` tunes, and the `winpos.Tracker` capture/restore that puts the window back where the user left it | 2-method `Host` (`FileCount`, `Advance`) — the smallest in the split; knows nothing about the grid — see below | | `exifwin/` | The EXIF metadata panel (`E` key and the info overlay's "Show EXIF data" link) over `internal/imaging`'s `ReadMetadata`. Remembers where and how large it was left (`RestoreGeometry`/`Geometry`/`StopTracking`, three lines over `widgets.Singleton`) | One `func() (fyne.URI, bool)` accessor — a single function is a smaller, more honest dependency than a one-method interface | diff --git a/FyneApp.toml b/FyneApp.toml index d1c3a5b..b4e841a 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -2,7 +2,7 @@ Name = "PicFetch" ID = "io.github.frathe.picfetch" Version = "0.1.7" -Build = 325 +Build = 330 [Migrations] fyneDo = true diff --git a/internal/ui/grid/grid.go b/internal/ui/grid/grid.go index 028f895..7619804 100644 --- a/internal/ui/grid/grid.go +++ b/internal/ui/grid/grid.go @@ -291,16 +291,15 @@ func New(host Host, win fyne.Window) *Overview { // Fired both by keyboard highlight movement (HandleKey forwards the // arrow keys to wrap.TypedKey, see below) and by mouse hover (GridWrap // wires its own onHovered to the same callback) - either way, move the - // ring to match. GridWrap's own TypedKey already calls RefreshItem on - // the old and new positions before this fires, but at that point - // g.highlight still holds the *old* value, so those calls redraw both - // cells as "still old" - these two RefreshItem calls are the ones that - // actually apply the moved ring, now that g.highlight has been updated. + // ring to match. + // + // The guard is what stops setHighlight's own re-entry through here from + // recursing: it re-enters with g.highlight already equal to id. g.wrap.OnHighlighted = func(id widget.GridWrapItemID) { - old := g.highlight - g.highlight = id - g.wrap.RefreshItem(old) - g.wrap.RefreshItem(id) + if id == g.highlight { + return + } + g.setHighlight(id) } g.searchLabel = widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) @@ -342,6 +341,33 @@ func (g *Overview) Highlight() int { return g.highlight } +// setHighlight moves the ring to display index id and keeps GridWrap's own +// keyboard cursor on the same cell. +// +// The two are separate positions: GridWrap advances its cursor only for the +// arrow keys it handles itself, so a mouse hover - or the grid opening on +// the file currently on screen - used to move the ring without it. The next +// arrow key then resumed from wherever the keyboard had last been, jumping +// the ring away from the cell the user was pointing at. +// +// wrap.Highlight re-enters OnHighlighted, which returns immediately because +// g.highlight is already set. GridWrap's own TypedKey does call RefreshItem +// on the old and new positions before this runs, but at that point +// g.highlight still holds the *old* value, so those calls redraw both cells +// as "still old" - the two RefreshItem calls here are the ones that actually +// apply the moved ring. +func (g *Overview) setHighlight(id int) { + old := g.highlight + g.highlight = id + // Highlight is a no-op on an empty grid, which would leave the cursor + // pointing into the set the filter just emptied. + if g.count() > 0 { + g.wrap.Highlight(id) + } + g.wrap.RefreshItem(old) + g.wrap.RefreshItem(id) +} + // ConsumeMaximized reports whether the window is still sitting maximized // from an earlier Toggle and hasn't been undone since, clearing the flag // either way - a one-shot check for whoever is about to resize the window @@ -379,10 +405,9 @@ func (g *Overview) Toggle() { g.maximized = true // Start the highlight on whichever image is currently on screen, and - // scroll it into view - ScrollTo also refreshes the grid, which is - // what actually paints the ring now that highlight is set. - g.highlight = g.host.CurrentIndex() - g.wrap.ScrollTo(g.highlight) + // scroll it into view - setHighlight also refreshes the grid, which is + // what actually paints the ring. + g.setHighlight(g.host.CurrentIndex()) g.overlay.Show() g.host.ForceRepaint() } @@ -604,11 +629,11 @@ func (g *Overview) applyFilter() { g.filterGen.Add(1) - // The highlight is a display index, so a filter that shortens the grid - // under it would leave it pointing past the last cell. - g.highlight = 0 - g.wrap.Refresh() + // The highlight is a display index, so a filter that shortens the grid + // under it would leave it pointing past the last cell. After the + // refresh, so GridWrap's cursor is moved against the new length. + g.setHighlight(0) if g.count() > 0 { g.wrap.ScrollTo(0) } diff --git a/internal/ui/grid/grid_test.go b/internal/ui/grid/grid_test.go index 9405bcc..3e0661b 100644 --- a/internal/ui/grid/grid_test.go +++ b/internal/ui/grid/grid_test.go @@ -201,6 +201,124 @@ func TestHandleKey_ArrowMovesHighlight(t *testing.T) { } } +// hover stands in for the pointer entering the cell at display index id. +// Fyne's GridWrap gives its items an onHovered that does exactly this call +// and nothing else, so driving the callback is the whole of a hover as far +// as the grid can observe it - the test driver has no pointer to move. +func hover(g *Overview, id int) { + g.wrap.OnHighlighted(id) +} + +// TestHover_MovesTheRingAndTheKeyboardCursor: the ring and GridWrap's own +// keyboard cursor are separate positions, and a hover only ever moved the +// first - so the next arrow key resumed from wherever the keyboard had last +// been rather than from the cell under the pointer. +func TestHover_MovesTheRingAndTheKeyboardCursor(t *testing.T) { + g := newOverview(t, hostWith(t, "a.jpg", "b.jpg", "c.jpg", "d.jpg")) + if err := g.Warm(); err != nil { + t.Fatalf("Warm: %v", err) + } + g.Toggle() + + hover(g, 2) + if g.Highlight() != 2 { + t.Fatalf("Highlight() = %d, want 2 right after hovering that cell", g.Highlight()) + } + + g.HandleKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + if g.Highlight() != 3 { + t.Errorf("Highlight() = %d, want 3 - Right should step on from the hovered cell", g.Highlight()) + } + + hover(g, 0) + g.HandleKey(&fyne.KeyEvent{Name: fyne.KeyLeft}) + if g.Highlight() != 0 { + t.Errorf("Highlight() = %d, want it to stay at 0 - Left from the hovered first cell has nowhere to go", g.Highlight()) + } +} + +// TestHover_OnTheHighlightedCellIsANoop covers the re-entry guard: moving +// the keyboard cursor fires the same callback a hover does, so an +// unguarded handler would recurse until the stack ran out. +func TestHover_OnTheHighlightedCellIsANoop(t *testing.T) { + g := newOverview(t, hostWith(t, "a.jpg", "b.jpg")) + if err := g.Warm(); err != nil { + t.Fatalf("Warm: %v", err) + } + g.Toggle() + + hover(g, 0) + hover(g, 0) + + if g.Highlight() != 0 { + t.Errorf("Highlight() = %d, want 0", g.Highlight()) + } +} + +// TestToggle_KeyboardCursorStartsOnTheCurrentImage: opening the grid puts +// the ring on the image on screen, and the arrow keys have to agree - they +// used to resume from cell 0 no matter where the ring was drawn. +func TestToggle_KeyboardCursorStartsOnTheCurrentImage(t *testing.T) { + host := hostWith(t, "a.jpg", "b.jpg", "c.jpg", "d.jpg") + host.index = 2 + g := newOverview(t, host) + if err := g.Warm(); err != nil { + t.Fatalf("Warm: %v", err) + } + g.Toggle() + + g.HandleKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + + if g.Highlight() != 3 { + t.Errorf("Highlight() = %d, want 3 - Right should step on from the image the grid opened on", g.Highlight()) + } +} + +// TestHandleRune_FilteringResetsTheKeyboardCursorToo: same reset as the +// ring's, since a cursor left past the end of the filtered set would send +// the first arrow key somewhere the user never was. +func TestHandleRune_FilteringResetsTheKeyboardCursorToo(t *testing.T) { + host := hostWith(t, "moon.jpg", "a.jpg", "b.jpg", "c.jpg") + host.index = 3 + g := newOverview(t, host) + if err := g.Warm(); err != nil { + t.Fatalf("Warm: %v", err) + } + g.Toggle() + + typeQuery(g, "moon") + g.HandleKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + + if g.Highlight() != 0 { + t.Errorf("Highlight() = %d, want it to stay at 0 - the filtered grid has a single cell", g.Highlight()) + } +} + +// TestHandleRune_NoMatchesLeavesTheKeyboardCursorAlone: an empty grid has +// no cell to put a cursor on, and widening the query again must not have +// left one pointing into the set that was filtered away. +func TestHandleRune_NoMatchesLeavesTheKeyboardCursorAlone(t *testing.T) { + g := newOverview(t, hostWith(t, "a.jpg", "b.jpg", "c.jpg")) + if err := g.Warm(); err != nil { + t.Fatalf("Warm: %v", err) + } + g.Toggle() + + typeQuery(g, "zzz") + g.HandleKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + if g.Highlight() != 0 { + t.Errorf("Highlight() = %d, want 0 with nothing to highlight", g.Highlight()) + } + + g.HandleKey(&fyne.KeyEvent{Name: fyne.KeyBackspace}) + g.HandleKey(&fyne.KeyEvent{Name: fyne.KeyBackspace}) + g.HandleKey(&fyne.KeyEvent{Name: fyne.KeyBackspace}) + g.HandleKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + if g.Highlight() != 1 { + t.Errorf("Highlight() = %d, want 1 - Right from the reset cursor once every cell is back", g.Highlight()) + } +} + func TestHandleKey_LeftAtStartIsNoop(t *testing.T) { g := newOverview(t, hostWith(t, "a.jpg", "b.jpg")) if err := g.Warm(); err != nil { diff --git a/todos.md b/todos.md index 80d599c..56fbf9a 100644 --- a/todos.md +++ b/todos.md @@ -2,6 +2,10 @@ ## Done +- grid hover moves the keyboard position + - Hovering a thumbnail moves GridWrap's own keyboard cursor with the ring, + so the next arrow key steps on from the cell under the pointer. + - favorites menu - Save the currently open file list as a named collection. - Reopen and remove saved collections from a startup-populated menu. @@ -9,8 +13,6 @@ ## TODO - - When in Galery mode and hovering over a preview image the index for the - keyboard position should lso be updated to that element. - favorites disk thumbnail cache Generate preview images in the background under each favorite's cache directory. When a favorite is loaded, let the grid read those previews and From 714fe0bcb508b40be93cdf7eed02ccac6fbac943 Mon Sep 17 00:00:00 2001 From: frathe Date: Wed, 19 Aug 2026 15:53:20 +0200 Subject: [PATCH 5/6] Added location map to exif window --- ARCHITECTURE.md | 12 +- README.md | 8 +- THIRD-PARTY-NOTICES.md | 43 +++ docs/index.html | 6 +- go.mod | 1 + go.sum | 2 + internal/imaging/exif.go | 148 +++++++- internal/imaging/exif_test.go | 373 +++++++++++++++++++++ internal/ui/exifwin/exifwin.go | 288 +++++++++++++++- internal/ui/exifwin/exifwin_test.go | 420 +++++++++++++++++++++++ internal/ui/exifwin/tiles.go | 404 ++++++++++++++++++++++ internal/ui/exifwin/tiles_test.go | 500 ++++++++++++++++++++++++++++ internal/ui/help/manual.md | 38 ++- internal/ui/help/manual_de.md | 36 +- internal/uitest/uitest.go | 116 +++++++ todos.md | 6 + translations/de.json | 5 + translations/en.json | 5 + 18 files changed, 2373 insertions(+), 38 deletions(-) create mode 100644 internal/ui/exifwin/tiles.go create mode 100644 internal/ui/exifwin/tiles_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 73b293b..ba4fab1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -86,7 +86,7 @@ one. Sizes below are that interface, which is the honest measure of how coupled | `grid/` | The full-window thumbnail overview (`G` key): a virtualized `widget.GridWrap` over every loaded image, owning its own thumbnail cache (a separate *byte* budget from `imgCache`, so neither evicts the other) and the bounded worker pool that fills it. `SetCacheBytes` is its one setter, retuning that budget while the app runs — the same shape `slideshow.Controller.SetInterval` already has. Also owns the filename search (`/`, fed by `HandleRune`): a grid-local filter that renumbers the cells it draws while leaving the app's file set untouched — `matches` maps display index → host index, and everything crossing that boundary (`ShowImage`, `FileAt`) goes through `fileIndex`. `filterGen` is the staleness guard it adds alongside the host generation and cell recycling, for the one thing neither can see: a keystroke changes neither the file set nor a cell's id, only what that id *means*. Also owns the **multi-select** (`selection.go`): Cmd/Ctrl+click toggles a cell, Shift+click extends a range, Space and Cmd/Ctrl+A do the same from the keyboard, and `Targets()` is what a batch action acts on — the selection, or the highlighted cell alone when there isn't one. The set holds *host* indices, not the display indices actually clicked, so it survives a filter change; `displayIndex` is `fileIndex`'s inverse, needed to walk a Shift+click's range in display space. Escape stages (selection → search → grid), and both selection gestures call `Host.Unfocus` themselves, since GridWrap grabs canvas focus on every tap and only `Close` used to hand it back. `FilesChanged` is how the app resyncs it after a batch delete shrinks the file set under it. The ring and GridWrap's *own* keyboard cursor are two positions, and GridWrap moves the latter only for the arrow keys it handles itself — so every move of the ring goes through `setHighlight`, which drives both; `OnHighlighted` (fired by hover *and* by arrow keys) delegates to it behind an `id == g.highlight` guard, which is also what stops `wrap.Highlight`'s re-entry through that callback from recursing | 8-method `Host` — the search added none of them and multi-select added exactly one (`Modifiers`, since a Fyne tap carries no modifier state); knows nothing about the slideshow, and nothing about deletion or the clipboard — see below | | `deletion/` | The Shift+Delete confirmation flow: its own card and button selection state, followed by a recoverable `trash.Move`. Takes a **set** of `Target`s (`RequestFiles`), not just the file on screen — `Request` is now the one-target wrapper over it, and worded identically for one file so the existing golden masters still hold. A batch's moves run one after another on the single background goroutine, collecting failures rather than aborting, so one file the OS refuses to move costs neither the rest of the batch nor the truth of the toast (`moved 9 of 12 files…`); only what actually moved is removed from the file set | 7-method `Host` (`CurrentFile`, `RemoveFiles`, `ShowImage`, `ShowEmptyStateError`, `ShowToast`, `ForceRepaint`, `Generation`) — the first of the consumer-side interfaces the split is built on. `RemoveFiles` takes a slice because removing a batch one index at a time would shift every later index out from under the list already captured | | `slideshow/` | Picture-frame mode (`P` key): the full-screen switch, the auto-advance goroutine, the interval `Up`/`Down` tunes, and the `winpos.Tracker` capture/restore that puts the window back where the user left it | 2-method `Host` (`FileCount`, `Advance`) — the smallest in the split; knows nothing about the grid — see below | -| `exifwin/` | The EXIF metadata panel (`E` key and the info overlay's "Show EXIF data" link) over `internal/imaging`'s `ReadMetadata`. Remembers where and how large it was left (`RestoreGeometry`/`Geometry`/`StopTracking`, three lines over `widgets.Singleton`) | One `func() (fyne.URI, bool)` accessor — a single function is a smaller, more honest dependency than a one-method interface | +| `exifwin/` | The EXIF metadata panel (`E` key and the info overlay's "Show EXIF data" link) over `internal/imaging`'s `ReadMetadata`. Below the tag list sits a collapsible **Location** section (`fyne.io/x/fyne`'s `widget.Map` over OpenStreetMap tiles, a marker on the capture position), hidden outright for a photo with no GPS tags and collapsed on every fresh open — which is what keeps it from fetching a tile unasked. The disclosure is hand-rolled (a button plus a hidden body) rather than a `widget.Accordion` precisely because expanding is the moment tiles start downloading and `Accordion` offers no way to be told. `tiles.go` is why the map doesn't freeze the app: the widget fetches tiles *inside* its raster draw, on the UI goroutine, so `tileFetcher` is installed as its `http.Client` transport and answers a cache miss instantly with `errTilePending` while downloading in the background — the widget skips that tile for the frame (and, importantly, does not cache the failure), and a redraw follows once the batch lands. Expanding first runs `Warm`, a 5×5 prefetch around the position behind a spinner, with the map hidden until it completes so the first frame is whole rather than a grid of holes. `quietPendingTiles` keeps that same design from filling the log: the widget reports every tile it doesn't get as an error, once per tile per frame, so a zoom or pan onto tiles still downloading would write dozens of blocks a second about this package's normal operation - `tileLogFilter` drops exactly the blocks caused by `errTilePending` and passes everything else, including a `tile fetch error` from any other cause. The panel's own content is a `Border` — metadata label on top, Location section filling the rest — so the map grows with the window rather than sitting at a fixed height; a transparent spacer behind it keeps a floor. A geotagged photo's latitude and longitude also appear as ordinary lines in the tag list. Remembers where and how large it was left (`RestoreGeometry`/`Geometry`/`StopTracking`, three lines over `widgets.Singleton`) | One `func() (fyne.URI, bool)` accessor — a single function is a smaller, more honest dependency than a one-method interface | | `help/` | The manual, the About box, and the Help menu, plus the embedded `manual.md`/`manual_de.md` (`currentManual` picks by system locale - German for `de*`, English otherwise). `Menu()` returns the Help `*fyne.Menu` on its own (not a whole `*fyne.MainMenu`) so `internal/ui`'s `menu.go` can compose it with the File menu | **Nothing at all** — no interface, no callbacks: everything it draws comes from `New(app, title, art)` | | `settingswin/` | The Settings window (File > Settings…): one `widget.Form` (sort order, picture-frame interval, folder-scan cap, max window width, max window height, max image cache MB, max thumbnail cache MB, max file size MB) plus two `widget.Check`s (merge mode, picture-frame shuffle) below it. Every control seeds from its `Host` getter and pushes a change straight back through the matching setter on its own `OnChanged` — no Save/Apply step, no draft state of its own. The three numeric entries validate via `fyne.io/fyne/v2/data/validation.NewRegexp` (positive whole numbers only) on top of each `Host` setter's own domain clamp (e.g. `SetMaxWindowWidth` flooring at the drop-zone size). Remembers its own geometry exactly as `exifwin/` does, through the same three `widgets.Singleton` methods | `Host`: a getter/setter pair per preference (`SortMode`/`SetSortMode`, `MergeMode`/`SetMergeMode`, `SlideShuffle`/`SetSlideShuffle`, `SlideInterval`/`SetSlideInterval`, `MaxScan`/`SetMaxScan`, `MaxWindowWidth`/`SetMaxWindowWidth`, `MaxWindowHeight`/`SetMaxWindowHeight`, `MaxImageCacheMB`/`SetMaxImageCacheMB`, `MaxThumbCacheMB`/`SetMaxThumbCacheMB`, `MaxFileSizeMB`/`SetMaxFileSizeMB`) | | `favorites/` | The Favorites menu and its add, overwrite, manage, and remove dialogs. `New` performs no disk access; production calls `SetDir` from `Run`, while tests opt into a temporary directory. The first ten case-insensitively sorted entries carry Cmd/Ctrl+1–9,0 accelerators; `Open` resolves those permanently registered slots against the latest refresh. Removal runs off the UI goroutine because the OS trash implementation may shell out. | 4-method `Host` (`FileCount`, `FileAt`, `OpenFiles`, `ShowToast`) | @@ -136,7 +136,7 @@ display scale changes. Zero dependency on `viewer`. | `loader.go` | `LoadedImage`, `NewImgCache`, `ReadAndProbe`, `DecodeLoaded`, `LoadImage`, `IsSupportedImage`, `InvalidDimensionsError`; plus the encoded-input ceiling — `DefaultMaxEncodedBytes`/`MaxEncodedBytes`/`SetMaxEncodedBytes` and `InputTooLargeError`, which `readRawBytes` enforces with an `io.LimitReader` of limit+1 (the extra byte is what tells "ended at the limit" from "truncated there"). That limit is a package-level atomic rather than a parameter because it is genuinely process-wide decode policy — see its own comment. `LoadedImage`'s `FileSize` and `HasEXIF` are both filled in by the *caller* (`internal/ui/load.go`, at the two sites that decode into the image cache) rather than by `DecodeLoaded`: the thumbnail path decodes through here too and needs neither | | `svg.go` | SVG format detection and size arithmetic. `isSVGData`'s root-element scan (`svgRootAttrs`) is the real format test, not whether `oksvg` parses the file: `oksvg` accepts a JSON document without complaint and reports a 0×0 viewBox for it, so "did it parse" can't mean "is it an SVG". `MinVectorWidth`/`MinVectorHeight` (520×340) are the floor a smaller SVG's logical size is raised to — deliberately equal to `internal/ui`'s `startW`/`startH`, the app's smallest window, so an icon-sized SVG opens filling that window instead of as a tiny stamp in its corner. `internal/imaging` can't import `internal/ui` to enforce that equality itself, so `internal/ui/vector_test.go`'s `TestVectorFloorMatchesStartWindowSize` pins the two constants together instead. `MaxVectorRasterPixels` caps a single rasterization — no longer a hard constant but derived from the user's image-cache setting (a quarter of the budget's bytes at 4 B/px, clamped to an 8 MP usability floor and the 32 MP `DefaultMaxVectorRasterPixels` ceiling; at the shipped 512 MB default that lands exactly on the old 32,000,000 constant). It is process-wide atomic state in the mold of `MaxEncodedBytes`, seeded by `buildViewer` and retuned by `internal/ui`'s `SetMaxImageCacheMB` (`memlimits.go`'s `vectorRasterPixelsFor` holds the derivation), and enforced by the exported `ClampVectorRaster` — exported because `internal/ui` must clamp its own re-render target through the same function *before* comparing it against the raster already on screen, or a target the cap would shrink anyway would look like a permanently unmet demand for a sharper image, and re-render forever. `svgSizeFrom` also repairs the 0×0 viewBox `oksvg` silently produces for `width="100%"` documents: `oksvg` abandons root-element parsing on the first attribute it can't read, which for most web-exported SVGs is that percentage width, before it ever reaches the viewBox that follows | | `vector.go` | `Vector`/`ParseVector`/`RasterAt`: a parsed SVG kept alive on `LoadedImage` so it can be rasterized again at a different pixel size whenever the zoom level or window size changes, instead of being decoded once like a raster format. `RasterAt`'s mutex guards the whole `SetTarget`-then-`Draw` sequence (`SetTarget` writes the icon's transform, `Draw` reads it) because two of `internal/ui/vector.go`'s `rasterizeVector` goroutines can be inside it on the same `*Vector` at once: one that already passed its own staleness check keeps running while a fresher scale change spawns another and bumps the generation - `TestRasterAtIsSafeForConcurrentUse` covers exactly this. (The grid's thumbnails are not a second party here: `LoadThumbnail` always decodes its own, ephemeral `*Vector` through a separate cache, and discards it after one raster.) Its `recover` sits inside that same lock: `oksvg` panics outright on some inputs (an extreme viewBox raises a slice-bounds panic), and letting that escape would both crash the app and leave the transform half-written under a still-held mutex | -| `exif.go` | EXIF orientation-tag parsing (unexported: `readEXIFOrientation`, `parseExifOrientation`) and the general-purpose tag reader `ReadMetadata`/`Metadata` (camera make/model, lens, exposure, aperture, ISO, focal length, capture date — GPS deliberately never read); falls back to `heic`/`avif`'s own `DecodeExif` for HEIC/AVIF files, which aren't JPEG-APP1-boxed | +| `exif.go` | EXIF orientation-tag parsing (unexported: `readEXIFOrientation`, `parseExifOrientation`) and the general-purpose tag reader `ReadMetadata`/`Metadata` (camera make/model, lens, exposure, aperture, ISO, focal length, capture date, and the GPS position the EXIF window's map view centers on — the `0x8825` pointer in IFD0 leads to a sub-IFD this reader now follows, and `Metadata.HasGPS` is what tells a photo at (0, 0) from one with no location tags); falls back to `heic`/`avif`'s own `DecodeExif` for HEIC/AVIF files, which aren't JPEG-APP1-boxed | | `orientation.go` | Pixel-level rotate/flip transforms (`ApplyOrientation`, `RotateSteps`) | | `gif.go` | Animated GIF frame decoding/compositing (unexported: `decodeAnimatedGIF`), under a cumulative byte budget: every frame is retained as a full composited RGBA canvas, so cost is canvas size × frame count. The check runs *before* `gif.DecodeAll`, against a frame count and canvas size that unexported `probeGIF`/`skipGIFExtension`/`skipGIFSubBlocks` walk out of the raw GIF block structure without decoding a pixel — so an over-budget animation allocates nothing at all, and the *transient* paletted decode is bounded too: `image/gif` rejects any frame larger than the logical screen, so `DecodeAll`'s own peak is at most a quarter of the budget just cleared. An over-budget GIF then takes the same nil-slice path a non-animated one does — `image.Decode` yields frame 0 — and reports `truncated` so the UI can say why it isn't moving. A zero budget means "never composite", which is what the thumbnail path passes. `probeGIF` mirrors `image/gif`'s own `readExtension` block for block and is deliberately *more lenient* than it, never stricter: accepting a file the decoder rejects merely lands on the static fallback, while rejecting one it accepts would stop a readable GIF animating. `FuzzProbeGIFAgreesWithDecodeAll` pins that agreement, and found the one case where a plain sub-block walk got it wrong | | `thumbnail.go` | `NewThumbCache`, `LoadThumbnail`, unexported `scaleToFit`/`fitEdge` — probes and decodes (`ReadAndProbe` + `DecodeLoaded`, the same pipeline `LoadImage` wraps) then downsamples (`golang.org/x/image/draw`, `ApproxBiLinear`) for `internal/ui/grid`. Passes a **zero animation budget**, so a long GIF no longer composites every frame here just to keep frame 0. An SVG never rasterizes at full logical size here: its branch aims `RasterAt` straight at the `fitEdge` target (~200 px), which is near-free, and discards the ephemeral `Vector` after that one raster | @@ -374,7 +374,7 @@ files, so it never reaches a production binary. Zero dependency on | File | Responsibility | |-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `uitest.go` | `TempJPEGURI`, `WriteTempFile`, `EncodeJPEG`/`EncodePNG`/`EncodeGIF`/`EncodeAnimatedGIF`, `SVGBytes`/`TempSVGURI` (a synthetic SVG with a given viewBox, so a rasterization of it has visibly non-zero pixels), `CaptureDateJPEG`, `TruncatedPNGHeader`, `FakeURI`, `ApproxEqual` | +| `uitest.go` | `TempJPEGURI`, `WriteTempFile`, `EncodeJPEG`/`EncodePNG`/`EncodeGIF`/`EncodeAnimatedGIF`, `SVGBytes`/`TempSVGURI` (a synthetic SVG with a given viewBox, so a rasterization of it has visibly non-zero pixels), `CaptureDateJPEG`, `GPSJPEG`/`TempGPSJPEGURI` (a JPEG carrying an Exif GPS sub-IFD, for the EXIF window's map), `TruncatedPNGHeader`, `FakeURI`, `ApproxEqual` | | `stubs.go` | `StubChooser`, `StubSaveChooser`, `StubClipboardCopy`, `StubClipboardCopyFiles`, `StubTrashMove`, `StubWallpaperSet` — swap `filepicker.Choose`/`filepicker.ChooseSave`/`clipboard.CopyImage`/`clipboard.CopyFiles`/`trash.Move`/`wallpaper.Set` for the duration of a test. `internal/ui`'s `newTestUI` also redirects `viewer.wallpaperDir` to a `t.TempDir()`, the way it neutralizes the toast's duration: the wallpaper copy is the one file this suite produces that is meant to outlive the process | Added 2026-08-14, replacing per-package copies of the same helpers — Go can't share unexported test helpers across @@ -435,10 +435,16 @@ Use `errcheck` command to check for unhadled errors. than after) + `internal/imaging/loader.go`'s `MaxEncodedBytes` (the ceiling on a file's *encoded* size, enforced before anything is decoded) - "Where does the EXIF panel live?" → `internal/ui/exifwin` +- "Why is the log not full of `tile fetch error`?" → `internal/ui/exifwin/tiles.go`'s `quietPendingTiles`/`tileLogFilter` +- "Why doesn't the EXIF window's map freeze the app while it loads?" → `internal/ui/exifwin/tiles.go` (a + non-blocking, byte-bounded caching transport under the map widget, whose own fetching happens inside its raster + draw) + `exifwin.go`'s `startWarm`/`syncLoading` (the prefetch and its spinner) - "Why is the info overlay's 'Show EXIF data' link missing?" → `internal/ui/info.go`'s `syncInfoOverlayVisibility` (it is only offered for a file that has metadata) + `viewer.currentHasEXIF` + `imaging.LoadedImage`'s `HasEXIF`, filled in by `load.go`'s `attemptLoad`/`preloadOne`. The `E` key is deliberately *not* conditional: it still opens the panel, which says so itself when there's nothing to show +- "Where is a photo's GPS position read, and where is it shown?" → `internal/imaging/exif.go`'s `parseGPSIFD` + + `internal/ui/exifwin`'s collapsible Location section and `formatExifMetadata`'s latitude/longitude lines - "How is EXIF orientation handled?" → `internal/imaging/exif.go` + `orientation.go` - "How does drag-and-drop / folder scanning work?" → `drop.go`'s `handleDrop` - "How is an image shown/preloaded/animated once loaded?" → `load.go` diff --git a/README.md b/README.md index fcb4532..9a78d53 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,11 @@ set with the keyboard. - EXIF orientation correction for JPEGs (auto-rotate/flip per the file's orientation tag) - EXIF data window (`E`, or a link in the info overlay) showing camera - make/model, lens, exposure, aperture, ISO, focal length, and capture - date, for files that carry them — GPS/location is deliberately never - read or shown + make/model, lens, exposure, aperture, ISO, focal length, capture date, + and the capture coordinates, for files that carry them — plus a + collapsible OpenStreetMap view pinned at the capture location for photos + with GPS tags (collapsed on every open, so no map tiles are fetched + unasked) - Drop multiple files at once and step through them with the arrow keys (wraps around at both ends), or jump to the first/last with `Home`/`End` - `G` opens a full-window thumbnail grid for jumping around a large drop by diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index af4b96e..ab207d3 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -265,6 +265,49 @@ Apache License --- +## fyne.io/x/fyne + +License: BSD-3-Clause +Source: https://github.com/fyne-io/fyne-x/blob/master/LICENSE + +Used for the `Map` widget behind the EXIF window's location view. The map +tiles it renders are served by [OpenStreetMap](https://openstreetmap.org) +and are © OpenStreetMap contributors, available under the +[Open Database License](https://www.openstreetmap.org/copyright); the widget +displays that attribution itself, in the corner of every map it draws. + +``` +BSD 3-Clause License + +Copyright (C) 2020 Fyne.io developers and community (see AUTHORS) +All rights reserved. + + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Fyne.io nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +--- + ## github.com/anthonynsimon/bild License: MIT diff --git a/docs/index.html b/docs/index.html index 8938920..505590e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -336,8 +336,10 @@

Animated GIFs

  • EXIF aware

    JPEGs auto-rotate to their orientation tag, and E shows - camera, lens, exposure, aperture, ISO and capture date. GPS location is - deliberately never read or shown.

    + camera, lens, exposure, aperture, ISO, capture date and coordinates + — plus a + collapsible OpenStreetMap view pinned at the spot a GPS-tagged photo + was taken, collapsed until you ask for it.

  • Sorting that makes sense

    diff --git a/go.mod b/go.mod index 57012be..0d0643b 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.6 require ( fyne.io/fyne/v2 v2.8.0 + fyne.io/x/fyne v0.0.0-20260712112324-6989f2f174fb github.com/fyne-io/image v0.1.1 github.com/fyne-io/oksvg v0.2.0 github.com/gen2brain/avif v0.6.0 diff --git a/go.sum b/go.sum index cd684c9..0fd20a4 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ fyne.io/fyne/v2 v2.8.0 h1:KNUdIk1eKsXSPy/wU6MdiR1hppAPvyzbjPbtJ8h6EUQ= fyne.io/fyne/v2 v2.8.0/go.mod h1:tLJK7CVtUBOnMiSDR+J88t/quiGuEhwGs09tIVM1RXg= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +fyne.io/x/fyne v0.0.0-20260712112324-6989f2f174fb h1:oa8Pqo2Xis8dWEB5sQRLL4hbwB8mLusOTXBP1xYvGwY= +fyne.io/x/fyne v0.0.0-20260712112324-6989f2f174fb/go.mod h1:UzabvSVT4msa76BU2Mw95m8i3yThjwhZyugBZ5Wh0hs= 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= diff --git a/internal/imaging/exif.go b/internal/imaging/exif.go index c1374c4..3ea83cc 100644 --- a/internal/imaging/exif.go +++ b/internal/imaging/exif.go @@ -134,11 +134,10 @@ func parseExifOrientation(seg []byte) int { // Metadata is the subset of a photo's Exif tags the EXIF window (see // internal/ui/exifwin) displays: camera make/model, lens, exposure, -// aperture, ISO, focal length, and capture date. GPS is deliberately not -// read at all - it lives in its own sub-IFD (pointer tag 0x8825) that -// ReadMetadata never follows - so location data never even reaches this -// struct, let alone the UI. A zero Metadata (every field "") means either -// the file has no Exif data or none of these particular tags. +// aperture, ISO, focal length, capture date, and - only where the photo +// carries one - the GPS position its map view centers on. A zero Metadata +// (every field "", no position) means either the file has no Exif data or +// none of these particular tags. type Metadata struct { Make string Model string @@ -149,6 +148,15 @@ type Metadata struct { FocalLength string DateTaken string + // Latitude and Longitude are the capture position in signed decimal + // degrees (north and east positive), read from the GPS sub-IFD that + // IFD0's pointer tag 0x8825 locates. Only meaningful when HasGPS is + // set: a photo without location tags leaves all three zero, which is + // what keeps the EXIF window's map collapsed and hidden. + Latitude float64 + Longitude float64 + HasGPS bool + // DateTakenTime is DateTaken's underlying value, parsed from the same // raw Exif tag - for callers that need to compare or sort capture // dates (see CaptureDate in loader.go and internal/filesort's @@ -227,11 +235,11 @@ func ReadMetadata(data []byte) Metadata { // GIF, WebP, BMP, TIFF, ICO, XPM). func isobmffMetadata(data []byte) Metadata { if ex, err := heic.DecodeExif(bytes.NewReader(data)); err == nil { - return metadataFromISOBMFFExif(ex.Make, ex.Model, ex.ExposureTime, ex.FNumber, ex.ISOSpeed, ex.FocalLength, ex.DateTimeOriginal, ex.DateTime) + return metadataFromISOBMFFExif(ex.Make, ex.Model, ex.ExposureTime, ex.FNumber, ex.ISOSpeed, ex.FocalLength, ex.DateTimeOriginal, ex.DateTime, ex.GPSLatitude, ex.GPSLongitude) } if ex, err := avif.DecodeExif(bytes.NewReader(data)); err == nil { - return metadataFromISOBMFFExif(ex.Make, ex.Model, ex.ExposureTime, ex.FNumber, ex.ISOSpeed, ex.FocalLength, ex.DateTimeOriginal, ex.DateTime) + return metadataFromISOBMFFExif(ex.Make, ex.Model, ex.ExposureTime, ex.FNumber, ex.ISOSpeed, ex.FocalLength, ex.DateTimeOriginal, ex.DateTime, ex.GPSLatitude, ex.GPSLongitude) } return Metadata{} @@ -242,10 +250,18 @@ func isobmffMetadata(data []byte) Metadata { // reusing the same formatting helpers the JPEG APP1 walk uses so a HEIC/AVIF // photo's EXIF window reads the same as a JPEG's. LensModel has no // equivalent in either struct, so it's left unset, same as a JPEG missing -// that tag. -func metadataFromISOBMFFExif(cameraMake, model string, exposureTime, fNumber float64, iso int, focalLength float64, dateTimeOriginal, dateTime string) Metadata { +// that tag. Both decoders report an absent position as a zero latitude and +// longitude rather than a flag, so an exact (0, 0) is read as "no +// location": Null Island is open ocean, and treating that one point as +// missing is a better trade than showing a map of it for every photo that +// simply has no GPS tags. +func metadataFromISOBMFFExif(cameraMake, model string, exposureTime, fNumber float64, iso int, focalLength float64, dateTimeOriginal, dateTime string, lat, lon float64) Metadata { m := Metadata{Make: cameraMake, Model: model} + if (lat != 0 || lon != 0) && validCoordinates(lat, lon) { + m.Latitude, m.Longitude, m.HasGPS = lat, lon, true + } + if exposureTime > 0 { m.ExposureTime = formatExposureTime(exposureTime) } @@ -280,6 +296,10 @@ func metadataFromISOBMFFExif(cameraMake, model string, exposureTime, fNumber flo // DateTimeOriginal). const exifIFDPointer = 0x8769 +// gpsIFDPointer (0x8825) locates the GPS sub-IFD, whose latitude and +// longitude tags the EXIF window's map view centers on. +const gpsIFDPointer = 0x8825 + // parseExifMetadata reads tiff - the TIFF header and IFDs following the // "Exif\x00\x00" marker, same payload parseExifOrientation works from - and // walks IFD0 plus the Exif SubIFD it points to for the tags Metadata cares @@ -311,6 +331,9 @@ func parseExifMetadata(tiff []byte) Metadata { var exifIFDOffset uint32 haveExifIFD := false + var gpsIFDOffset uint32 + haveGPSIFD := false + walkIFD(tiff, bo, ifd0Offset, func(tag, typ uint16, val []byte) { switch tag { case 0x010F: // Make @@ -334,9 +357,20 @@ func parseExifMetadata(tiff []byte) Metadata { exifIFDOffset = v haveExifIFD = true } + case gpsIFDPointer: + if v, ok := uintValue(bo, typ, val); ok { + gpsIFDOffset = v + haveGPSIFD = true + } } }) + if haveGPSIFD { + if lat, lon, ok := parseGPSIFD(tiff, bo, gpsIFDOffset); ok { + m.Latitude, m.Longitude, m.HasGPS = lat, lon, true + } + } + if !haveExifIFD { return m } @@ -376,6 +410,77 @@ func parseExifMetadata(tiff []byte) Metadata { return m } +// parseGPSIFD reads the latitude/longitude pair out of the GPS sub-IFD at +// gpsOffset within tiff and converts it to signed decimal degrees. ok is +// false unless all four tags are present and readable and the result lands +// in valid coordinate ranges - a partial or malformed GPS IFD is treated +// as no location at all, in keeping with the rest of this reader. +func parseGPSIFD(tiff []byte, bo binary.ByteOrder, gpsOffset uint32) (lat, lon float64, ok bool) { + var latRef, lonRef string + var latDMS, lonDMS []float64 + + walkIFD(tiff, bo, gpsOffset, func(tag, typ uint16, val []byte) { + switch tag { + case 0x0001: // GPSLatitudeRef: "N" or "S" + if s, ok := asciiValue(typ, val); ok { + latRef = strings.ToUpper(s) + } + case 0x0002: // GPSLatitude: degrees, minutes, seconds + if v, ok := rationalsValue(bo, typ, val, 3); ok { + latDMS = v + } + case 0x0003: // GPSLongitudeRef: "E" or "W" + if s, ok := asciiValue(typ, val); ok { + lonRef = strings.ToUpper(s) + } + case 0x0004: // GPSLongitude + if v, ok := rationalsValue(bo, typ, val, 3); ok { + lonDMS = v + } + } + }) + + lat, latOK := degreesFromDMS(latDMS, latRef, "N", "S") + lon, lonOK := degreesFromDMS(lonDMS, lonRef, "E", "W") + + if !latOK || !lonOK || !validCoordinates(lat, lon) { + return 0, 0, false + } + + return lat, lon, true +} + +// degreesFromDMS converts one Exif degrees/minutes/seconds triple into +// signed decimal degrees, negating it for the southern/western hemisphere +// reference. ok is false for a missing triple or a reference that is +// neither of the two the axis allows - Exif writes the hemisphere only in +// that tag, so without it the sign is unknowable and the coordinate is +// unusable rather than merely ambiguous. +func degreesFromDMS(dms []float64, ref, positive, negative string) (float64, bool) { + if len(dms) != 3 || (ref != positive && ref != negative) { + return 0, false + } + + deg := dms[0] + dms[1]/60 + dms[2]/3600 + + if ref == negative { + deg = -deg + } + + return deg, true +} + +// validCoordinates reports whether lat/lon are real coordinates: in range +// and not NaN or infinite, which a rational with an absurd numerator could +// otherwise produce. +func validCoordinates(lat, lon float64) bool { + if math.IsNaN(lat) || math.IsNaN(lon) || math.IsInf(lat, 0) || math.IsInf(lon, 0) { + return false + } + + return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 +} + // walkIFD calls fn once per readable entry in the IFD at ifdOffset within // tiff. Entries with an unrecognized type, an implausible count, or a // value/offset that doesn't fit inside tiff are silently skipped rather @@ -504,6 +609,31 @@ func rationalValue(bo binary.ByteOrder, typ uint16, val []byte) (float64, bool) return float64(num) / float64(den), true } +// rationalsValue decodes val as n consecutive unsigned RATIONALs - the +// shape Exif uses for a GPS coordinate's degrees/minutes/seconds triple. +// ok is false for a wrong type, a value holding fewer than n rationals, or +// any zero denominator among them. +func rationalsValue(bo binary.ByteOrder, typ uint16, val []byte, n int) ([]float64, bool) { + if typ != 5 || len(val) < n*8 { + return nil, false + } + + out := make([]float64, n) + + for i := range out { + num := bo.Uint32(val[i*8 : i*8+4]) + den := bo.Uint32(val[i*8+4 : i*8+8]) + + if den == 0 { + return nil, false + } + + out[i] = float64(num) / float64(den) + } + + return out, true +} + // formatExposureTime renders a shutter speed in seconds as Exif-style // display text: "1/200 s" for anything faster than a second (the common // case), or "2.5 s" for a full second or slower (long exposures). diff --git a/internal/imaging/exif_test.go b/internal/imaging/exif_test.go index da04048..262c1af 100644 --- a/internal/imaging/exif_test.go +++ b/internal/imaging/exif_test.go @@ -4,12 +4,21 @@ import ( "bytes" "encoding/binary" "image/color" + "math" "os" "path/filepath" "testing" "time" ) +// approx reports whether two decimal degrees are equal to within a +// millionth of a degree (about 10 cm) - the DMS-to-degrees conversion is +// exact only for whole seconds, so coordinate assertions compare with a +// tolerance rather than for equality. +func approx(a, b float64) bool { + return math.Abs(a-b) < 1e-6 +} + // buildExifSegment builds the payload of an APP1 Exif segment (starting with // the "Exif\0\0" marker) that declares a single orientation tag. func buildExifSegment(t *testing.T, orientation uint16, bigEndian bool) []byte { @@ -275,6 +284,370 @@ func TestReadMetadata_FullTagSet(t *testing.T) { } } +// gpsFields is the raw GPS sub-IFD content buildGPSExifTIFF writes: the two +// hemisphere reference strings and the two degrees/minutes/seconds triples, +// each component a numerator/denominator pair so a test can write a +// fractional (or deliberately broken, zero-denominator) value. +type gpsFields struct { + latRef, lonRef string + lat, lon [3][2]uint32 + + // omitLatRef and omitLon drop a tag entirely, for the partial-IFD cases + // where the coordinate can't be resolved. + omitLatRef, omitLon bool +} + +// buildGPSExifTIFF builds a little-endian TIFF payload whose IFD0 holds +// nothing but the GPS sub-IFD pointer (0x8825), plus that sub-IFD. Refs are +// two bytes and so live inline in their entries; the DMS triples are 24 +// bytes each and need the trailing value area. +func buildGPSExifTIFF(t *testing.T, f gpsFields) []byte { + t.Helper() + + bo := binary.LittleEndian + + u16 := func(v uint16) []byte { b := make([]byte, 2); bo.PutUint16(b, v); return b } + u32 := func(v uint32) []byte { b := make([]byte, 4); bo.PutUint32(b, v); return b } + + dms := func(v [3][2]uint32) []byte { + var b []byte + for _, pair := range v { + b = append(b, u32(pair[0])...) + b = append(b, u32(pair[1])...) + } + return b + } + + gpsEntryCount := 4 + if f.omitLatRef { + gpsEntryCount-- + } + if f.omitLon { + gpsEntryCount -= 2 + } + + const headerSize = 8 + ifd0Offset := uint32(headerSize) + ifd0Size := 2 + 1*12 + 4 + gpsOffset := ifd0Offset + uint32(ifd0Size) + gpsSize := 2 + gpsEntryCount*12 + 4 + valueAreaStart := gpsOffset + uint32(gpsSize) + + var valueArea []byte + place := func(b []byte) uint32 { + offset := valueAreaStart + uint32(len(valueArea)) + valueArea = append(valueArea, b...) + return offset + } + + latOffset := place(dms(f.lat)) + lonOffset := place(dms(f.lon)) + + inlineASCII := func(s string) []byte { + b := make([]byte, 4) + copy(b, s) + return b + } + + buf := new(bytes.Buffer) + buf.WriteString("II") + buf.Write(u16(0x002A)) + buf.Write(u32(ifd0Offset)) + + buf.Write(u16(1)) + buf.Write(u16(0x8825)) // GPSIFDPointer + buf.Write(u16(4)) // LONG + buf.Write(u32(1)) + buf.Write(u32(gpsOffset)) + buf.Write(u32(0)) + + if buf.Len() != int(gpsOffset) { + t.Fatalf("IFD0 layout mismatch: wrote %d bytes, want %d", buf.Len(), gpsOffset) + } + + buf.Write(u16(uint16(gpsEntryCount))) + + if !f.omitLatRef { + buf.Write(u16(0x0001)) // GPSLatitudeRef + buf.Write(u16(2)) // ASCII + buf.Write(u32(2)) + buf.Write(inlineASCII(f.latRef)) + } + + buf.Write(u16(0x0002)) // GPSLatitude + buf.Write(u16(5)) // RATIONAL + buf.Write(u32(3)) + buf.Write(u32(latOffset)) + + if !f.omitLon { + buf.Write(u16(0x0003)) // GPSLongitudeRef + buf.Write(u16(2)) + buf.Write(u32(2)) + buf.Write(inlineASCII(f.lonRef)) + + buf.Write(u16(0x0004)) // GPSLongitude + buf.Write(u16(5)) + buf.Write(u32(3)) + buf.Write(u32(lonOffset)) + } + + buf.Write(u32(0)) + + if buf.Len() != int(valueAreaStart) { + t.Fatalf("GPS IFD layout mismatch: wrote %d bytes, want %d", buf.Len(), valueAreaStart) + } + + buf.Write(valueArea) + + return buf.Bytes() +} + +// gpsJPEG wraps a GPS TIFF payload in the APP1 segment and JPEG SOI marker +// ReadMetadata expects to walk. +func gpsJPEG(t *testing.T, f gpsFields) []byte { + t.Helper() + + seg := append([]byte("Exif\x00\x00"), buildGPSExifTIFF(t, f)...) + + return append([]byte{0xFF, 0xD8}, wrapAsAPP1(seg)...) +} + +func TestReadMetadata_GPS(t *testing.T) { + // 48° 51' 29.6" N, 2° 17' 40.2" E - the Eiffel Tower, with the seconds + // written as hundredths so the rational denominators aren't all 1. + m := ReadMetadata(gpsJPEG(t, gpsFields{ + latRef: "N", lat: [3][2]uint32{{48, 1}, {51, 1}, {2960, 100}}, + lonRef: "E", lon: [3][2]uint32{{2, 1}, {17, 1}, {4020, 100}}, + })) + + if !m.HasGPS { + t.Fatalf("ReadMetadata() = %+v, want a GPS position", m) + } + + if !approx(m.Latitude, 48.858222) || !approx(m.Longitude, 2.294500) { + t.Errorf("position = (%v, %v), want approximately (48.858222, 2.294500)", m.Latitude, m.Longitude) + } +} + +func TestReadMetadata_GPSSouthWestIsNegative(t *testing.T) { + m := ReadMetadata(gpsJPEG(t, gpsFields{ + latRef: "S", lat: [3][2]uint32{{33, 1}, {51, 1}, {31, 1}}, + lonRef: "W", lon: [3][2]uint32{{70, 1}, {39, 1}, {0, 1}}, + })) + + if !m.HasGPS { + t.Fatalf("ReadMetadata() = %+v, want a GPS position", m) + } + + if m.Latitude >= 0 || m.Longitude >= 0 { + t.Errorf("position = (%v, %v), want both negative for S/W", m.Latitude, m.Longitude) + } + + if !approx(m.Latitude, -33.858611) || !approx(m.Longitude, -70.650000) { + t.Errorf("position = (%v, %v), want approximately (-33.858611, -70.650000)", m.Latitude, m.Longitude) + } +} + +func TestReadMetadata_GPSLowercaseRef(t *testing.T) { + // Some writers emit a lowercase hemisphere ref; it means the same thing. + m := ReadMetadata(gpsJPEG(t, gpsFields{ + latRef: "s", lat: [3][2]uint32{{10, 1}, {0, 1}, {0, 1}}, + lonRef: "w", lon: [3][2]uint32{{20, 1}, {0, 1}, {0, 1}}, + })) + + if !m.HasGPS || m.Latitude != -10 || m.Longitude != -20 { + t.Errorf("ReadMetadata() = %+v, want (-10, -20) with HasGPS", m) + } +} + +func TestReadMetadata_GPSZeroIslandIsStillAPosition(t *testing.T) { + // Unlike the HEIC/AVIF path, which can't tell (0, 0) from "no tags", an + // explicit all-zero JPEG GPS IFD is a real - if unlikely - position. + m := ReadMetadata(gpsJPEG(t, gpsFields{ + latRef: "N", lat: [3][2]uint32{{0, 1}, {0, 1}, {0, 1}}, + lonRef: "E", lon: [3][2]uint32{{0, 1}, {0, 1}, {0, 1}}, + })) + + if !m.HasGPS || m.Latitude != 0 || m.Longitude != 0 { + t.Errorf("ReadMetadata() = %+v, want (0, 0) with HasGPS", m) + } +} + +func TestReadMetadata_GPSRejected(t *testing.T) { + cases := []struct { + name string + f gpsFields + }{ + {"missing latitude ref", gpsFields{ + lonRef: "E", lon: [3][2]uint32{{2, 1}, {0, 1}, {0, 1}}, + lat: [3][2]uint32{{48, 1}, {0, 1}, {0, 1}}, omitLatRef: true, + }}, + {"missing longitude", gpsFields{ + latRef: "N", lat: [3][2]uint32{{48, 1}, {0, 1}, {0, 1}}, omitLon: true, + }}, + {"unknown hemisphere ref", gpsFields{ + latRef: "X", lat: [3][2]uint32{{48, 1}, {0, 1}, {0, 1}}, + lonRef: "E", lon: [3][2]uint32{{2, 1}, {0, 1}, {0, 1}}, + }}, + {"latitude ref on the longitude axis", gpsFields{ + latRef: "N", lat: [3][2]uint32{{48, 1}, {0, 1}, {0, 1}}, + lonRef: "N", lon: [3][2]uint32{{2, 1}, {0, 1}, {0, 1}}, + }}, + {"zero denominator", gpsFields{ + latRef: "N", lat: [3][2]uint32{{48, 0}, {0, 1}, {0, 1}}, + lonRef: "E", lon: [3][2]uint32{{2, 1}, {0, 1}, {0, 1}}, + }}, + {"latitude out of range", gpsFields{ + latRef: "N", lat: [3][2]uint32{{91, 1}, {0, 1}, {0, 1}}, + lonRef: "E", lon: [3][2]uint32{{2, 1}, {0, 1}, {0, 1}}, + }}, + {"longitude out of range", gpsFields{ + latRef: "N", lat: [3][2]uint32{{48, 1}, {0, 1}, {0, 1}}, + lonRef: "E", lon: [3][2]uint32{{181, 1}, {0, 1}, {0, 1}}, + }}, + {"minutes push latitude past the pole", gpsFields{ + latRef: "N", lat: [3][2]uint32{{90, 1}, {1, 1}, {0, 1}}, + lonRef: "E", lon: [3][2]uint32{{2, 1}, {0, 1}, {0, 1}}, + }}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + m := ReadMetadata(gpsJPEG(t, c.f)) + + if m.HasGPS { + t.Errorf("ReadMetadata() = %+v, want no GPS position", m) + } + }) + } +} + +func TestReadMetadata_GPSExactlyAtTheRangeEdges(t *testing.T) { + m := ReadMetadata(gpsJPEG(t, gpsFields{ + latRef: "S", lat: [3][2]uint32{{90, 1}, {0, 1}, {0, 1}}, + lonRef: "W", lon: [3][2]uint32{{180, 1}, {0, 1}, {0, 1}}, + })) + + if !m.HasGPS || m.Latitude != -90 || m.Longitude != -180 { + t.Errorf("ReadMetadata() = %+v, want the in-range edge (-90, -180)", m) + } +} + +func TestReadMetadata_NoGPSIFDLeavesThePositionUnset(t *testing.T) { + tiff := buildFullExifTIFF(t, fullExifFields{ + make: "Canon", model: "EOS 90D", lensModel: "EF50mm f/1.8", + dateTimeOriginal: "2024:08:12 14:33:02", + exposureNum: 1, exposureDen: 200, + fNumberNum: 28, fNumberDen: 10, + iso: 400, + focalNum: 50, focalDen: 1, + }) + + seg := append([]byte("Exif\x00\x00"), tiff...) + + if m := ReadMetadata(append([]byte{0xFF, 0xD8}, wrapAsAPP1(seg)...)); m.HasGPS { + t.Errorf("ReadMetadata() = %+v, want no GPS position", m) + } +} + +func TestDegreesFromDMS(t *testing.T) { + cases := []struct { + name string + dms []float64 + ref string + want float64 + wantOK bool + }{ + {name: "north", dms: []float64{48, 30, 36}, ref: "N", want: 48.51, wantOK: true}, + {name: "south negates", dms: []float64{48, 30, 36}, ref: "S", want: -48.51, wantOK: true}, + {name: "zero", dms: []float64{0, 0, 0}, ref: "N", want: 0, wantOK: true}, + {name: "nil triple", dms: nil, ref: "N"}, + {name: "short triple", dms: []float64{48, 30}, ref: "N"}, + {name: "long triple", dms: []float64{48, 30, 36, 1}, ref: "N"}, + {name: "empty ref", dms: []float64{48, 30, 36}, ref: ""}, + {name: "wrong axis ref", dms: []float64{48, 30, 36}, ref: "E"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := degreesFromDMS(c.dms, c.ref, "N", "S") + + if ok != c.wantOK { + t.Fatalf("degreesFromDMS() ok = %v, want %v", ok, c.wantOK) + } + + if ok && !approx(got, c.want) { + t.Errorf("degreesFromDMS() = %v, want %v", got, c.want) + } + }) + } +} + +func TestValidCoordinates(t *testing.T) { + cases := []struct { + name string + lat, lon float64 + want bool + }{ + {"origin", 0, 0, true}, + {"north-east edge", 90, 180, true}, + {"south-west edge", -90, -180, true}, + {"just past the pole", 90.000001, 0, false}, + {"just past the antimeridian", 0, 180.000001, false}, + {"just inside the pole", 89.999999, 0, true}, + {"NaN latitude", math.NaN(), 0, false}, + {"NaN longitude", 0, math.NaN(), false}, + {"infinite latitude", math.Inf(1), 0, false}, + {"negative infinite longitude", 0, math.Inf(-1), false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := validCoordinates(c.lat, c.lon); got != c.want { + t.Errorf("validCoordinates(%v, %v) = %v, want %v", c.lat, c.lon, got, c.want) + } + }) + } +} + +func TestRationalsValue(t *testing.T) { + bo := binary.LittleEndian + + rationals := func(pairs ...uint32) []byte { + b := make([]byte, 0, len(pairs)*4) + for _, v := range pairs { + b = binary.LittleEndian.AppendUint32(b, v) + } + return b + } + + t.Run("three rationals", func(t *testing.T) { + got, ok := rationalsValue(bo, 5, rationals(1, 2, 3, 4, 10, 4), 3) + + if !ok || len(got) != 3 || got[0] != 0.5 || got[1] != 0.75 || got[2] != 2.5 { + t.Errorf("rationalsValue() = %v, %v, want [0.5 0.75 2.5], true", got, ok) + } + }) + + t.Run("wrong type", func(t *testing.T) { + if _, ok := rationalsValue(bo, 4, rationals(1, 2, 3, 4, 5, 6), 3); ok { + t.Error("rationalsValue() ok = true for a LONG entry, want false") + } + }) + + t.Run("truncated", func(t *testing.T) { + if _, ok := rationalsValue(bo, 5, rationals(1, 2, 3, 4), 3); ok { + t.Error("rationalsValue() ok = true for two rationals, want false") + } + }) + + t.Run("zero denominator", func(t *testing.T) { + if _, ok := rationalsValue(bo, 5, rationals(1, 0, 3, 4, 5, 6), 3); ok { + t.Error("rationalsValue() ok = true for a zero denominator, want false") + } + }) +} + func TestReadMetadata_NoExifData(t *testing.T) { data := encodeJPEG(t, 4, 4, color.White) diff --git a/internal/ui/exifwin/exifwin.go b/internal/ui/exifwin/exifwin.go index 17aaadc..924c389 100644 --- a/internal/ui/exifwin/exifwin.go +++ b/internal/ui/exifwin/exifwin.go @@ -1,6 +1,9 @@ // Package exifwin is the EXIF metadata window: a small panel listing the // current image's camera settings, opened with the E key or the info -// overlay's "Show EXIF data" link. +// overlay's "Show EXIF data" link. Below the list sits a collapsible +// OpenStreetMap view, shown only for a photo that carries GPS tags and +// collapsed until the user expands it - which is also what keeps the widget +// from fetching any map tiles unasked. // // It takes no host interface, only a `current` func: everything it needs // from the app is "which file is on screen, if any", and one accessor is a @@ -10,12 +13,16 @@ package exifwin import ( "context" "fmt" + "image/color" "strings" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + xwidget "fyne.io/x/fyne/widget" "github.com/frathe/picfetch/internal/imaging" "github.com/frathe/picfetch/internal/ui/widgets" @@ -24,6 +31,12 @@ import ( const ( exifW = 420.0 exifH = 360.0 + + // mapH is the least the map opens at, and mapZoom how far in it + // starts: close enough to read the streets around the pin, far enough + // to place it in its town. Beyond mapH the map follows the window. + mapH = 240.0 + mapZoom = 15 ) // Window is the EXIF panel. At most one is open at a time (widgets. @@ -42,12 +55,43 @@ type Window struct { // text is the panel's content label, live only while the window is // open (nil otherwise, which is what makes Refresh a no-op then). text *widget.Label + + // locationMap and location are the OpenStreetMap view and the + // collapsible section holding it, both live only while the window is + // open. The section is hidden entirely for a photo with no GPS tags, + // and starts collapsed otherwise: no tiles are fetched until the user + // asks to see them. + locationMap *xwidget.Map + location *fyne.Container + toggle *widget.Button + body *fyne.Container + loading *fyne.Container + + // expanded is whether the user has opened the section; lat/lon/hasPos + // are the position the current image carries, kept so expanding later + // knows where to point without re-reading the file. + expanded bool + lat, lon float64 + hasPos bool + + // tiles downloads and caches the map's tiles off the UI goroutine - + // see tiles.go for why the widget's own fetching can't be left to it. + // warming and warmGen track the prefetch that fills the first view, + // warmDone lets tests wait for it. + tiles *tileFetcher + warming bool + warmGen int + warmDone chan struct{} } // New returns the EXIF window for application. current is called on every // open and refresh to find the file to read. func New(application fyne.App, current func() (fyne.URI, bool)) *Window { - return &Window{app: application, current: current} + return &Window{ + app: application, + current: current, + tiles: newTileFetcher(osmTiles, nil), + } } // Show opens the panel, or raises it and syncs it to the current image if @@ -66,11 +110,31 @@ func (w *Window) Show() { w.win.Show(w.app, lang.L("EXIF Data"), fyne.NewSize(exifW, exifH), func() fyne.CanvasObject { w.text = widget.NewLabel("") w.text.Wrapping = fyne.TextWrapWord + + w.buildLocation() w.Refresh() - return container.NewScroll(container.NewPadded(w.text)) + // Border, not a scrolled box: the metadata takes the height it + // needs at the top and the map section gets everything below it, + // so dragging the window taller makes the map taller with it. + // Nothing here needs to scroll - the panel's minimum size already + // covers the longest the metadata gets. + return container.NewBorder(container.NewPadded(w.text), nil, nil, nil, w.location) }, func() { + w.tiles.SetOnChange(nil) + + // Anything a prefetch still has in flight belongs to a window that + // no longer exists. + w.warmGen++ + w.text = nil + w.locationMap = nil + w.location = nil + w.toggle = nil + w.body = nil + w.loading = nil + w.expanded = false + w.warming = false }) } @@ -98,10 +162,191 @@ func (w *Window) Refresh() { data, _, err := imaging.ReadAndProbe(context.Background(), u) if err != nil { w.text.SetText(lang.L("Could not read this file's metadata.")) + w.showLocation(imaging.Metadata{}) + return + } + + m := imaging.ReadMetadata(data) + + w.text.SetText(formatExifMetadata(m)) + w.showLocation(m) +} + +// buildLocation assembles the collapsible location section: a disclosure +// button, and under it the map with a loading indicator stacked over it. +// +// It is a hand-rolled disclosure rather than a widget.Accordion because +// expanding is the moment the first tiles may be downloaded, and Accordion +// offers no way to be told when that happens - the whole point of this +// section is that nothing is fetched until the user asks for it. +func (w *Window) buildLocation() { + w.locationMap = xwidget.NewMapWithOptions( + xwidget.WithOsmTiles(), + xwidget.WithTileSource(w.tiles.template), + xwidget.WithHTTPClient(w.tiles.client()), + xwidget.WithZoomButtons(true), + xwidget.WithScrollButtons(false), + xwidget.AtZoomLevel(mapZoom), + ) + + // A tile that arrives after the frame that asked for it only reaches + // the screen if the map is told to redraw - see tiles.go. Redrawing + // once the batch is in, rather than per tile, is what keeps a pan + // across a dozen new tiles from queueing a dozen repaints of a map + // that is still mostly holes. + w.tiles.SetOnChange(func(pending int) { + if pending > 0 { + return + } + + fyne.Do(func() { + if w.locationMap == nil { + return + } + + w.syncLoading() + w.locationMap.Refresh() + }) + }) + + spinner := widget.NewProgressBarInfinite() + w.loading = container.NewCenter(container.NewVBox(widget.NewLabel(lang.L("Loading map…")), spinner)) + w.loading.Hide() + + // The map's MinSize is a single tile, so a transparent rectangle + // stacked behind it gives the section a floor to open at; above that + // the map grows with the window - see the panel's content layout. + spacer := canvas.NewRectangle(color.Transparent) + spacer.SetMinSize(fyne.NewSize(0, mapH)) + + w.body = container.NewStack(spacer, w.locationMap, w.loading) + w.body.Hide() + + w.toggle = widget.NewButtonWithIcon(lang.L("Location"), theme.MenuExpandIcon(), w.toggleLocation) + w.toggle.Alignment = widget.ButtonAlignLeading + w.toggle.Importance = widget.LowImportance + + w.location = container.NewBorder(w.toggle, nil, nil, nil, w.body) +} + +// toggleLocation opens or closes the section. Opening is what starts the +// download of the tiles around the capture position, and what puts the +// loading indicator up until they are all in. +func (w *Window) toggleLocation() { + w.expanded = !w.expanded + + if w.expanded { + w.toggle.SetIcon(theme.MenuDropDownIcon()) + w.body.Show() + + // Showing a child doesn't re-run its parent's layout, and a hidden + // child is given no space at all - without this the map would be + // revealed at zero height, and so never drawn. + w.location.Refresh() + w.startWarm() + return } - w.text.SetText(formatExifMetadata(imaging.ReadMetadata(data))) + w.toggle.SetIcon(theme.MenuExpandIcon()) + w.body.Hide() + w.location.Refresh() +} + +// startWarm downloads the block of tiles around the current position in +// the background, showing the loading indicator until they land. Its own +// generation counter is what keeps a prefetch for an image the user has +// already navigated away from - or for a window they have since closed - +// from touching anything when it finishes. +func (w *Window) startWarm() { + if !w.hasPos || w.locationMap == nil { + return + } + + w.warmGen++ + gen := w.warmGen + lat, lon := w.lat, w.lon + + done := make(chan struct{}) + w.warmDone = done + + w.warming = true + + // Drawing the map before its tiles are cached would have it ask for + // every one of them and get nothing (see tiles.go), logging a failure + // per tile per frame and showing a grid of holes. Keeping it hidden + // until the block is in trades that for a spinner and one clean frame. + w.locationMap.Hide() + w.syncLoading() + + tiles := w.tiles + + go func() { + tiles.Warm(lat, lon, mapZoom) + + fyne.Do(func() { + defer close(done) + + if gen != w.warmGen || w.locationMap == nil { + return + } + + w.warming = false + w.syncLoading() + w.locationMap.Show() + + // Revealing the map has to re-run the stack's layout for the + // same reason expanding the section does. + w.body.Refresh() + }) + }() +} + +// syncLoading shows the indicator while the first view is still being +// prefetched or any tile a pan or zoom asked for is still on its way, and +// hides it once nothing is outstanding. +func (w *Window) syncLoading() { + if w.loading == nil { + return + } + + if w.warming || w.tiles.Pending() > 0 { + w.loading.Show() + return + } + + w.loading.Hide() +} + +// showLocation points the map at m's capture position and reveals the +// section holding it, or hides the section entirely when m carries no GPS +// tags - most photos don't, and an empty map of the Atlantic is worse than +// no map at all. The section is left however the user set it: a photo that +// still has a position doesn't re-collapse an expanded map out from under +// them, only a fresh window starts collapsed. +func (w *Window) showLocation(m imaging.Metadata) { + if w.location == nil { + return + } + + w.lat, w.lon, w.hasPos = m.Latitude, m.Longitude, m.HasGPS + + if !m.HasGPS { + w.location.Hide() + return + } + + w.locationMap.SetMarkers([]xwidget.MapMarker{ + xwidget.NewMapMarker(m.Latitude, m.Longitude, lang.L("Photo location")), + }) + w.locationMap.PanToLatLon(m.Latitude, m.Longitude) + w.location.Show() + + // An expanded section following the user from image to image needs the + // new position's tiles, which are usually nowhere near the old ones. + if w.expanded { + w.startWarm() + } } // Open reports whether the panel is currently showing. @@ -144,10 +389,43 @@ func (w *Window) Text() *widget.Label { return w.text } +// Location returns the collapsible map section while the panel is open, or +// nil - for tests that need to check whether the current image has a +// position to show at all. +func (w *Window) Location() *fyne.Container { + return w.location +} + +// LocationExpanded reports whether the map section is open. False for a +// freshly-opened window, which is what keeps a photo's coordinates off the +// network until the user asks to see them. +func (w *Window) LocationExpanded() bool { + return w.expanded +} + +// ToggleLocation opens or closes the map section, as tapping its header +// does - the entry point for tests, and for any future menu item or key +// that wants to drive it. +func (w *Window) ToggleLocation() { + if w.toggle == nil { + return + } + + w.toggle.OnTapped() +} + // formatExifMetadata renders m as one display line per field that's // actually set - a file with only some tags (or, for non-JPEG formats, // none at all) just shows fewer lines rather than a wall of blanks. func formatExifMetadata(m imaging.Metadata) string { + // Six decimals is about a tenth of a metre - past anything a camera's + // GPS resolves, and short enough to read. + var lat, lon string + if m.HasGPS { + lat = fmt.Sprintf("%.6f°", m.Latitude) + lon = fmt.Sprintf("%.6f°", m.Longitude) + } + fields := []struct { label, value string }{ @@ -158,6 +436,8 @@ func formatExifMetadata(m imaging.Metadata) string { {lang.L("ISO"), m.ISO}, {lang.L("Focal length"), m.FocalLength}, {lang.L("Date taken"), m.DateTaken}, + {lang.L("Latitude"), lat}, + {lang.L("Longitude"), lon}, } var lines []string diff --git a/internal/ui/exifwin/exifwin_test.go b/internal/ui/exifwin/exifwin_test.go index 864f494..2aa19a9 100644 --- a/internal/ui/exifwin/exifwin_test.go +++ b/internal/ui/exifwin/exifwin_test.go @@ -2,9 +2,12 @@ package exifwin import ( "image/color" + "path/filepath" "testing" + "time" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/test" "github.com/frathe/picfetch/internal/imaging" @@ -52,6 +55,42 @@ func TestFormatExifMetadata(t *testing.T) { t.Errorf("formatExifMetadata() = %q, want %q", got, want) } }) + + t.Run("position set", func(t *testing.T) { + m := imaging.Metadata{Make: "Canon", Latitude: 48.858222, Longitude: 2.2945, HasGPS: true} + + want := "Camera: Canon\nLatitude: 48.858222°\nLongitude: 2.294500°" + if got := formatExifMetadata(m); got != want { + t.Errorf("formatExifMetadata() = %q, want %q", got, want) + } + }) + + t.Run("southern and western hemispheres keep their sign", func(t *testing.T) { + m := imaging.Metadata{Latitude: -33.856784, Longitude: -70.664247, HasGPS: true} + + want := "Latitude: -33.856784°\nLongitude: -70.664247°" + if got := formatExifMetadata(m); got != want { + t.Errorf("formatExifMetadata() = %q, want %q", got, want) + } + }) + + // Null Island is a real position, and the only one a zero-valued + // Metadata could be mistaken for - HasGPS is what tells them apart. + t.Run("a zero position is still shown when it is a position", func(t *testing.T) { + want := "Latitude: 0.000000°\nLongitude: 0.000000°" + if got := formatExifMetadata(imaging.Metadata{HasGPS: true}); got != want { + t.Errorf("formatExifMetadata() = %q, want %q", got, want) + } + }) + + t.Run("coordinates are left out without GPS", func(t *testing.T) { + m := imaging.Metadata{Make: "Canon", Latitude: 48.858222, Longitude: 2.2945} + + want := "Camera: Canon" + if got := formatExifMetadata(m); got != want { + t.Errorf("formatExifMetadata() = %q, want %q", got, want) + } + }) } // The panel needs a file to read before it will open at all (Show is a @@ -113,3 +152,384 @@ func TestShow_WithoutRestoreGeometryUsesTheBuiltInSize(t *testing.T) { t.Errorf("window size = %v, want the built-in %v", got, want) } } + +// gpsApp is testApp with a photo that carries GPS tags, for the map +// section's tests. The coordinates are the Eiffel Tower's. +func gpsApp(t *testing.T) (fyne.App, func() (fyne.URI, bool)) { + t.Helper() + app := test.NewApp() + u := uitest.TempGPSJPEGURI(t, "gps.jpg", 8, 8, 48.858222, 2.2945) + + return app, func() (fyne.URI, bool) { return u, true } +} + +func TestShow_LocationSectionIsShownCollapsedForAPhotoWithGPS(t *testing.T) { + app, current := gpsApp(t) + w := New(app, current) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + loc := w.Location() + if loc == nil { + t.Fatal("Location() = nil while the window is open") + } + + if !loc.Visible() { + t.Error("location section is hidden for a photo that has GPS tags, want shown") + } + + if w.LocationExpanded() { + t.Error("location section starts expanded, want collapsed until the user opens it") + } + + if w.body.Visible() { + t.Error("map is visible while the section is collapsed, want hidden") + } +} + +func TestShow_LocationSectionIsHiddenWithoutGPS(t *testing.T) { + app, current := testApp(t) // a plain JPEG, no Exif at all + w := New(app, current) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + if w.Location().Visible() { + t.Error("location section is shown for a photo with no GPS tags, want hidden") + } +} + +func TestRefresh_LocationSectionFollowsTheCurrentImage(t *testing.T) { + app := test.NewApp() + + withGPS := uitest.TempGPSJPEGURI(t, "gps.jpg", 8, 8, 48.858222, 2.2945) + without := uitest.TempJPEGURI(t, "plain.jpg", 8, 8, color.White) + + shown := withGPS + w := New(app, func() (fyne.URI, bool) { return shown, true }) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + if !w.Location().Visible() { + t.Fatal("location section is hidden for the GPS photo, want shown") + } + + shown = without + w.Refresh() + + if w.Location().Visible() { + t.Error("location section stayed visible after navigating to a photo with no GPS, want hidden") + } + + shown = withGPS + w.Refresh() + + if !w.Location().Visible() { + t.Error("location section stayed hidden after navigating back to the GPS photo, want shown") + } +} + +func TestRefresh_LocationSectionIsHiddenForAnUnreadableFile(t *testing.T) { + app := test.NewApp() + + missing := storage.NewFileURI(filepath.Join(t.TempDir(), "gone.jpg")) + shown := uitest.TempGPSJPEGURI(t, "gps.jpg", 8, 8, 48.858222, 2.2945) + + w := New(app, func() (fyne.URI, bool) { return shown, true }) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + shown = missing + w.Refresh() + + if w.Location().Visible() { + t.Error("location section stayed visible for an unreadable file, want hidden") + } + + if got, want := w.Text().Text, "Could not read this file's metadata."; got != want { + t.Errorf("text = %q, want %q", got, want) + } +} + +// waitForWarm blocks until the prefetch the last expand started has +// finished, so a test can assert on the loading indicator without racing +// it. Deliberately a channel wait rather than polling widget state: the +// Fyne test driver runs fyne.Do inline, so widget state is written from the +// fetching goroutine. +func waitForWarm(t *testing.T, w *Window) { + t.Helper() + + if w.warmDone == nil { + t.Fatal("no prefetch has been started") + } + + select { + case <-w.warmDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the map prefetch") + } +} + +// The tile server is held for every stretch in which the test's own +// goroutine touches widgets: the Fyne test driver runs fyne.Do inline on +// the calling goroutine, so a tile landing mid-assertion would have a +// background goroutine repainting the map while this one reads it. +func TestToggleLocation_ShowsAndHidesTheMap(t *testing.T) { + app, current := gpsApp(t) + + server := newTileServer(t) + release := server.hold() + + w := New(app, current) + w.tiles = fetcherFor(server) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + w.ToggleLocation() + + if !w.LocationExpanded() || !w.body.Visible() { + t.Fatal("map is still hidden after expanding the section, want shown") + } + + release() + waitForWarm(t, w) + + w.ToggleLocation() + + if w.LocationExpanded() || w.body.Visible() { + t.Error("map is still shown after collapsing the section, want hidden") + } +} + +func TestRefresh_IsANoOpWhileTheWindowIsClosed(t *testing.T) { + app, current := gpsApp(t) + w := New(app, current) + + w.Refresh() // must not panic on the nil label and nil map + + if w.Location() != nil { + t.Error("Location() is non-nil with no window open") + } +} + +func TestToggleLocation_ShowsTheLoadingIndicatorUntilTheTilesAreIn(t *testing.T) { + app, current := gpsApp(t) + + server := newTileServer(t) + release := server.hold() + t.Cleanup(release) + + w := New(app, current) + w.tiles = fetcherFor(server) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + if w.loading.Visible() { + t.Error("loading indicator is up before the section was ever expanded, want hidden") + } + + w.ToggleLocation() + + if !w.loading.Visible() { + t.Error("loading indicator is hidden while tiles are still downloading, want shown") + } + + if w.locationMap.Visible() { + t.Error("map is drawn while its tiles are still downloading, want it held back until they are in") + } + + release() + waitForWarm(t, w) + + if w.loading.Visible() { + t.Error("loading indicator stayed up after the tiles arrived, want hidden") + } + + if !w.locationMap.Visible() { + t.Error("map is still hidden after its tiles arrived, want shown") + } +} + +func TestToggleLocation_FetchesNothingUntilTheSectionIsExpanded(t *testing.T) { + app, current := gpsApp(t) + + server := newTileServer(t) + release := server.hold() + + w := New(app, current) + w.tiles = fetcherFor(server) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + w.Refresh() + + if got := server.count(); got != 0 { + t.Fatalf("server saw %d requests with the section collapsed, want none", got) + } + + w.ToggleLocation() + release() + waitForWarm(t, w) + + if server.count() == 0 { + t.Error("server saw no requests after expanding the section, want the prefetch") + } +} + +func TestRefresh_ExpandedSectionRefetchesForANewPosition(t *testing.T) { + app := test.NewApp() + + server := newTileServer(t) + + paris := uitest.TempGPSJPEGURI(t, "paris.jpg", 8, 8, 48.858222, 2.2945) + sydney := uitest.TempGPSJPEGURI(t, "sydney.jpg", 8, 8, -33.856785, 151.215194) + + release := server.hold() + + shown := paris + w := New(app, func() (fyne.URI, bool) { return shown, true }) + w.tiles = fetcherFor(server) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + w.ToggleLocation() + release() + waitForWarm(t, w) + + first := server.count() + + release = server.hold() + + shown = sydney + w.Refresh() + release() + waitForWarm(t, w) + + if server.count() <= first { + t.Error("navigating to a photo on the other side of the world fetched no new tiles") + } + + if w.loading.Visible() { + t.Error("loading indicator stayed up after the second prefetch, want hidden") + } +} + +func TestClose_StopsTheFetcherFromTouchingDeadWidgets(t *testing.T) { + app, current := gpsApp(t) + + server := newTileServer(t) + release := server.hold() + + w := New(app, current) + w.tiles = fetcherFor(server) + + w.Show() + w.ToggleLocation() + w.Window().Close() + + // The tiles land after the window is gone: the fetcher must find no + // callback and the prefetch must find no map, rather than panicking on + // either. + release() + waitForWarm(t, w) + + if w.Location() != nil { + t.Error("Location() is non-nil after the window closed") + } +} + +func TestPaint_DoesNotBlockOnSlowTiles(t *testing.T) { + app, current := gpsApp(t) + + server := newTileServer(t) + + w := New(app, current) + w.tiles = fetcherFor(server) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + w.Window().Resize(fyne.NewSize(exifW, exifH)) + w.ToggleLocation() + waitForWarm(t, w) + + if w.locationMap.Size().IsZero() { + t.Fatal("expanded map has no size, so this test would not be painting it at all") + } + + // Panning off the prefetched block is the case that still reaches the + // network from inside the widget's raster draw - which runs on the UI + // goroutine, so before this package's tile plumbing existed a frame + // like this froze the whole app for as long as the server took. + release := server.hold() + t.Cleanup(release) + + w.locationMap.PanEast() + w.locationMap.PanEast() + w.locationMap.PanEast() + + start := time.Now() + w.Window().Canvas().Capture() + elapsed := time.Since(start) + + if elapsed > 2*time.Second { + t.Errorf("painting the map took %v while the tile server was hanging, want a prompt frame", elapsed) + } +} + +func TestToggleLocation_ExpandedMapGetsRealSpace(t *testing.T) { + app, current := gpsApp(t) + + server := newTileServer(t) + + w := New(app, current) + w.tiles = fetcherFor(server) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + w.Window().Resize(fyne.NewSize(exifW, exifH)) + w.ToggleLocation() + waitForWarm(t, w) + + // Revealing a child does not re-run its parent's layout by itself, and + // a hidden child is given no space: without an explicit refresh the + // map is "visible" at zero height and never drawn at all. + if got := w.locationMap.Size(); got.Height < mapH { + t.Errorf("expanded map size = %v, want at least %v tall", got, mapH) + } +} + +func TestToggleLocation_MapGrowsWithTheWindow(t *testing.T) { + app, current := gpsApp(t) + + server := newTileServer(t) + + w := New(app, current) + w.tiles = fetcherFor(server) + + w.Show() + t.Cleanup(func() { w.Window().Close() }) + + w.Window().Resize(fyne.NewSize(exifW, exifH)) + w.ToggleLocation() + waitForWarm(t, w) + + before := w.locationMap.Size().Height + + w.Window().Resize(fyne.NewSize(exifW, exifH+300)) + + // The map fills what the metadata above it leaves, so a taller window + // is a taller map - the whole extra height, since nothing else in the + // panel grows. + if got := w.locationMap.Size().Height; got < before+300 { + t.Errorf("map height after growing the window by 300 = %v, want at least %v", got, before+300) + } +} diff --git a/internal/ui/exifwin/tiles.go b/internal/ui/exifwin/tiles.go new file mode 100644 index 0000000..d16fd9a --- /dev/null +++ b/internal/ui/exifwin/tiles.go @@ -0,0 +1,404 @@ +package exifwin + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + "math" + "net/http" + "sync" + "time" + + "github.com/frathe/picfetch/internal/imaging" +) + +const ( + // osmTiles is the tile URL the map is drawn from, in the + // zoom/x/y order fyne.io/x/fyne's Map formats its arguments in. + osmTiles = "https://tile.openstreetmap.org/%d/%d/%d.png" + + // userAgent identifies this app to the tile server, as + // OpenStreetMap's tile usage policy requires - the map widget's own + // generic "Fyne-X Map Widget" would not. + userAgent = "PicFetch/1.0 (+https://github.com/frathe/picfetch)" + + // tileBudget bounds the decoded-tile cache. An OSM tile is a PNG of + // roughly 10-50 KB, so this holds several hundred of them - far more + // than the handful of screens' worth a session's EXIF windows look at. + tileBudget = 16 << 20 + + // maxTileBytes is the most this will read from one tile response, so a + // server answering with something enormous can't grow the cache + // unbounded before the budget above ever sees it. + maxTileBytes = 4 << 20 + + // tileTimeout bounds a single tile request; tileWorkers bounds how many + // of them a prefetch runs at once, both to stay a good citizen of a + // donated tile server and to keep a stalled request from pinning the + // whole prefetch - which is also how long the "loading" indicator can + // linger on a network that accepts connections and then says nothing. + tileTimeout = 10 * time.Second + tileWorkers = 4 + + // tileRetryAfter is how long a failed tile is left alone before it is + // tried again. Without it an offline session would re-request every + // missing tile on every single repaint. + tileRetryAfter = 30 * time.Second + + // prefetchRadius is how many tiles beyond the center one a prefetch + // warms in each direction: a 5x5 block, comfortably more than the + // panel-sized map draws at once. + prefetchRadius = 2 +) + +// errTilePending is what the map widget's HTTP client returns for a tile +// that isn't cached yet. It is the whole point of this file: the widget +// fetches tiles from *inside* its raster draw function, which runs on the +// UI goroutine, so letting that call reach the network freezes the app for +// as long as the download takes. Failing instantly instead - while the +// real download runs in the background, and the map is refreshed once it +// lands - keeps the draw non-blocking. The widget logs the failure and +// skips that tile for this frame, and, crucially, does not cache the +// failure, so the refresh redraws it for real. +var errTilePending = errors.New("tile not downloaded yet") + +// The map widget calls fyne.LogError("tile fetch error", err) for every +// tile it doesn't get, on every frame it doesn't get it - so a zoom or a +// pan onto tiles that are still downloading writes a three-line block per +// missing tile, dozens at a time, for a condition that is this file's +// normal operation rather than a fault. quietPendingTiles drops exactly +// that block from the log. +// +// Suppressing it loses nothing: the widget only ever sees a cached tile or +// errTilePending, because a real download failure is handled here (backed +// off in claim, never passed on), so "tile fetch error" caused by +// errTilePending carries no information the log doesn't already have. A +// "tile fetch error" from any other cause - a corrupt tile failing to +// decode, say - still prints its cause and location. +const tileFetchError = "tile fetch error" + +var quietOnce sync.Once + +// quietPendingTiles installs the filter over the standard logger's current +// output, once per process. It is a process-wide side effect for want of +// anywhere narrower to put it: the log call is inside the widget, made +// from a draw this package doesn't drive. +func quietPendingTiles() { + quietOnce.Do(func() { + log.SetOutput(&tileLogFilter{out: log.Writer()}) + }) +} + +// tileLogFilter passes writes through except for the three lines +// fyne.LogError emits for a tile that hasn't downloaded yet. Each of those +// lines arrives as its own Write, hence the state: the header only counts +// as ours once the cause behind it turns out to be errTilePending. +type tileLogFilter struct { + out io.Writer + + mu sync.Mutex + + // stage is how far into a suppressed block the last line got: 0 none, + // 1 the "Fyne error" header, 2 its cause. + stage int +} + +func (t *tileLogFilter) Write(p []byte) (int, error) { + t.mu.Lock() + + stage := t.stage + t.stage = 0 + + switch { + case bytes.Contains(p, []byte(tileFetchError)): + t.stage = 1 + case stage == 1 && bytes.Contains(p, []byte(errTilePending.Error())): + t.stage = 2 + case stage == 2 && bytes.Contains(p, []byte(" At:")): + default: + t.mu.Unlock() + + return t.out.Write(p) + } + + t.mu.Unlock() + + return len(p), nil +} + +// tileFetcher is the map's tile source: an HTTP client whose transport +// never blocks (see errTilePending), a byte-bounded cache of the tiles it +// has, and the bookkeeping that lets the window show a spinner while +// anything is still on its way. +// +// It is a field on Window rather than package-level state so tests can +// point one at an httptest server instead of the real tile service. +type tileFetcher struct { + template string + base http.RoundTripper + cache *imaging.ByteCache[[]byte] + + mu sync.Mutex + inflight map[string]bool + failed map[string]time.Time + pending int + warming bool + onChange func(pending int) + + // now is time.Now, replaced in tests that need the retry backoff to + // pass without sleeping. + now func() time.Time +} + +// newTileFetcher returns a fetcher for tiles named by template (the +// zoom/x/y URL format string), downloading through base - nil for the +// default transport, which is what production uses. +func newTileFetcher(template string, base http.RoundTripper) *tileFetcher { + if base == nil { + base = http.DefaultTransport + } + + // The widget logs a fault for every tile this fetcher answers with + // errTilePending, which is most of them on a first view - see + // quietPendingTiles. + quietPendingTiles() + + return &tileFetcher{ + template: template, + base: base, + cache: imaging.NewByteCache(int64(tileBudget), func(b []byte) int64 { return int64(len(b)) }), + inflight: make(map[string]bool), + failed: make(map[string]time.Time), + now: time.Now, + } +} + +// client is the http.Client to hand the map widget: this fetcher itself, +// acting as the transport. +func (f *tileFetcher) client() *http.Client { + return &http.Client{Transport: f} +} + +// SetOnChange registers what to call after every background tile finishes, +// with the number still outstanding. Called from the fetching goroutine, +// so the callback is responsible for marshalling onto the UI goroutine. +func (f *tileFetcher) SetOnChange(fn func(pending int)) { + f.mu.Lock() + defer f.mu.Unlock() + + f.onChange = fn +} + +// Pending is how many tiles are being downloaded right now - what the +// window's loading indicator follows. +func (f *tileFetcher) Pending() int { + f.mu.Lock() + defer f.mu.Unlock() + + return f.pending +} + +// RoundTrip serves the map widget's tile requests from cache, and answers +// anything it doesn't have with errTilePending after starting the real +// download in the background. +func (f *tileFetcher) RoundTrip(req *http.Request) (*http.Response, error) { + url := req.URL.String() + + if b, ok := f.cache.Get(url); ok { + return tileResponse(req, b), nil + } + + if f.claim(url) { + go f.fetch(url) + } + + return nil, errTilePending +} + +// tileResponse wraps cached tile bytes as the 200 response the widget's +// image decoder expects. +func tileResponse(req *http.Request, b []byte) *http.Response { + return &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(bytes.NewReader(b)), + ContentLength: int64(len(b)), + Request: req, + } +} + +// claim reports whether the caller should download url, and counts it as +// outstanding if so. It says no for a tile that is already cached, already +// being downloaded, or that failed within the last tileRetryAfter. +func (f *tileFetcher) claim(url string) bool { + if f.cache.Contains(url) { + return false + } + + f.mu.Lock() + defer f.mu.Unlock() + + if f.inflight[url] { + return false + } + + if at, ok := f.failed[url]; ok && f.now().Sub(at) < tileRetryAfter { + return false + } + + f.inflight[url] = true + f.pending++ + + return true +} + +// release records a claimed tile's outcome - its bytes, or an error worth +// backing off from - and reports the outstanding count to onChange. +func (f *tileFetcher) release(url string, data []byte, err error) { + if err == nil { + f.cache.Add(url, data) + } + + f.mu.Lock() + + delete(f.inflight, url) + f.pending-- + + if err != nil { + f.failed[url] = f.now() + } else { + delete(f.failed, url) + } + + pending, onChange := f.pending, f.onChange + + // A prefetch reports its own completion once, when the whole block is + // in - one redraw for the batch instead of one per tile, and one + // goroutine touching the map instead of two racing to. + if f.warming { + onChange = nil + } + + f.mu.Unlock() + + if onChange != nil { + onChange(pending) + } +} + +// fetch downloads one claimed tile. Errors are deliberately not reported +// anywhere: a tile that doesn't arrive is a gap in the map, the widget has +// already logged its own failure for that frame, and the backoff in claim +// is what stops a dead network turning into a request storm. +func (f *tileFetcher) fetch(url string) { + data, err := f.get(url) + + f.release(url, data, err) +} + +func (f *tileFetcher) get(url string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", userAgent) + + client := &http.Client{Transport: f.base, Timeout: tileTimeout} + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tile server returned %s", res.Status) + } + + data, err := io.ReadAll(io.LimitReader(res.Body, maxTileBytes)) + if err != nil { + return nil, err + } + + return data, nil +} + +// Warm downloads the block of tiles around lat/lon at zoom and returns +// once they have all arrived (or failed), so the window can keep a +// "loading" indicator up for exactly as long as the first view of the map +// is still missing pieces. Tiles already cached, already in flight, or in +// backoff are skipped, which is what makes re-expanding the section +// instant. +func (f *tileFetcher) Warm(lat, lon float64, zoom int) { + f.mu.Lock() + f.warming = true + f.mu.Unlock() + + defer func() { + f.mu.Lock() + f.warming = false + f.mu.Unlock() + }() + + urls := f.neighborhood(lat, lon, zoom) + + sem := make(chan struct{}, tileWorkers) + var wg sync.WaitGroup + + for _, url := range urls { + if !f.claim(url) { + continue + } + + wg.Add(1) + sem <- struct{}{} + + go func(url string) { + defer wg.Done() + defer func() { <-sem }() + + f.fetch(url) + }(url) + } + + wg.Wait() +} + +// neighborhood is the tile URLs within prefetchRadius of the tile holding +// lat/lon at zoom, skipping the ones off the edge of the world. +func (f *tileFetcher) neighborhood(lat, lon float64, zoom int) []string { + centerX, centerY := tileXY(lat, lon, zoom) + n := 1 << zoom + + var urls []string + + for x := centerX - prefetchRadius; x <= centerX+prefetchRadius; x++ { + for y := centerY - prefetchRadius; y <= centerY+prefetchRadius; y++ { + if x < 0 || y < 0 || x >= n || y >= n { + continue + } + + urls = append(urls, fmt.Sprintf(f.template, zoom, x, y)) + } + } + + return urls +} + +// tileXY converts a position to the slippy-map tile holding it, the same +// arithmetic the map widget uses to decide which tiles to draw: +// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Mathematics +func tileXY(lat, lon float64, zoom int) (x, y int) { + n := float64(int(1) << zoom) + latRad := lat * math.Pi / 180 + + x = int(math.Floor((lon + 180) / 360 * n)) + y = int(math.Floor((1 - math.Log(math.Tan(latRad)+1/math.Cos(latRad))/math.Pi) / 2 * n)) + + return x, y +} diff --git a/internal/ui/exifwin/tiles_test.go b/internal/ui/exifwin/tiles_test.go new file mode 100644 index 0000000..98fa662 --- /dev/null +++ b/internal/ui/exifwin/tiles_test.go @@ -0,0 +1,500 @@ +package exifwin + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/png" + "log" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "fyne.io/fyne/v2" +) + +// tilePNG is a one-pixel PNG - the smallest thing the map widget's decoder +// accepts as a tile, and all these tests need it to be. +func tilePNG(t *testing.T) []byte { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255}) + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode tile: %v", err) + } + + return buf.Bytes() +} + +// tileServer is a stand-in tile service: it counts what was asked for, and +// can be made to hang until released, so a test can look at the world while +// a download is still in flight. +type tileServer struct { + *httptest.Server + + mu sync.Mutex + requests []string + + block chan struct{} + fail bool +} + +func newTileServer(t *testing.T) *tileServer { + t.Helper() + + body := tilePNG(t) + s := &tileServer{} + + s.Server = httptest.NewServer(http.HandlerFunc(func(wr http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.requests = append(s.requests, r.URL.Path) + block, fail := s.block, s.fail + s.mu.Unlock() + + if block != nil { + <-block + } + + if fail { + wr.WriteHeader(http.StatusInternalServerError) + return + } + + wr.Header().Set("Content-Type", "image/png") + _, _ = wr.Write(body) + })) + + t.Cleanup(s.Close) + + return s +} + +func (s *tileServer) count() int { + s.mu.Lock() + defer s.mu.Unlock() + + return len(s.requests) +} + +func (s *tileServer) paths() []string { + s.mu.Lock() + defer s.mu.Unlock() + + return append([]string(nil), s.requests...) +} + +// hold makes every later request hang until the returned func is called. +func (s *tileServer) hold() func() { + block := make(chan struct{}) + + s.mu.Lock() + s.block = block + s.mu.Unlock() + + var once sync.Once + + return func() { + once.Do(func() { + s.mu.Lock() + s.block = nil + s.mu.Unlock() + + close(block) + }) + } +} + +func (s *tileServer) breakIt() { + s.mu.Lock() + defer s.mu.Unlock() + + s.fail = true +} + +// fetcherFor returns a tileFetcher pointed at s instead of the real tile +// service - the swap every test in this package makes, and the reason the +// fetcher is a field on Window rather than package-level state. +func fetcherFor(s *tileServer) *tileFetcher { + return newTileFetcher(s.URL+"/%d/%d/%d.png", http.DefaultTransport) +} + +// waitForPending blocks until the fetcher has nothing outstanding. +func waitForPending(t *testing.T, f *tileFetcher) { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if f.Pending() == 0 { + return + } + + time.Sleep(5 * time.Millisecond) + } + + t.Fatalf("timed out with %d tiles still pending", f.Pending()) +} + +func TestRoundTrip_AnswersAMissImmediatelyAndFetchesInTheBackground(t *testing.T) { + s := newTileServer(t) + release := s.hold() + t.Cleanup(release) + + f := fetcherFor(s) + + req, err := http.NewRequest(http.MethodGet, s.URL+"/15/0/0.png", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + start := time.Now() + res, err := f.RoundTrip(req) + elapsed := time.Since(start) + + if res != nil { + t.Error("RoundTrip returned a response for an uncached tile, want none") + } + + if err != errTilePending { + t.Fatalf("RoundTrip() err = %v, want errTilePending", err) + } + + // The map widget calls this from inside its raster draw, on the UI + // goroutine: whatever the network is doing, it has to come straight + // back or the app freezes. + if elapsed > time.Second { + t.Errorf("RoundTrip blocked for %v on a hanging server, want an immediate answer", elapsed) + } + + release() + waitForPending(t, f) + + res, err = f.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip() after the download err = %v, want a cached response", err) + } + + if res.StatusCode != http.StatusOK { + t.Errorf("cached response status = %d, want 200", res.StatusCode) + } + + body := make([]byte, 8) + if _, err := res.Body.Read(body); err != nil { + t.Fatalf("read cached body: %v", err) + } + + if !bytes.Equal(body, tilePNG(t)[:8]) { + t.Error("cached response body is not the tile the server sent") + } +} + +func TestRoundTrip_DownloadsATileOnlyOnce(t *testing.T) { + s := newTileServer(t) + f := fetcherFor(s) + + req, err := http.NewRequest(http.MethodGet, s.URL+"/15/1/1.png", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + // Every repaint re-asks for the same tile while it is on its way; only + // the first of those may turn into a request. + for range 5 { + if _, err := f.RoundTrip(req); err != errTilePending { + t.Fatalf("RoundTrip() err = %v, want errTilePending", err) + } + } + + waitForPending(t, f) + + if _, err := f.RoundTrip(req); err != nil { + t.Fatalf("RoundTrip() after the download err = %v, want a cached response", err) + } + + if got := s.count(); got != 1 { + t.Errorf("server saw %d requests, want exactly 1", got) + } +} + +func TestWarm_DownloadsTheBlockAroundTheLocationOnce(t *testing.T) { + s := newTileServer(t) + f := fetcherFor(s) + + f.Warm(48.858222, 2.2945, mapZoom) + + want := (2*prefetchRadius + 1) * (2*prefetchRadius + 1) + if got := s.count(); got != want { + t.Fatalf("server saw %d requests, want the %dx%d block (%d)", got, 2*prefetchRadius+1, 2*prefetchRadius+1, want) + } + + for _, p := range s.paths() { + if !strings.HasPrefix(p, "/15/") { + t.Errorf("prefetched %q, want a tile at zoom %d", p, mapZoom) + } + } + + // Re-expanding the section must not re-download what is already cached. + f.Warm(48.858222, 2.2945, mapZoom) + + if got := s.count(); got != want { + t.Errorf("server saw %d requests after a second warm, want the cache to serve it (%d)", got, want) + } + + if f.Pending() != 0 { + t.Errorf("Pending() = %d after Warm returned, want 0", f.Pending()) + } +} + +func TestOnChange_ReportsABackgroundBatchButNotAPrefetch(t *testing.T) { + s := newTileServer(t) + f := fetcherFor(s) + + var mu sync.Mutex + var calls, last int + + f.SetOnChange(func(pending int) { + mu.Lock() + defer mu.Unlock() + + calls++ + last = pending + }) + + f.Warm(48.858222, 2.2945, mapZoom) + + mu.Lock() + during := calls + mu.Unlock() + + // Warm returning *is* the prefetch's completion report, so letting + // every tile in the block report as well would only queue redraws of a + // map the caller is about to redraw anyway. + if during != 0 { + t.Errorf("onChange fired %d times during a prefetch, want none", during) + } + + // A tile the user pans onto is a different matter: nobody is waiting + // on it, so its arrival is the only thing that can trigger the redraw. + req, err := http.NewRequest(http.MethodGet, s.URL+"/15/900/900.png", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + if _, err := f.RoundTrip(req); err != errTilePending { + t.Fatalf("RoundTrip() err = %v, want errTilePending", err) + } + + waitForPending(t, f) + + mu.Lock() + defer mu.Unlock() + + if calls != 1 { + t.Errorf("onChange fired %d times for one background tile, want 1", calls) + } + + if last != 0 { + t.Errorf("reported pending = %d, want 0 once the tile is in", last) + } +} + +func TestFetch_FailedTileIsRetriedOnlyAfterTheBackoff(t *testing.T) { + s := newTileServer(t) + s.breakIt() + + f := fetcherFor(s) + + now := time.Now() + f.now = func() time.Time { return now } + + url := s.URL + "/15/2/2.png" + + if !f.claim(url) { + t.Fatal("claim() = false for a tile nobody has asked for, want true") + } + + f.fetch(url) + + if f.claim(url) { + t.Error("claim() = true straight after a failure, want the backoff to hold it off") + } + + now = now.Add(tileRetryAfter + time.Second) + + if !f.claim(url) { + t.Error("claim() = false after the backoff elapsed, want a retry") + } +} + +func TestNeighborhood_ClampsToTheEdgeOfTheWorld(t *testing.T) { + f := newTileFetcher("%d/%d/%d", http.DefaultTransport) + + // Zoom 1 is a 2x2 world, so a radius-2 block around any tile in it is + // almost entirely off the map. + got := f.neighborhood(0, 0, 1) + + if len(got) != 4 { + t.Errorf("neighborhood at zoom 1 = %v (%d tiles), want the whole 2x2 world", got, len(got)) + } + + for _, url := range got { + if strings.Contains(url, "-") { + t.Errorf("neighborhood produced a negative tile index: %q", url) + } + } +} + +func TestTileXY(t *testing.T) { + cases := []struct { + name string + lat, lon float64 + zoom int + x, y int + }{ + {"whole world at zoom 0", 48.858222, 2.2945, 0, 0, 0}, + {"origin sits at the seam", 0, 0, 1, 1, 1}, + {"north-west corner", 85.05, -180, 1, 0, 0}, + {"the Eiffel Tower", 48.858222, 2.2945, 15, 16592, 11272}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + x, y := tileXY(c.lat, c.lon, c.zoom) + + if x != c.x || y != c.y { + t.Errorf("tileXY(%v, %v, %d) = (%d, %d), want (%d, %d)", c.lat, c.lon, c.zoom, x, y, c.x, c.y) + } + }) + } +} + +// logLines drives lines through a filter the way log.Logger does - one +// Write per line, timestamp prefix and all - and returns what got through. +func logLines(t *testing.T, lines ...string) string { + t.Helper() + + var out bytes.Buffer + f := &tileLogFilter{out: &out} + + for _, line := range lines { + p := []byte("2026/08/19 15:39:17 " + line + "\n") + + n, err := f.Write(p) + if err != nil { + t.Fatalf("Write(%q) returned %v", line, err) + } + + // A filtered write still has to claim the whole line, or the + // standard logger treats the difference as a short write. + if n != len(p) { + t.Errorf("Write(%q) = %d, want %d", line, n, len(p)) + } + } + + return out.String() +} + +func TestTileLogFilter(t *testing.T) { + const ( + header = "Fyne error: tile fetch error" + pending = ` Cause: Get "https://tile.openstreetmap.org/14/8650/5412.png": tile not downloaded yet` + at = " At: /Users/x/go/pkg/mod/fyne.io/x/fyne@v0/widget/map.go:389" + ) + + t.Run("drops a whole pending-tile block", func(t *testing.T) { + if got := logLines(t, header, pending, at); got != "" { + t.Errorf("filter passed %q, want nothing", got) + } + }) + + t.Run("drops every block of a burst", func(t *testing.T) { + if got := logLines(t, header, pending, at, header, pending, at); got != "" { + t.Errorf("filter passed %q, want nothing", got) + } + }) + + t.Run("passes everything else through", func(t *testing.T) { + got := logLines(t, "Fyne error: could not read file", " Cause: no such file", at) + + for _, want := range []string{"could not read file", "no such file", "map.go:389"} { + if !strings.Contains(got, want) { + t.Errorf("filter dropped %q from %q", want, got) + } + } + }) + + // Only errTilePending is this package's own noise. A "tile fetch error" + // from anything else is a real fault, and its cause and location have + // to survive. + t.Run("keeps a tile error with another cause", func(t *testing.T) { + got := logLines(t, header, " Cause: png: invalid format", at) + + if !strings.Contains(got, "png: invalid format") || !strings.Contains(got, "map.go:389") { + t.Errorf("filter passed %q, want the cause and location kept", got) + } + }) + + t.Run("does not swallow a line following a partial block", func(t *testing.T) { + got := logLines(t, header, pending, "Fyne error: something else") + + if !strings.Contains(got, "something else") { + t.Errorf("filter passed %q, want the unrelated error kept", got) + } + }) + + t.Run("is safe to write to from several goroutines", func(t *testing.T) { + f := &tileLogFilter{out: &bytes.Buffer{}} + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + + go func() { + defer wg.Done() + + for j := 0; j < 50; j++ { + _, _ = f.Write([]byte("Fyne error: tile fetch error\n")) + } + }() + } + + wg.Wait() + }) +} + +// The filter's whole design rests on the shape fyne.LogError writes, so +// pin it against the real thing rather than a hand-written imitation: a +// future Fyne that logs a pending tile differently has to be noticed here. +func TestTileLogFilter_SwallowsARealLogErrorCall(t *testing.T) { + var out bytes.Buffer + + restore := log.Writer() + log.SetOutput(&tileLogFilter{out: &out}) + t.Cleanup(func() { log.SetOutput(restore) }) + + fyne.LogError(tileFetchError, errTilePending) + + if got := out.String(); got != "" { + t.Errorf("a pending tile logged %q, want nothing", got) + } + + fyne.LogError("something real", errors.New("boom")) + + if got := out.String(); !strings.Contains(got, "boom") { + t.Errorf("a real error logged %q, want it kept", got) + } +} + +func TestNewTileFetcher_InstallsTheLogFilter(t *testing.T) { + newTileFetcher(osmTiles, nil) + + if _, ok := log.Writer().(*tileLogFilter); !ok { + t.Errorf("log.Writer() is %T, want the tile log filter installed", log.Writer()) + } +} diff --git a/internal/ui/help/manual.md b/internal/ui/help/manual.md index 7038e51..a84391a 100644 --- a/internal/ui/help/manual.md +++ b/internal/ui/help/manual.md @@ -194,10 +194,11 @@ loads even if you briefly return to the empty drop screen in between. Below that summary, a **"Show EXIF data"** link opens a separate window with the current image's Exif metadata — camera make and model, lens, exposure -time, aperture, ISO, focal length, and capture date, one line per tag that's -actually present in the file. `E` opens the same window directly, without -needing the info overlay open first. Location data (GPS coordinates) is -deliberately never read or shown. The window updates if you navigate to a +time, aperture, ISO, focal length, capture date, and — for a photo that was +geotagged — its **latitude** and **longitude** in decimal degrees, one line +per tag that's actually present in the file. `E` opens the same window +directly, without +needing the info overlay open first. The window updates if you navigate to a different image while it's still open, and — like the manual and About windows — `Esc` closes just that window, and pressing `E` again while it's already open brings it back to the front instead of opening a second copy. @@ -205,6 +206,23 @@ Files with no Exif data (most PNGs, GIFs, and WebPs, and any JPEG without a camera-written Exif segment) show a "no metadata found" message instead of an empty window. +Below the tag list, a photo that carries GPS coordinates gets a collapsible +**Location** section: expand it and a map centred on the spot the photo was +taken appears, with a pin marking it. It starts collapsed every time the +window opens, and it is only while it is expanded that PicFetch fetches map +tiles — so opening the EXIF window never puts your photo's location on the +network by itself. + +The first expand shows **"Loading map…"** while the tiles around the +location download; the map appears complete once they are in, and the +window stays responsive throughout. Panning or zooming beyond what was +downloaded fills in as the new tiles arrive, again without blocking +anything. The map takes whatever height the window leaves it, so drag the +EXIF window taller to get a bigger map. The map is drawn from +[OpenStreetMap](https://openstreetmap.org) tiles (© OpenStreetMap +contributors); the section is absent entirely for the great majority of +files, which carry no GPS tags at all. + --- ## 7. Browsing multiple images @@ -435,7 +453,8 @@ many of them actually went. - **`I`** — toggle the info overlay (file name, position, dimensions, file size, zoom level) - **`E`** — open the EXIF data window for the current image (camera - make/model, lens, exposure, aperture, ISO, focal length, capture date); + make/model, lens, exposure, aperture, ISO, focal length, capture date, + coordinates); also reachable via the **"Show EXIF data"** link in the info overlay - **`Cmd`/`Ctrl+C`** — copy the current image to the system clipboard, as image data you can paste into another app (not a file). In the grid @@ -618,8 +637,8 @@ Things PicFetch deliberately does not do (yet): (**File -> Set as Wallpaper**), all described in "Menu" below - No support for RAW or PDF - No playback controls (pause, step, restart) for animated GIFs -- No EXIF GPS/location display — deliberately left out of the EXIF data - window (see "Info overlay" above) for privacy +- No offline maps: the EXIF window's location view needs a working internet + connection, since it draws live OpenStreetMap tiles --- @@ -653,8 +672,9 @@ Things PicFetch deliberately does not do (yet): - **Info overlay** — `I` toggles a card with the file name, position, dimensions, file size, and zoom level - **EXIF data window** — `E`, or the info overlay's "Show EXIF data" link, - opens camera make/model, lens, exposure, aperture, ISO, focal length, and - capture date for the current image (no GPS/location) + opens camera make/model, lens, exposure, aperture, ISO, focal length, + capture date and coordinates for the current image, plus a collapsible map + of where it was taken when the photo carries GPS tags - **Picture-frame mode** — `P` toggles a full-screen slideshow with a crossfade between images; `↑`/`↓` tune the (default 10s) auto-advance interval while it's on; `Shift+P` toggles shuffle order (`[shuffle]` in diff --git a/internal/ui/help/manual_de.md b/internal/ui/help/manual_de.md index 7822ae3..1b7c6f2 100644 --- a/internal/ui/help/manual_de.md +++ b/internal/ui/help/manual_de.md @@ -219,10 +219,11 @@ leeren Ablegebildschirm zurückkehren. Unter dieser Übersicht öffnet ein Link **„EXIF-Daten anzeigen“** ein separates Fenster mit den Exif-Metadaten des aktuellen Bildes — Kamerahersteller und -modell, Objektiv, Belichtungszeit, Blende, ISO, -Brennweite und Aufnahmedatum, eine Zeile pro Tag, das tatsächlich in der +Brennweite und Aufnahmedatum sowie — bei einem Foto mit GPS-Tags — +**Breitengrad** und **Längengrad** in Dezimalgrad, eine Zeile pro Tag, das +tatsächlich in der Datei vorhanden ist. `E` öffnet dasselbe Fenster direkt, ohne dass das -Info-Overlay vorher geöffnet sein muss. GPS-Standortdaten werden absichtlich -nie gelesen oder angezeigt. Das Fenster aktualisiert sich, wenn Sie bei +Info-Overlay vorher geöffnet sein muss. Das Fenster aktualisiert sich, wenn Sie bei geöffnetem Fenster zu einem anderen Bild wechseln, und — wie beim Handbuch- und Info-Fenster — schließt `Esc` nur dieses Fenster, und ein erneuter Druck auf `E`, während es bereits offen ist, holt es nach vorne, statt eine zweite @@ -230,6 +231,24 @@ Kopie zu öffnen. Dateien ohne Exif-Daten (die meisten PNGs, GIFs und WebPs sowie jedes JPEG ohne von einer Kamera geschriebenes Exif-Segment) zeigen stattdessen die Meldung „keine Metadaten gefunden“. +Unterhalb der Tag-Liste erhält ein Foto mit GPS-Koordinaten einen +ausklappbaren Bereich **„Ort“**: aufgeklappt zeigt er eine Karte, die auf +die Aufnahmestelle zentriert ist und sie mit einer Nadel markiert. Er ist +bei jedem Öffnen des Fensters zunächst eingeklappt, und erst im +aufgeklappten Zustand lädt PicFetch Kartenkacheln — das Öffnen des +EXIF-Fensters allein schickt den Aufnahmeort also nie ins Netz. + +Beim ersten Aufklappen erscheint **„Karte wird geladen…“**, während die +Kacheln rund um den Aufnahmeort geladen werden; die Karte wird vollständig +angezeigt, sobald sie da sind, und das Fenster bleibt die ganze Zeit +bedienbar. Verschieben und Zoomen über den geladenen Bereich hinaus füllt +sich nach und nach auf, ebenfalls ohne zu blockieren. Die Karte nimmt die +Höhe ein, die das Fenster ihr lässt — ziehen Sie das EXIF-Fenster größer, +wird auch die Karte größer. Die Karte besteht aus +Kacheln von [OpenStreetMap](https://openstreetmap.org) +(© OpenStreetMap-Mitwirkende); für die große Mehrheit der Dateien, die +überhaupt keine GPS-Tags tragen, fehlt der Bereich vollständig. + --- ## 7. Mehrere Bilder durchblättern @@ -494,7 +513,7 @@ tatsächlich verschoben wurden. Dateigröße, Zoomstufe) - **`E`** — das EXIF-Datenfenster für das aktuelle Bild öffnen (Kamerahersteller/-modell, Objektiv, Belichtung, Blende, ISO, Brennweite, - Aufnahmedatum); auch über den Link **„EXIF-Daten anzeigen“** im + Aufnahmedatum, Koordinaten); auch über den Link **„EXIF-Daten anzeigen“** im Info-Overlay erreichbar - **`Cmd`/`Strg+C`** — das aktuelle Bild in die Systemzwischenablage kopieren, als Bilddaten, die Sie in eine andere App einfügen können (keine @@ -709,8 +728,8 @@ Dinge, die PicFetch absichtlich (noch) nicht tut: - Keine Unterstützung für RAW oder PDF - Keine Wiedergabesteuerung (Pause, Einzelschritt, Neustart) für animierte GIFs -- Keine EXIF-GPS-/Standortanzeige — absichtlich aus dem EXIF-Datenfenster - ausgelassen (siehe „Info-Overlay“ oben) zum Schutz der Privatsphäre +- Keine Offline-Karten: die Ortsansicht im EXIF-Fenster benötigt eine + Internetverbindung, da sie OpenStreetMap-Kacheln live lädt --- @@ -749,8 +768,9 @@ Dinge, die PicFetch absichtlich (noch) nicht tut: Abmessungen, Dateigröße und Zoomstufe ein/aus - **EXIF-Datenfenster** — `E`, oder der Link „EXIF-Daten anzeigen“ im Info-Overlay, öffnet Kamerahersteller/-modell, Objektiv, Belichtung, - Blende, ISO, Brennweite und Aufnahmedatum für das aktuelle Bild (kein - GPS/Standort) + Blende, ISO, Brennweite, Aufnahmedatum und Koordinaten für das aktuelle + Bild sowie eine ausklappbare Karte des Aufnahmeorts, wenn das Foto + GPS-Tags trägt - **Diaschau-Modus** — `P` schaltet eine Vollbild-Diaschau mit Überblendung zwischen den Bildern ein/aus; `↑`/`↓` stellen das (standardmäßig 10 s) Auto-Weiterschalt-Intervall ein, solange sie aktiv ist; `Shift+P` schaltet diff --git a/internal/uitest/uitest.go b/internal/uitest/uitest.go index 3bc3425..ceec91c 100644 --- a/internal/uitest/uitest.go +++ b/internal/uitest/uitest.go @@ -31,6 +31,7 @@ import ( "image/gif" "image/jpeg" "image/png" + "math" "os" "path/filepath" "testing" @@ -197,6 +198,121 @@ func CaptureDateJPEG(t *testing.T, w, h int, raw string) []byte { return out } +// GPSJPEG builds a minimal encoded JPEG carrying an Exif GPS sub-IFD (the +// 0x8825 pointer in IFD0, then the latitude/longitude reference and +// degrees/minutes/seconds tags) for the given signed decimal degrees - +// enough for imaging.ReadMetadata to read a position back, and so for the +// EXIF window's map section to have somewhere to point. +func GPSJPEG(t *testing.T, w, h int, lat, lon float64) []byte { + t.Helper() + + data := EncodeJPEG(t, w, h, color.White) + + const ( + headerSize = 8 // "II" + magic(2) + IFD0 offset(4) + ifd0Size = 2 + 1*12 + 4 + gpsEntryCnt = 4 + gpsSize = 2 + gpsEntryCnt*12 + 4 + ) + gpsOffset := uint32(headerSize + ifd0Size) + valueOffset := gpsOffset + gpsSize + + le := binary.LittleEndian + u16 := func(v uint16) []byte { b := make([]byte, 2); le.PutUint16(b, v); return b } + u32 := func(v uint32) []byte { b := make([]byte, 4); le.PutUint32(b, v); return b } + + // Exif carries the hemisphere in its own tag, so the coordinate itself + // is written unsigned. + ref := func(v float64, positive, negative string) []byte { + b := make([]byte, 4) + if v < 0 { + copy(b, negative) + } else { + copy(b, positive) + } + return b + } + + buf := new(bytes.Buffer) + buf.WriteString("II") + buf.Write(u16(0x002A)) + buf.Write(u32(headerSize)) + + buf.Write(u16(1)) + buf.Write(u16(0x8825)) // GPSIFDPointer + buf.Write(u16(4)) // LONG + buf.Write(u32(1)) + buf.Write(u32(gpsOffset)) + buf.Write(u32(0)) // next IFD offset + + buf.Write(u16(gpsEntryCnt)) + + buf.Write(u16(0x0001)) // GPSLatitudeRef + buf.Write(u16(2)) // ASCII + buf.Write(u32(2)) + buf.Write(ref(lat, "N", "S")) + + buf.Write(u16(0x0002)) // GPSLatitude + buf.Write(u16(5)) // RATIONAL + buf.Write(u32(3)) + buf.Write(u32(valueOffset)) + + buf.Write(u16(0x0003)) // GPSLongitudeRef + buf.Write(u16(2)) + buf.Write(u32(2)) + buf.Write(ref(lon, "E", "W")) + + buf.Write(u16(0x0004)) // GPSLongitude + buf.Write(u16(5)) + buf.Write(u32(3)) + buf.Write(u32(valueOffset + 24)) // three rationals past the latitude + + buf.Write(u32(0)) // next IFD offset + + buf.Write(dmsRationals(lat)) + buf.Write(dmsRationals(lon)) + + seg := append([]byte("Exif\x00\x00"), buf.Bytes()...) + length := len(seg) + 2 + app1 := append([]byte{0xFF, 0xE1, byte(length >> 8), byte(length)}, seg...) + + out := append([]byte{}, data[:2]...) + out = append(out, app1...) + out = append(out, data[2:]...) + + return out +} + +// dmsRationals encodes the magnitude of a decimal-degree coordinate as the +// three little-endian unsigned rationals Exif stores it in: whole degrees, +// whole minutes, and seconds to four decimal places (a ten-thousandth of a +// second is well under a millimeter, so nothing meaningful is lost). +func dmsRationals(deg float64) []byte { + deg = math.Abs(deg) + + d := math.Floor(deg) + m := math.Floor((deg - d) * 60) + s := ((deg-d)*60 - m) * 60 + + b := make([]byte, 0, 24) + b = binary.LittleEndian.AppendUint32(b, uint32(d)) + b = binary.LittleEndian.AppendUint32(b, 1) + b = binary.LittleEndian.AppendUint32(b, uint32(m)) + b = binary.LittleEndian.AppendUint32(b, 1) + b = binary.LittleEndian.AppendUint32(b, uint32(math.Round(s*10000))) + b = binary.LittleEndian.AppendUint32(b, 10000) + + return b +} + +// TempGPSJPEGURI writes GPSJPEG's output to a temp file and returns its +// URI, mirroring TempJPEGURI. +func TempGPSJPEGURI(t *testing.T, name string, w, h int, lat, lon float64) fyne.URI { + t.Helper() + + return storage.NewFileURI(WriteTempFile(t, name, GPSJPEG(t, w, h, lat, lon))) +} + // TruncatedPNGHeader builds a PNG file containing only the 8-byte signature // and a single, correctly-checksummed IHDR chunk declaring width x height - // no IDAT/IEND, so it's useless for a full decode but perfectly readable by diff --git a/todos.md b/todos.md index 56fbf9a..8057ef2 100644 --- a/todos.md +++ b/todos.md @@ -11,6 +11,12 @@ - Reopen and remove saved collections from a startup-populated menu. - Move removed collection folders to the OS recycle bin. +- exif window location map + - Read the Exif GPS sub-IFD and show a collapsible OpenStreetMap view, + pinned at and centered on where the photo was taken. + - Collapsed on every open, and hidden entirely for a photo with no GPS + tags, so no map tiles are fetched unasked. + ## TODO - favorites disk thumbnail cache diff --git a/translations/de.json b/translations/de.json index 0282d78..6856a6c 100644 --- a/translations/de.json +++ b/translations/de.json @@ -53,6 +53,11 @@ "ISO": "ISO", "Focal length": "Brennweite", "Date taken": "Aufnahmedatum", + "Latitude": "Breitengrad", + "Longitude": "Längengrad", + "Location": "Ort", + "Loading map…": "Karte wird geladen…", + "Photo location": "Aufnahmeort", "File": "Datei", "Open Files…": "Dateien öffnen…", "Save Changes": "Änderungen speichern", diff --git a/translations/en.json b/translations/en.json index d6d09b0..efad3f3 100644 --- a/translations/en.json +++ b/translations/en.json @@ -53,6 +53,11 @@ "ISO": "ISO", "Focal length": "Focal length", "Date taken": "Date taken", + "Latitude": "Latitude", + "Longitude": "Longitude", + "Location": "Location", + "Loading map…": "Loading map…", + "Photo location": "Photo location", "File": "File", "Open Files…": "Open Files…", "Save Changes": "Save Changes", From af5883eb6abef1a4d0ee29139a7cb8e57cd1da0b Mon Sep 17 00:00:00 2001 From: frathe Date: Wed, 19 Aug 2026 16:11:01 +0200 Subject: [PATCH 6/6] Added makefile tooling for releasing the app --- Makefile | 80 +++++++++++++++++++++++++++-------------- README.md | 26 ++++++++++++++ scripts/bump_version.sh | 58 ++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 27 deletions(-) create mode 100755 scripts/bump_version.sh diff --git a/Makefile b/Makefile index 90d4281..7230c4b 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,9 @@ BIN_DIR := bin WIN_ARCHES := amd64 arm64 LINUX_ARCHES := amd64 arm64 -.PHONY: all build build-linux-all run fmt vet test 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 help +RELEASE_BRANCH := main + +.PHONY: all build build-linux-all run fmt vet test 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 help all: build @@ -26,6 +28,15 @@ vet: ## Run go vet test: ## Run tests go test ./... +verify: ## Run the same checks CI does (gofmt, vet, build, race tests) + @unformatted=$$(gofmt -l .); \ + if [ -n "$$unformatted" ]; then \ + echo "These files need gofmt (run 'make fmt'):"; echo "$$unformatted"; exit 1; \ + fi + go vet ./... + go build ./... + go test -race ./... + golden: ## Regenerate the e2e golden-master screenshots via Docker (linux/amd64, matching CI exactly - needs Docker) @# Fyne's software rasterizer renders slightly different anti-aliased @# pixels depending on CPU architecture - fyne.io/fyne/v2's own test @@ -110,36 +121,51 @@ install-linux-tools: ## Install apt dev headers needed to build natively on Linu sudo apt-get update sudo apt-get install -y gcc libgl1-mesa-dev xorg-dev libwayland-dev libxkbcommon-dev -bump-version: ## Bump FyneApp.toml version (PART=major|minor|patch, default patch) and tag HEAD as vX.Y.Z - @version=$$(sed -nE 's/^Version = "(.*)"/\1/p' FyneApp.toml); \ - build=$$(sed -nE 's/^Build = ([0-9]+)/\1/p' FyneApp.toml); \ +bump-version: ## Bump FyneApp.toml's Version/Build only (PART=major|minor|patch, default patch); no commit, no tag + @scripts/bump_version.sh $${PART:-patch} >/dev/null + @echo "FyneApp.toml was updated but NOT committed. Use 'make release' for the full flow." + +release: ## Full release: verify, bump version, commit, tag, push (PART=major|minor|patch, default patch; YES=1 skips the prompt) + @# The tag must contain its own version bump, so this target commits the + @# FyneApp.toml edit before tagging - the one place the Makefile writes to + @# git history. Publishing happens in .github/workflows/release.yml, which + @# is triggered by the tag push and re-runs CI as a gate, so a red run + @# leaves the tag orphaned rather than shipping a broken build. + @set -e; \ part=$${PART:-patch}; \ - major=$$(echo $$version | cut -d. -f1); \ - minor=$$(echo $$version | cut -d. -f2); \ - patch=$$(echo $$version | cut -d. -f3); \ - case $$part in \ - major) major=$$((major+1)); minor=0; patch=0 ;; \ - minor) minor=$$((minor+1)); patch=0 ;; \ - patch) patch=$$((patch+1)) ;; \ - *) echo "Unknown PART=$$part (want major|minor|patch)"; exit 1 ;; \ - esac; \ - new_version=$$major.$$minor.$$patch; \ - new_build=$$((build+1)); \ + branch=$$(git rev-parse --abbrev-ref HEAD); \ + if [ "$$branch" != "$(RELEASE_BRANCH)" ]; then \ + echo "On branch '$$branch', expected '$(RELEASE_BRANCH)' (override with RELEASE_BRANCH=)"; exit 1; \ + fi; \ + if [ -n "$$(git status --porcelain)" ]; then \ + echo "Working tree is dirty - commit or stash first:"; git status --short; exit 1; \ + fi; \ + git fetch --quiet origin "$(RELEASE_BRANCH)"; \ + if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse origin/$(RELEASE_BRANCH))" ]; then \ + echo "HEAD and origin/$(RELEASE_BRANCH) have diverged - pull/push first"; exit 1; \ + fi; \ + new_version=$$(scripts/bump_version.sh $$part --dry-run); \ tag="v$$new_version"; \ - if git rev-parse "$$tag" >/dev/null 2>&1; then \ + if git rev-parse -q --verify "refs/tags/$$tag" >/dev/null || \ + [ -n "$$(git ls-remote --tags origin "refs/tags/$$tag")" ]; then \ echo "Tag $$tag already exists"; exit 1; \ fi; \ - sed -i.bak -E "s/^Version = \".*\"/Version = \"$$new_version\"/" FyneApp.toml; \ - sed -i.bak -E "s/^Build = [0-9]+/Build = $$new_build/" FyneApp.toml; \ - rm -f FyneApp.toml.bak; \ - git tag -a "$$tag" -m "Release $$tag" HEAD; \ - echo "Bumped version $$version -> $$new_version (build $$build -> $$new_build)"; \ - echo "Tagged current HEAD ($$(git rev-parse --short HEAD)) as $$tag"; \ - echo; \ - echo "FyneApp.toml was updated but NOT committed (this Makefile never commits)."; \ - echo "Note: $$tag points at HEAD as it was BEFORE this edit, so it does not yet"; \ - echo "include the FyneApp.toml change. Suggested commit message:"; \ - echo " Bump version to $$new_version" + if [ -z "$$YES" ]; then \ + printf "Release %s from %s (%s)? [y/N] " "$$tag" "$(RELEASE_BRANCH)" "$$(git rev-parse --short HEAD)"; \ + read answer; \ + case $$answer in y|Y|yes|YES) ;; *) echo "Aborted."; exit 1 ;; esac; \ + fi; \ + $(MAKE) verify; \ + scripts/bump_version.sh $$part >/dev/null; \ + git add FyneApp.toml; \ + git commit -m "Release $$tag"; \ + git tag -a "$$tag" -m "Release $$tag"; \ + git push origin "$(RELEASE_BRANCH)"; \ + git push origin "$$tag"; \ + echo "Pushed $$tag - .github/workflows/release.yml now builds and publishes the artifacts."; \ + if command -v gh >/dev/null 2>&1; then \ + echo "Watch it with: gh run watch --exit-status"; \ + fi help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*##"}; {printf " %-16s %s\n", $$1, $$2}' diff --git a/README.md b/README.md index 9a78d53..0851cd1 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ packaged build. | `make fmt` | `gofmt` all Go source files | | `make vet` | `go vet ./...` | | `make test` | `go test ./...` | +| `make verify` | The same gate CI runs: `gofmt` check, `go vet`, `go build`, `go test -race` | | `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` | @@ -185,6 +186,31 @@ packaged build. > (`gh`) to be installed and authenticated (`gh auth login`), and it must be run > from a checkout with a GitHub `origin` remote. +### Releasing + +```sh +make release # patch bump, e.g. 0.1.7 -> 0.1.8 +make release PART=minor # or PART=major +``` + +`make release` is the whole flow. It refuses to start unless you're on `main` +(override with `RELEASE_BRANCH=`), the working tree is clean, and `HEAD` +matches `origin/main`; it also refuses if the tag it would create already +exists locally or on the remote. After a confirmation prompt (`YES=1` skips +it) it runs `make verify`, bumps `Version`/`Build` in +[FyneApp.toml](FyneApp.toml), commits that as `Release vX.Y.Z`, tags the +commit, and pushes the branch and the tag. + +Pushing the tag is what publishes: [`.github/workflows/release.yml`](.github/workflows/release.yml) +re-runs the full CI suite as a gate, then packages macOS, Windows, and Linux +artifacts and attaches them to a GitHub release. Nothing is published if that +run goes red — the tag just sits there, and you can delete it and try again. +The download links on the [website](https://frathe.github.io/picfetch/) point +at `releases/latest`, so they need no edit per release. + +`make bump-version` does only the FyneApp.toml edit (no commit, no tag, no +push) for the rare case where you want the version bumped by itself. + ## Testing `make test` (or `go test ./...`) runs everything: unit tests colocated with diff --git a/scripts/bump_version.sh b/scripts/bump_version.sh new file mode 100755 index 0000000..4815940 --- /dev/null +++ b/scripts/bump_version.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Bump the Version (semver) and Build (monotonic counter) fields in +# FyneApp.toml and print the new version to stdout. +# +# Usage: scripts/bump_version.sh [major|minor|patch] [--dry-run] +# +# --dry-run prints the version the bump would produce and touches nothing, +# so callers can check whether the resulting tag already exists before +# writing to the working tree. + +set -eu + +part=${1:-patch} +dry_run=${2:-} +toml=FyneApp.toml + +if [ ! -f "$toml" ]; then + echo "$toml not found (run from the repository root)" >&2 + exit 1 +fi + +version=$(sed -nE 's/^Version = "(.*)"/\1/p' "$toml") +build=$(sed -nE 's/^Build = ([0-9]+)/\1/p' "$toml") + +if [ -z "$version" ] || [ -z "$build" ]; then + echo "could not read Version/Build from $toml" >&2 + exit 1 +fi + +major=$(echo "$version" | cut -d. -f1) +minor=$(echo "$version" | cut -d. -f2) +patch=$(echo "$version" | cut -d. -f3) + +case $part in +major) major=$((major + 1)); minor=0; patch=0 ;; +minor) minor=$((minor + 1)); patch=0 ;; +patch) patch=$((patch + 1)) ;; +*) + echo "Unknown part '$part' (want major|minor|patch)" >&2 + exit 1 + ;; +esac + +new_version=$major.$minor.$patch + +if [ "$dry_run" = "--dry-run" ]; then + echo "$new_version" + exit 0 +fi + +new_build=$((build + 1)) + +sed -i.bak -E "s/^Version = \".*\"/Version = \"$new_version\"/" "$toml" +sed -i.bak -E "s/^Build = [0-9]+/Build = $new_build/" "$toml" +rm -f "$toml.bak" + +echo "Bumped version $version -> $new_version (build $build -> $new_build)" >&2 +echo "$new_version"