From 99deab3c1c5a0f9ee95d41969b506ccd5e86fc87 Mon Sep 17 00:00:00 2001
From: frathe
Date: Tue, 18 Aug 2026 20:20:08 +0200
Subject: [PATCH] -updated todo list. -add plan for favorites menu. -Add
persistent named favorites without coupling storage to the viewer -Add
persistent named favorites through an isolated storage and UI feature
-Document named favorites and their keyboard shortcuts
---
.claude/memory/go-expert.md | 2 +
ARCHITECTURE.md | 15 +-
FyneApp.toml | 2 +-
docs/index.html | 7 +
go.mod | 2 +-
go.sum | 3 +-
internal/favstore/favstore.go | 170 +++++++++++
internal/favstore/favstore_test.go | 259 +++++++++++++++++
internal/trash/trash.go | 17 +-
internal/trash/trash_test.go | 114 ++++++--
internal/ui/build.go | 19 +-
internal/ui/favorites/favorites.go | 300 +++++++++++++++++++
internal/ui/favorites/favorites_test.go | 368 ++++++++++++++++++++++++
internal/ui/help/manual.md | 20 ++
internal/ui/help/manual_de.md | 24 ++
internal/ui/menu.go | 5 +-
internal/ui/menu_test.go | 81 +++++-
internal/ui/run.go | 2 +
internal/ui/save.go | 1 +
internal/ui/viewer.go | 14 +-
todos.md | 61 ++++
translations/de.json | 24 +-
translations/en.json | 24 +-
23 files changed, 1485 insertions(+), 49 deletions(-)
create mode 100644 internal/favstore/favstore.go
create mode 100644 internal/favstore/favstore_test.go
create mode 100644 internal/ui/favorites/favorites.go
create mode 100644 internal/ui/favorites/favorites_test.go
diff --git a/.claude/memory/go-expert.md b/.claude/memory/go-expert.md
index 77eba06..979c65e 100644
--- a/.claude/memory/go-expert.md
+++ b/.claude/memory/go-expert.md
@@ -4,6 +4,8 @@ Persistent notes for the go-expert agent on this repo. Local-only (gitignored)
## 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`.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 4342fb0..4fd4833 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -30,10 +30,10 @@ 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`/`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 |
+| `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`, `CurrentIndex`, `Generation`, `Unfocus`, `Modifiers`, `Advance`). `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 |
+| `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 |
| `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) |
@@ -70,6 +70,7 @@ one. Sizes below are that interface, which is the honest measure of how coupled
| `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 |
| `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`) |
| `widgets/` | Viewer-free UI mechanics shared across the packages above: `TappableArea` (the drop zone's tap target), `Singleton` (the raise-or-build lifecycle behind every secondary window, plus the opt-in geometry memory `Remember`/`Geometry`/`StopTracking` turn on — a `winpos.Tracker` and its poller for the position, a `SizeTracker` wrapped around the content for the size, both outliving the window itself so the app can save them at shutdown), `NewSizeTracker` (the size half of that, also used by `internal/ui` for the main window), and the app's hardcoded style values (`CardRadius`, the dropzone/toast/scrim colors, `NewFocusRing`) | Nothing from the app — a leaf apart from `internal/winpos`, which the geometry memory reads positions through |
| `assets/` | `WelcomeWebP`/`PlaceholderWebP`, the two images the UI embeds. They live beside the code that draws them because `//go:embed` cannot reach a parent directory; the root `assets/` keeps the icon and README artwork, which the build consumes rather than the program | Nothing — it is a leaf |
@@ -122,6 +123,14 @@ display scale changes. Zero dependency on `viewer`.
Extracted from `library.go` + the root `orientation.go`/`exif.go`/`gif.go` on 2026-08-13.
+### `internal/favstore`
+
+Persists named file lists under the user's config directory. It takes the base
+directory explicitly for every operation, uses atomic temp-file-and-rename
+writes, sorts favorite names case-insensitively, reconstructs JSON index keys
+numerically, and delegates removal to `internal/trash`. It has no UI or mutable
+package state; `DefaultDir` is the production-path helper.
+
### `internal/session`
Persists and restores the set of files that were open when the window last closed, via Fyne's app-scoped cache. Zero
@@ -446,6 +455,8 @@ Use `errcheck` command to check for unhadled errors.
`openfiles.go` and `export.go` (the `*viewer` glue for each)
- "How is the last session saved/restored?" → `internal/session/session.go` (persistence) + `session.go`
(`restoreSession` glue)
+- "How do Favorites work?" → `internal/favstore` (named-list persistence) + `internal/ui/favorites` (menu and dialogs) +
+ `internal/ui/run.go` (production directory initialization) + `viewer.go`'s `OpenFiles` (existing drop/scan path)
- "Where is the File menu / Settings window?" → `internal/ui/menu.go`'s `buildMainMenu` (Open Files…/Save
Changes/Export as…/Close
Files/Settings…, composed with `help.Menu()`) + `internal/ui/settingswin` (the Settings window itself) + `viewer.go`'s
diff --git a/FyneApp.toml b/FyneApp.toml
index d996861..95e8e91 100644
--- a/FyneApp.toml
+++ b/FyneApp.toml
@@ -2,7 +2,7 @@
Name = "PicFetch"
ID = "io.github.frathe.picfetch"
Version = "0.1.7"
-Build = 299
+Build = 310
[Migrations]
fyneDo = true
diff --git a/docs/index.html b/docs/index.html
index 72c0b43..8938920 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -351,6 +351,13 @@ Folders and merging
trees. M makes further drops add to the set instead of
replacing it.
+
+ Named favorites
+ Save the current file list as a collection and reopen it from the
+ Favorites menu after a restart. The first ten are one shortcut away with
+ Cmd/Ctrl+1–9 and
+ Cmd/Ctrl+0.
+
Download
diff --git a/go.mod b/go.mod
index 7af827d..57012be 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
require (
fyne.io/systray v1.12.2 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
+ github.com/FyshOS/fancyfs v0.0.1 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -32,7 +33,6 @@ require (
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
- github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
diff --git a/go.sum b/go.sum
index fc92012..cd684c9 100644
--- a/go.sum
+++ b/go.sum
@@ -4,11 +4,12 @@ fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
+github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
diff --git a/internal/favstore/favstore.go b/internal/favstore/favstore.go
new file mode 100644
index 0000000..8b2b49a
--- /dev/null
+++ b/internal/favstore/favstore.go
@@ -0,0 +1,170 @@
+// Package favstore persists named lists of image files.
+package favstore
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/storage"
+
+ "github.com/frathe/picfetch/internal/trash"
+)
+
+const fileListName = "file-list.json"
+
+// DefaultDir returns the directory used for favorites in production.
+func DefaultDir() string {
+ base, err := os.UserConfigDir()
+ if err != nil || base == "" {
+ base = os.TempDir()
+ }
+ return filepath.Join(base, "picfetch", "favorites")
+}
+
+// ValidName reports whether name is safe to use as one directory component.
+func ValidName(name string) bool {
+ return name != "" &&
+ name != "." &&
+ name != ".." &&
+ !strings.ContainsAny(name, `/\:*?"<>|`)
+}
+
+// Exists reports whether a favorite with name exists.
+func Exists(dir, name string) bool {
+ if !ValidName(name) {
+ return false
+ }
+ info, err := os.Stat(filepath.Join(dir, name, fileListName))
+ return err == nil && !info.IsDir()
+}
+
+// List returns favorite names sorted case-insensitively.
+func List(dir string) ([]string, error) {
+ entries, err := os.ReadDir(dir)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ names := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ if !entry.IsDir() || !ValidName(entry.Name()) {
+ continue
+ }
+ info, err := os.Stat(filepath.Join(dir, entry.Name(), fileListName))
+ if err == nil && !info.IsDir() {
+ names = append(names, entry.Name())
+ continue
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ }
+
+ sort.Slice(names, func(i, j int) bool {
+ left, right := strings.ToLower(names[i]), strings.ToLower(names[j])
+ if left == right {
+ return names[i] < names[j]
+ }
+ return left < right
+ })
+ return names, nil
+}
+
+// Save atomically writes files as the favorite named name.
+func Save(dir, name string, files []fyne.URI) error {
+ if !ValidName(name) {
+ return fmt.Errorf("invalid favorite name %q", name)
+ }
+
+ list := make(map[string]string, len(files))
+ for i, file := range files {
+ if file == nil {
+ return fmt.Errorf("favorite file %d is nil", i)
+ }
+ list[strconv.Itoa(i)] = file.Path()
+ }
+
+ favoriteDir := filepath.Join(dir, name)
+ if err := os.MkdirAll(favoriteDir, 0o755); err != nil {
+ return err
+ }
+
+ tmp, err := os.CreateTemp(favoriteDir, ".file-list-*.json")
+ if err != nil {
+ return err
+ }
+ tmpPath := tmp.Name()
+ defer func() { _ = os.Remove(tmpPath) }()
+
+ if err := tmp.Chmod(0o644); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if err := json.NewEncoder(tmp).Encode(list); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, filepath.Join(favoriteDir, fileListName))
+}
+
+// Load returns the files stored in the favorite named name.
+func Load(dir, name string) ([]fyne.URI, error) {
+ if !ValidName(name) {
+ return nil, fmt.Errorf("invalid favorite name %q", name)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, name, fileListName))
+ if err != nil {
+ return nil, err
+ }
+
+ var list map[string]string
+ if err := json.Unmarshal(data, &list); err != nil {
+ return nil, err
+ }
+
+ type indexedPath struct {
+ index int
+ path string
+ }
+ paths := make([]indexedPath, 0, len(list))
+ for key, path := range list {
+ index, err := strconv.Atoi(key)
+ if err != nil || index < 0 {
+ return nil, fmt.Errorf("invalid file index %q", key)
+ }
+ paths = append(paths, indexedPath{index: index, path: path})
+ }
+ sort.Slice(paths, func(i, j int) bool { return paths[i].index < paths[j].index })
+
+ files := make([]fyne.URI, len(paths))
+ for i, item := range paths {
+ files[i] = storage.NewFileURI(item.path)
+ }
+ return files, nil
+}
+
+// Remove moves the favorite named name to the operating system's trash.
+func Remove(dir, name string) error {
+ if !ValidName(name) {
+ return fmt.Errorf("invalid favorite name %q", name)
+ }
+ return trash.Move(filepath.Join(dir, name))
+}
diff --git a/internal/favstore/favstore_test.go b/internal/favstore/favstore_test.go
new file mode 100644
index 0000000..f1ac9f2
--- /dev/null
+++ b/internal/favstore/favstore_test.go
@@ -0,0 +1,259 @@
+package favstore
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/storage"
+
+ "github.com/frathe/picfetch/internal/uitest"
+)
+
+func TestDefaultDirUsesUserConfigDirectory(t *testing.T) {
+ t.Parallel()
+
+ base, err := os.UserConfigDir()
+ if err != nil || base == "" {
+ base = os.TempDir()
+ }
+ want := filepath.Join(base, "picfetch", "favorites")
+ if got := DefaultDir(); got != want {
+ t.Errorf("DefaultDir() = %q, want %q", got, want)
+ }
+}
+
+func TestValidName(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ want bool
+ }{
+ {name: "", want: false},
+ {name: ".", want: false},
+ {name: "..", want: false},
+ {name: "Summer 2026", want: true},
+ {name: "Trip.2026", want: true},
+ {name: " leading space", want: true},
+ {name: "trailing space ", want: true},
+ {name: "a/b", want: false},
+ {name: `a\b`, want: false},
+ {name: "a:b", want: false},
+ {name: "a*b", want: false},
+ {name: "a?b", want: false},
+ {name: `a"b`, want: false},
+ {name: "ab", want: false},
+ {name: "a|b", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ValidName(tt.name); got != tt.want {
+ t.Errorf("ValidName(%q) = %v, want %v", tt.name, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSaveLoadRoundTripPreservesNumericOrder(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ files := make([]fyne.URI, 12)
+ for i := range files {
+ files[i] = storage.NewFileURI(filepath.Join(dir, "images", string(rune('a'+i))+".jpg"))
+ }
+
+ if err := Save(dir, "Trip", files); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ got, err := Load(dir, "Trip")
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ gotPaths := make([]string, len(got))
+ wantPaths := make([]string, len(files))
+ for i := range got {
+ gotPaths[i] = got[i].Path()
+ wantPaths[i] = files[i].Path()
+ }
+ if !slices.Equal(gotPaths, wantPaths) {
+ t.Errorf("Load paths = %v, want %v", gotPaths, wantPaths)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "Trip", fileListName))
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+ var stored map[string]string
+ if err := json.Unmarshal(data, &stored); err != nil {
+ t.Fatalf("Unmarshal: %v", err)
+ }
+ if stored["10"] != files[10].Path() {
+ t.Errorf("stored[10] = %q, want %q", stored["10"], files[10].Path())
+ }
+}
+
+func TestSaveOverwritesExistingFavorite(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ first := []fyne.URI{storage.NewFileURI("/first.jpg")}
+ second := []fyne.URI{storage.NewFileURI("/second.jpg")}
+
+ if err := Save(dir, "Set", first); err != nil {
+ t.Fatalf("first Save: %v", err)
+ }
+ if err := Save(dir, "Set", second); err != nil {
+ t.Fatalf("second Save: %v", err)
+ }
+ got, err := Load(dir, "Set")
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if len(got) != 1 || got[0].Path() != "/second.jpg" {
+ t.Errorf("Load = %v, want only /second.jpg", got)
+ }
+}
+
+func TestSaveRejectsInvalidNameAndNilURI(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ if err := Save(dir, "../escape", nil); err == nil {
+ t.Error("Save accepted an invalid name")
+ }
+ if err := Save(dir, "Nil", []fyne.URI{nil}); err == nil {
+ t.Error("Save accepted a nil URI")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "Nil")); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("Save left a directory behind for invalid input: %v", err)
+ }
+}
+
+func TestLoadRejectsMalformedData(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ data string
+ }{
+ {name: "invalid JSON", data: "{"},
+ {name: "non-numeric index", data: `{"first":"/a.jpg"}`},
+ {name: "negative index", data: `{"-1":"/a.jpg"}`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ favoriteDir := filepath.Join(dir, "Broken")
+ if err := os.Mkdir(favoriteDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(favoriteDir, fileListName), []byte(tt.data), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Load(dir, "Broken"); err == nil {
+ t.Error("Load accepted malformed data")
+ }
+ })
+ }
+}
+
+func TestList(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ for _, name := range []string{"zebra", "Alpha", "beta"} {
+ if err := Save(dir, name, nil); err != nil {
+ t.Fatalf("Save %q: %v", name, err)
+ }
+ }
+ if err := os.Mkdir(filepath.Join(dir, "not-a-favorite"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "plain-file"), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := List(dir)
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ want := []string{"Alpha", "beta", "zebra"}
+ if !slices.Equal(got, want) {
+ t.Errorf("List = %v, want %v", got, want)
+ }
+}
+
+func TestListMissingDirectoryIsEmpty(t *testing.T) {
+ t.Parallel()
+
+ got, err := List(filepath.Join(t.TempDir(), "missing"))
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if got != nil {
+ t.Errorf("List = %v, want nil", got)
+ }
+}
+
+func TestExists(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ if Exists(dir, "Set") {
+ t.Fatal("Exists reported a missing favorite")
+ }
+ if err := Save(dir, "Set", nil); err != nil {
+ t.Fatal(err)
+ }
+ if !Exists(dir, "Set") {
+ t.Error("Exists did not report a saved favorite")
+ }
+ if Exists(dir, "../Set") {
+ t.Error("Exists accepted an invalid name")
+ }
+}
+
+func TestRemoveMovesFavoriteDirectoryToTrash(t *testing.T) {
+ dir := t.TempDir()
+ if err := Save(dir, "Set", nil); err != nil {
+ t.Fatal(err)
+ }
+
+ var moved string
+ uitest.StubTrashMove(t, func(path string) error {
+ moved = path
+ return os.RemoveAll(path)
+ })
+ if err := Remove(dir, "Set"); err != nil {
+ t.Fatalf("Remove: %v", err)
+ }
+ want := filepath.Join(dir, "Set")
+ if moved != want {
+ t.Errorf("trash path = %q, want %q", moved, want)
+ }
+ if _, err := os.Stat(want); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("removed favorite still exists: %v", err)
+ }
+}
+
+func TestRemoveRejectsInvalidNameAndPropagatesError(t *testing.T) {
+ if err := Remove(t.TempDir(), "../escape"); err == nil {
+ t.Error("Remove accepted an invalid name")
+ }
+
+ wantErr := errors.New("trash failed")
+ uitest.StubTrashMove(t, func(string) error { return wantErr })
+ if err := Remove(t.TempDir(), "Set"); !errors.Is(err, wantErr) {
+ t.Errorf("Remove error = %v, want %v", err, wantErr)
+ }
+}
diff --git a/internal/trash/trash.go b/internal/trash/trash.go
index 0903e25..768814e 100644
--- a/internal/trash/trash.go
+++ b/internal/trash/trash.go
@@ -93,12 +93,21 @@ func homeTrashEnv() []string {
}
// moveWindows shells out to Microsoft.VisualBasic.FileIO.FileSystem's
-// DeleteFile with RecycleOption.SendToRecycleBin - the same recycle-bin
-// delete Explorer's own Delete key uses.
+// DeleteFile/DeleteDirectory with RecycleOption.SendToRecycleBin - the same
+// recycle-bin delete Explorer's own Delete key uses.
func moveWindows(path string) error {
+ info, err := os.Stat(path)
+ if err != nil {
+ return err
+ }
+
+ method := "DeleteFile"
+ if info.IsDir() {
+ method = "DeleteDirectory"
+ }
script := `Add-Type -AssemblyName Microsoft.VisualBasic
try {
- [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile("` + escapePowerShellPath(path) + `", 'OnlyErrorDialogs', 'SendToRecycleBin')
+ [Microsoft.VisualBasic.FileIO.FileSystem]::` + method + `("` + escapePowerShellPath(path) + `", 'OnlyErrorDialogs', 'SendToRecycleBin')
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 1
@@ -106,7 +115,7 @@ try {
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
hideConsoleWindow(cmd)
- _, err := runTrashCommand(cmd)
+ _, err = runTrashCommand(cmd)
return err
}
diff --git a/internal/trash/trash_test.go b/internal/trash/trash_test.go
index a48d397..54a2768 100644
--- a/internal/trash/trash_test.go
+++ b/internal/trash/trash_test.go
@@ -120,34 +120,68 @@ func TestMoveLinux_ReturnsErrorWhenNeitherToolInstalled(t *testing.T) {
}
func TestMoveWindows_BuildsExpectedScript(t *testing.T) {
- origRun := runTrashCommand
- t.Cleanup(func() { runTrashCommand = origRun })
-
- var gotScript string
- runTrashCommand = func(cmd *exec.Cmd) ([]byte, error) {
- for i, a := range cmd.Args {
- if a == "-Command" && i+1 < len(cmd.Args) {
- gotScript = cmd.Args[i+1]
+ tests := []struct {
+ name string
+ makeTarget func(*testing.T) string
+ method string
+ notMethod string
+ }{
+ {
+ name: "file",
+ makeTarget: func(t *testing.T) string {
+ path := filepath.Join(t.TempDir(), "photo.jpg")
+ if err := os.WriteFile(path, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+ },
+ method: "DeleteFile",
+ notMethod: "DeleteDirectory",
+ },
+ {
+ name: "directory",
+ makeTarget: func(t *testing.T) string { return t.TempDir() },
+ method: "DeleteDirectory",
+ notMethod: "DeleteFile",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ origRun := runTrashCommand
+ t.Cleanup(func() { runTrashCommand = origRun })
+
+ var gotScript string
+ runTrashCommand = func(cmd *exec.Cmd) ([]byte, error) {
+ for i, a := range cmd.Args {
+ if a == "-Command" && i+1 < len(cmd.Args) {
+ gotScript = cmd.Args[i+1]
+ }
+ }
+ return nil, nil
}
- }
- return nil, nil
- }
- if err := moveWindows(`C:\Users\me\photo.jpg`); err != nil {
- t.Fatalf("moveWindows() error = %v", err)
- }
+ path := tt.makeTarget(t)
+ if err := moveWindows(path); err != nil {
+ t.Fatalf("moveWindows() error = %v", err)
+ }
- for _, want := range []string{
- "Microsoft.VisualBasic",
- "[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile",
- "SendToRecycleBin",
- "catch",
- "exit 1",
- `C:\Users\me\photo.jpg`,
- } {
- if !strings.Contains(gotScript, want) {
- t.Errorf("script does not contain %q:\n%s", want, gotScript)
- }
+ for _, want := range []string{
+ "Microsoft.VisualBasic",
+ "[Microsoft.VisualBasic.FileIO.FileSystem]::" + tt.method,
+ "SendToRecycleBin",
+ "catch",
+ "exit 1",
+ path,
+ } {
+ if !strings.Contains(gotScript, want) {
+ t.Errorf("script does not contain %q:\n%s", want, gotScript)
+ }
+ }
+ if strings.Contains(gotScript, "[Microsoft.VisualBasic.FileIO.FileSystem]::"+tt.notMethod) {
+ t.Errorf("script unexpectedly contains %s:\n%s", tt.notMethod, gotScript)
+ }
+ })
}
}
@@ -171,7 +205,11 @@ func TestMoveWindows_EscapesPathMetacharacters(t *testing.T) {
return nil, nil
}
- if err := moveWindows("C:\\Users\\me\\$weird`file.jpg"); err != nil {
+ path := filepath.Join(t.TempDir(), "$weird`file.jpg")
+ if err := os.WriteFile(path, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := moveWindows(path); err != nil {
t.Fatalf("moveWindows() error = %v", err)
}
@@ -183,6 +221,30 @@ func TestMoveWindows_EscapesPathMetacharacters(t *testing.T) {
}
}
+func TestMoveWindows_ReturnsStatErrorBeforeRunningCommand(t *testing.T) {
+ origRun := runTrashCommand
+ t.Cleanup(func() { runTrashCommand = origRun })
+ runTrashCommand = func(*exec.Cmd) ([]byte, error) {
+ t.Fatal("command should not run for a missing target")
+ return nil, nil
+ }
+
+ if err := moveWindows(filepath.Join(t.TempDir(), "missing")); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("moveWindows error = %v, want os.ErrNotExist", err)
+ }
+}
+
+func TestMoveWindows_ReturnsCommandError(t *testing.T) {
+ origRun := runTrashCommand
+ t.Cleanup(func() { runTrashCommand = origRun })
+ wantErr := errors.New("powershell failed")
+ runTrashCommand = func(*exec.Cmd) ([]byte, error) { return nil, wantErr }
+
+ if err := moveWindows(t.TempDir()); !errors.Is(err, wantErr) {
+ t.Errorf("moveWindows error = %v, want %v", err, wantErr)
+ }
+}
+
func TestEscapePowerShellPath(t *testing.T) {
got := escapePowerShellPath("C:\\a$b`c")
want := "C:\\a`$b``c"
diff --git a/internal/ui/build.go b/internal/ui/build.go
index 64b6bbc..019506f 100644
--- a/internal/ui/build.go
+++ b/internal/ui/build.go
@@ -27,6 +27,7 @@ import (
"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"
@@ -379,6 +380,7 @@ func buildViewer(application fyne.App) (*viewer, fyne.Window) {
// 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
@@ -466,6 +468,7 @@ func buildViewer(application fyne.App) (*viewer, fyne.Window) {
})
wireOpenShortcuts(window.Canvas(), view)
+ wireFavoriteShortcuts(window.Canvas(), view.favorites.Open)
wireClipboardShortcuts(window.Canvas(), view)
wireDeleteShortcut(window.Canvas(), view)
wireSelectAllShortcut(window.Canvas(), view)
@@ -474,8 +477,8 @@ func buildViewer(application fyne.App) (*viewer, fyne.Window) {
return view, window
}
-// shortcutAdder is the one method of fyne.Canvas that wireOpenShortcuts
-// needs, narrow enough that a bare *fyne.ShortcutHandler satisfies it too -
+// 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
@@ -510,6 +513,18 @@ func wireOpenShortcuts(c shortcutAdder, view *viewer) {
}, 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
diff --git a/internal/ui/favorites/favorites.go b/internal/ui/favorites/favorites.go
new file mode 100644
index 0000000..ca06185
--- /dev/null
+++ b/internal/ui/favorites/favorites.go
@@ -0,0 +1,300 @@
+// Package favorites owns the Favorites menu and its dialogs.
+package favorites
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/data/validation"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/driver/desktop"
+ "fyne.io/fyne/v2/lang"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/frathe/picfetch/internal/favstore"
+)
+
+// ShortcutCount is how many sorted favorites can be opened by keyboard.
+const ShortcutCount = 10
+
+var shortcutKeys = [...]fyne.KeyName{
+ fyne.Key1,
+ fyne.Key2,
+ fyne.Key3,
+ fyne.Key4,
+ fyne.Key5,
+ fyne.Key6,
+ fyne.Key7,
+ fyne.Key8,
+ fyne.Key9,
+ fyne.Key0,
+}
+
+// Host is the viewer behavior used by the favorites feature.
+type Host interface {
+ FileCount() int
+ FileAt(i int) fyne.URI
+ OpenFiles(files []fyne.URI)
+ ShowToast(msg string)
+}
+
+// Feature owns the Favorites menu and its dialogs.
+type Feature struct {
+ host Host
+ win fyne.Window
+ dir string
+
+ menu *fyne.Menu
+ addItem *fyne.MenuItem
+ manageItem *fyne.MenuItem
+ names []string
+
+ manageDialog dialog.Dialog
+ pending sync.WaitGroup
+}
+
+// New builds the Favorites menu without reading from disk.
+func New(host Host, win fyne.Window) *Feature {
+ f := &Feature{host: host, win: win}
+ f.addItem = fyne.NewMenuItem(lang.L("Add Current List to Favorites…"), f.addToFavorites)
+ f.addItem.Disabled = true
+ f.manageItem = fyne.NewMenuItem(lang.L("Manage Favorites…"), f.showManage)
+ f.menu = fyne.NewMenu(lang.L("Favorites"),
+ f.addItem, fyne.NewMenuItemSeparator(), f.manageItem)
+ return f
+}
+
+// Menu returns the feature's top-level menu.
+func (f *Feature) Menu() *fyne.Menu {
+ return f.menu
+}
+
+// SetDir selects the storage directory and populates the menu from it.
+func (f *Feature) SetDir(dir string) {
+ f.dir = dir
+ f.refreshMenu()
+}
+
+// SetHasFiles enables adding the current list when it is non-empty.
+func (f *Feature) SetHasFiles(has bool) {
+ f.addItem.Disabled = !has
+ f.menu.Refresh()
+}
+
+// ShortcutForIndex returns the Cmd/Ctrl+digit accelerator for a zero-based
+// favorite index: 1 through 9, then 0 for the tenth.
+func ShortcutForIndex(index int) *desktop.CustomShortcut {
+ if index < 0 || index >= len(shortcutKeys) {
+ return nil
+ }
+ return &desktop.CustomShortcut{
+ KeyName: shortcutKeys[index],
+ Modifier: fyne.KeyModifierShortcutDefault,
+ }
+}
+
+// Open opens the favorite currently assigned to a zero-based shortcut slot.
+func (f *Feature) Open(index int) {
+ if index < 0 || index >= ShortcutCount || index >= len(f.names) {
+ return
+ }
+ f.openFavorite(f.names[index])
+}
+
+func (f *Feature) refreshMenu() bool {
+ names, err := favstore.List(f.dir)
+ if err != nil {
+ f.reportError(lang.L("could not list favorites: %v"), err)
+ return false
+ }
+
+ items := []*fyne.MenuItem{f.addItem, fyne.NewMenuItemSeparator()}
+ for i, name := range names {
+ favoriteName := name
+ item := fyne.NewMenuItem(favoriteName, func() {
+ f.openFavorite(favoriteName)
+ })
+ if shortcut := ShortcutForIndex(i); shortcut != nil {
+ item.Shortcut = shortcut
+ }
+ items = append(items, item)
+ }
+ if len(names) > 0 {
+ items = append(items, fyne.NewMenuItemSeparator())
+ }
+ items = append(items, f.manageItem)
+ f.names = names
+ f.menu.Items = items
+ f.menu.Refresh()
+ return true
+}
+
+func (f *Feature) addToFavorites() {
+ form, _ := f.newAddDialog()
+ form.Show()
+}
+
+func (f *Feature) newAddDialog() (*dialog.FormDialog, *widget.Entry) {
+ entry := widget.NewEntry()
+ reason := lang.L(`enter a name without / \ : * ? " < > |`)
+ entry.Validator = validation.NewAllStrings(
+ validation.NewRegexp(`^[^/\\:*?"<>|]+$`, reason),
+ func(name string) error {
+ if !favstore.ValidName(strings.TrimSpace(name)) {
+ return errors.New(reason)
+ }
+ return nil
+ },
+ )
+
+ form := dialog.NewForm(
+ lang.L("Add to Favorites"),
+ lang.L("Add"),
+ lang.L("Cancel"),
+ []*widget.FormItem{widget.NewFormItem(lang.L("Name"), entry)},
+ func(confirmed bool) {
+ if confirmed {
+ f.saveFavorite(entry.Text)
+ }
+ },
+ f.win,
+ )
+ return form, entry
+}
+
+func (f *Feature) saveFavorite(name string) {
+ name = strings.TrimSpace(name)
+ if !favstore.ValidName(name) {
+ f.host.ShowToast(lang.L(`enter a name without / \ : * ? " < > |`))
+ return
+ }
+
+ if favstore.Exists(f.dir, name) {
+ confirm := dialog.NewConfirm(
+ lang.L("Replace Favorite"),
+ fmt.Sprintf(lang.L("A favorite named %q already exists. Replace it?"), name),
+ func(replace bool) {
+ if replace {
+ f.writeFavorite(name)
+ }
+ },
+ f.win,
+ )
+ confirm.SetConfirmText(lang.L("Replace"))
+ confirm.Show()
+ return
+ }
+ f.writeFavorite(name)
+}
+
+func (f *Feature) writeFavorite(name string) {
+ count := f.host.FileCount()
+ if count == 0 {
+ f.host.ShowToast(lang.L("there are no open files to add to favorites"))
+ return
+ }
+
+ files := make([]fyne.URI, count)
+ for i := range files {
+ files[i] = f.host.FileAt(i)
+ }
+ if err := favstore.Save(f.dir, name, files); err != nil {
+ f.reportError(lang.L("could not save favorite %q: %v"), name, err)
+ return
+ }
+ if !f.refreshMenu() {
+ return
+ }
+ f.host.ShowToast(fmt.Sprintf(lang.L("saved favorite %q"), name))
+}
+
+func (f *Feature) openFavorite(name string) {
+ files, err := favstore.Load(f.dir, name)
+ if err != nil {
+ f.reportError(lang.L("could not open favorite %q: %v"), name, err)
+ return
+ }
+ f.host.OpenFiles(files)
+}
+
+func (f *Feature) showManage() {
+ names, err := favstore.List(f.dir)
+ if err != nil {
+ f.reportError(lang.L("could not list favorites: %v"), err)
+ return
+ }
+
+ var content fyne.CanvasObject
+ if len(names) == 0 {
+ content = widget.NewLabel(lang.L("No favorites yet"))
+ } else {
+ rows := make([]fyne.CanvasObject, 0, len(names))
+ for _, name := range names {
+ favoriteName := name
+ remove := widget.NewButton(lang.L("Remove"), func() {
+ f.removeFavorite(favoriteName)
+ })
+ rows = append(rows, container.NewBorder(nil, nil, nil, remove,
+ widget.NewLabel(favoriteName)))
+ }
+ scroll := container.NewVScroll(container.NewVBox(rows...))
+ scroll.SetMinSize(fyne.NewSize(420, 240))
+ content = scroll
+ }
+
+ f.manageDialog = dialog.NewCustom(
+ lang.L("Manage Favorites"),
+ lang.L("Close"),
+ content,
+ f.win,
+ )
+ f.manageDialog.Show()
+}
+
+func (f *Feature) removeFavorite(name string) {
+ confirm := dialog.NewConfirm(
+ lang.L("Remove Favorite"),
+ fmt.Sprintf(lang.L("Remove %q from favorites? Its folder will be moved to the Trash."), name),
+ func(remove bool) {
+ if remove {
+ f.performRemove(name)
+ }
+ },
+ f.win,
+ )
+ confirm.SetConfirmText(lang.L("Remove"))
+ confirm.SetConfirmImportance(widget.DangerImportance)
+ confirm.Show()
+}
+
+func (f *Feature) performRemove(name string) {
+ f.pending.Add(1)
+ go func() {
+ err := favstore.Remove(f.dir, name)
+ fyne.Do(func() {
+ defer f.pending.Done()
+
+ if err != nil {
+ f.reportError(lang.L("could not remove favorite %q: %v"), name, err)
+ return
+ }
+
+ f.refreshMenu()
+ f.host.ShowToast(fmt.Sprintf(lang.L("removed favorite %q"), name))
+ if f.manageDialog != nil {
+ f.manageDialog.Hide()
+ f.showManage()
+ }
+ })
+ }()
+}
+
+func (f *Feature) reportError(format string, args ...any) {
+ message := fmt.Sprintf(format, args...)
+ fyne.LogError("favorites operation failed", errors.New(message))
+ f.host.ShowToast(message)
+}
diff --git a/internal/ui/favorites/favorites_test.go b/internal/ui/favorites/favorites_test.go
new file mode 100644
index 0000000..999327b
--- /dev/null
+++ b/internal/ui/favorites/favorites_test.go
@@ -0,0 +1,368 @@
+package favorites
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/driver/desktop"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/test"
+
+ "github.com/frathe/picfetch/internal/favstore"
+ "github.com/frathe/picfetch/internal/uitest"
+)
+
+type fakeHost struct {
+ files []fyne.URI
+ opened []fyne.URI
+ toasts []string
+}
+
+func (h *fakeHost) FileCount() int { return len(h.files) }
+func (h *fakeHost) FileAt(i int) fyne.URI { return h.files[i] }
+func (h *fakeHost) OpenFiles(files []fyne.URI) {
+ h.opened = slices.Clone(files)
+}
+func (h *fakeHost) ShowToast(message string) { h.toasts = append(h.toasts, message) }
+
+func newFeature(t *testing.T, host *fakeHost) *Feature {
+ t.Helper()
+
+ app := test.NewApp()
+ t.Cleanup(app.Quit)
+ win := app.NewWindow("favorites test")
+ t.Cleanup(win.Close)
+
+ f := New(host, win)
+ f.SetDir(t.TempDir())
+ return f
+}
+
+func TestNewBuildsStaticMenuWithoutDiskAccess(t *testing.T) {
+ host := &fakeHost{}
+ app := test.NewApp()
+ t.Cleanup(app.Quit)
+ win := app.NewWindow("favorites test")
+ t.Cleanup(win.Close)
+
+ f := New(host, win)
+
+ if f.dir != "" {
+ t.Errorf("New set storage dir to %q, want no disk initialization", f.dir)
+ }
+ if f.menu.Label != "Favorites" {
+ t.Errorf("menu label = %q, want Favorites", f.menu.Label)
+ }
+ if len(f.menu.Items) != 3 || f.menu.Items[0] != f.addItem ||
+ !f.menu.Items[1].IsSeparator || f.menu.Items[2] != f.manageItem {
+ t.Errorf("static menu items = %+v", f.menu.Items)
+ }
+ if !f.addItem.Disabled {
+ t.Error("Add should start disabled")
+ }
+}
+
+func TestSetDirBuildsSortedFavoriteItems(t *testing.T) {
+ host := &fakeHost{}
+ f := newFeature(t, host)
+ for _, name := range []string{"zebra", "Alpha", "beta"} {
+ if err := favstore.Save(f.dir, name, nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ f.SetDir(f.dir)
+
+ if len(f.menu.Items) != 7 {
+ t.Fatalf("menu item count = %d, want 7", len(f.menu.Items))
+ }
+ got := []string{f.menu.Items[2].Label, f.menu.Items[3].Label, f.menu.Items[4].Label}
+ want := []string{"Alpha", "beta", "zebra"}
+ if !slices.Equal(got, want) {
+ t.Errorf("favorite items = %v, want %v", got, want)
+ }
+ if !f.menu.Items[1].IsSeparator || !f.menu.Items[5].IsSeparator {
+ t.Error("dynamic entries should be enclosed by separators")
+ }
+}
+
+func TestSetDirAssignsDigitShortcutsToFirstTenFavorites(t *testing.T) {
+ f := newFeature(t, &fakeHost{})
+ for i := 0; i < ShortcutCount+1; i++ {
+ name := fmt.Sprintf("Favorite %02d", i+1)
+ if err := favstore.Save(f.dir, name, nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ f.SetDir(f.dir)
+
+ wantKeys := []fyne.KeyName{
+ fyne.Key1,
+ fyne.Key2,
+ fyne.Key3,
+ fyne.Key4,
+ fyne.Key5,
+ fyne.Key6,
+ fyne.Key7,
+ fyne.Key8,
+ fyne.Key9,
+ fyne.Key0,
+ }
+ for i := 0; i < ShortcutCount+1; i++ {
+ item := f.menu.Items[i+2]
+ if i == ShortcutCount {
+ if item.Shortcut != nil {
+ t.Errorf("favorite 11 shortcut = %v, want nil", item.Shortcut)
+ }
+ continue
+ }
+
+ got, ok := item.Shortcut.(*desktop.CustomShortcut)
+ if !ok {
+ t.Fatalf("favorite %d shortcut type = %T, want *desktop.CustomShortcut", i+1, item.Shortcut)
+ }
+ if got.KeyName != wantKeys[i] || got.Modifier != fyne.KeyModifierShortcutDefault {
+ t.Errorf("favorite %d shortcut = %+v, want key %s with default modifier",
+ i+1, got, wantKeys[i])
+ }
+ }
+ if ShortcutForIndex(-1) != nil || ShortcutForIndex(ShortcutCount) != nil {
+ t.Error("ShortcutForIndex should return nil outside the ten favorite slots")
+ }
+}
+
+func TestOpenUsesCurrentSortedShortcutSlots(t *testing.T) {
+ host := &fakeHost{}
+ f := newFeature(t, host)
+ for i := 0; i < ShortcutCount+1; i++ {
+ name := fmt.Sprintf("Favorite %02d", i+1)
+ files := []fyne.URI{storage.NewFileURI(fmt.Sprintf("/photos/%02d.jpg", i+1))}
+ if err := favstore.Save(f.dir, name, files); err != nil {
+ t.Fatal(err)
+ }
+ }
+ f.SetDir(f.dir)
+
+ for i := 0; i < ShortcutCount; i++ {
+ f.Open(i)
+ want := fmt.Sprintf("/photos/%02d.jpg", i+1)
+ if len(host.opened) != 1 || host.opened[0].Path() != want {
+ t.Errorf("Open(%d) opened %v, want %q", i, host.opened, want)
+ }
+ }
+
+ if err := favstore.Save(f.dir, "A Favorite", []fyne.URI{storage.NewFileURI("/photos/new-first.jpg")}); err != nil {
+ t.Fatal(err)
+ }
+ f.SetDir(f.dir)
+ f.Open(0)
+ if len(host.opened) != 1 || host.opened[0].Path() != "/photos/new-first.jpg" {
+ t.Errorf("Open(0) after refresh opened %v, want the newly sorted first favorite", host.opened)
+ }
+
+ host.opened = nil
+ f.Open(-1)
+ f.Open(ShortcutCount)
+ if host.opened != nil {
+ t.Errorf("out-of-range shortcut opened %v", host.opened)
+ }
+}
+
+func TestSetHasFilesTogglesAddItem(t *testing.T) {
+ f := newFeature(t, &fakeHost{})
+
+ f.SetHasFiles(true)
+ if f.addItem.Disabled {
+ t.Error("Add should be enabled with files")
+ }
+ f.SetHasFiles(false)
+ if !f.addItem.Disabled {
+ t.Error("Add should be disabled without files")
+ }
+}
+
+func TestWriteFavoriteSavesCurrentListAndRefreshesMenu(t *testing.T) {
+ host := &fakeHost{files: []fyne.URI{
+ storage.NewFileURI("/photos/a.jpg"),
+ storage.NewFileURI("/photos/b.jpg"),
+ }}
+ f := newFeature(t, host)
+
+ f.writeFavorite("Trip")
+
+ got, err := favstore.Load(f.dir, "Trip")
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if len(got) != 2 || got[0].Path() != "/photos/a.jpg" || got[1].Path() != "/photos/b.jpg" {
+ t.Errorf("stored files = %v", got)
+ }
+ if len(f.menu.Items) != 5 || f.menu.Items[2].Label != "Trip" {
+ t.Errorf("menu not refreshed after save: %+v", f.menu.Items)
+ }
+ if len(host.toasts) != 1 || !strings.Contains(host.toasts[0], "Trip") {
+ t.Errorf("toasts = %v, want saved Trip", host.toasts)
+ }
+}
+
+func TestAddDialogSubmitsValidatedName(t *testing.T) {
+ host := &fakeHost{files: []fyne.URI{storage.NewFileURI("/photos/a.jpg")}}
+ f := newFeature(t, host)
+ form, entry := f.newAddDialog()
+ form.Show()
+
+ entry.SetText(" Trip ")
+ form.Submit()
+
+ if !favstore.Exists(f.dir, "Trip") {
+ t.Error("submitting the add dialog did not save the trimmed favorite name")
+ }
+}
+
+func TestAddDialogRejectsInvalidName(t *testing.T) {
+ host := &fakeHost{files: []fyne.URI{storage.NewFileURI("/photos/a.jpg")}}
+ f := newFeature(t, host)
+ form, entry := f.newAddDialog()
+ form.Show()
+
+ entry.SetText("../escape")
+ form.Submit()
+ form.Dismiss()
+
+ if favstore.Exists(f.dir, "../escape") {
+ t.Error("submitting the add dialog accepted an invalid favorite name")
+ }
+ if len(host.toasts) != 0 {
+ t.Errorf("disabled form submission unexpectedly ran its callback: %v", host.toasts)
+ }
+}
+
+func TestWriteFavoriteRejectsEmptyCurrentList(t *testing.T) {
+ host := &fakeHost{}
+ f := newFeature(t, host)
+
+ f.writeFavorite("Empty")
+
+ if favstore.Exists(f.dir, "Empty") {
+ t.Error("empty current list was saved")
+ }
+ if len(host.toasts) != 1 || !strings.Contains(host.toasts[0], "no open files") {
+ t.Errorf("toasts = %v", host.toasts)
+ }
+}
+
+func TestSaveFavoriteRejectsInvalidName(t *testing.T) {
+ host := &fakeHost{files: []fyne.URI{storage.NewFileURI("/a.jpg")}}
+ f := newFeature(t, host)
+
+ f.saveFavorite("../escape")
+
+ if len(host.toasts) != 1 || !strings.Contains(host.toasts[0], "enter a name") {
+ t.Errorf("toasts = %v", host.toasts)
+ }
+ if _, err := os.Stat(filepath.Join(filepath.Dir(f.dir), "escape")); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("invalid favorite escaped storage dir: %v", err)
+ }
+}
+
+func TestOpenFavoriteLoadsStoredList(t *testing.T) {
+ host := &fakeHost{}
+ f := newFeature(t, host)
+ files := []fyne.URI{
+ storage.NewFileURI("/photos/one.jpg"),
+ storage.NewFileURI("/photos/two.jpg"),
+ }
+ if err := favstore.Save(f.dir, "Trip", files); err != nil {
+ t.Fatal(err)
+ }
+
+ f.openFavorite("Trip")
+
+ if len(host.opened) != 2 || host.opened[0].Path() != files[0].Path() ||
+ host.opened[1].Path() != files[1].Path() {
+ t.Errorf("opened = %v, want %v", host.opened, files)
+ }
+}
+
+func TestOpenFavoriteReportsLoadError(t *testing.T) {
+ host := &fakeHost{}
+ f := newFeature(t, host)
+
+ f.openFavorite("Missing")
+
+ if host.opened != nil {
+ t.Errorf("opened = %v, want nil", host.opened)
+ }
+ if len(host.toasts) != 1 || !strings.Contains(host.toasts[0], "Missing") {
+ t.Errorf("toasts = %v", host.toasts)
+ }
+}
+
+func TestPerformRemoveTrashesDirectoryAndRefreshesMenu(t *testing.T) {
+ host := &fakeHost{}
+ f := newFeature(t, host)
+ if err := favstore.Save(f.dir, "Trip", nil); err != nil {
+ t.Fatal(err)
+ }
+ f.SetDir(f.dir)
+ uitest.StubTrashMove(t, func(path string) error { return os.RemoveAll(path) })
+
+ f.performRemove("Trip")
+ f.pending.Wait()
+
+ if favstore.Exists(f.dir, "Trip") {
+ t.Error("favorite still exists after removal")
+ }
+ if len(f.menu.Items) != 3 {
+ t.Errorf("menu item count = %d, want static 3 after removal", len(f.menu.Items))
+ }
+ if len(host.toasts) != 1 || !strings.Contains(host.toasts[0], "Trip") {
+ t.Errorf("toasts = %v", host.toasts)
+ }
+}
+
+func TestPerformRemoveReportsTrashError(t *testing.T) {
+ host := &fakeHost{}
+ f := newFeature(t, host)
+ if err := favstore.Save(f.dir, "Trip", nil); err != nil {
+ t.Fatal(err)
+ }
+ wantErr := errors.New("trash unavailable")
+ uitest.StubTrashMove(t, func(string) error { return wantErr })
+
+ f.performRemove("Trip")
+ f.pending.Wait()
+
+ if !favstore.Exists(f.dir, "Trip") {
+ t.Error("favorite disappeared after failed removal")
+ }
+ if len(host.toasts) != 1 || !strings.Contains(host.toasts[0], wantErr.Error()) {
+ t.Errorf("toasts = %v", host.toasts)
+ }
+}
+
+func TestShowManageBuildsEmptyAndPopulatedDialogs(t *testing.T) {
+ f := newFeature(t, &fakeHost{})
+
+ f.showManage()
+ if f.manageDialog == nil {
+ t.Fatal("showManage did not build an empty dialog")
+ }
+ f.manageDialog.Hide()
+
+ if err := favstore.Save(f.dir, "Trip", nil); err != nil {
+ t.Fatal(err)
+ }
+ f.showManage()
+ if f.manageDialog == nil {
+ t.Fatal("showManage did not build a populated dialog")
+ }
+ f.manageDialog.Hide()
+}
diff --git a/internal/ui/help/manual.md b/internal/ui/help/manual.md
index 616c159..7038e51 100644
--- a/internal/ui/help/manual.md
+++ b/internal/ui/help/manual.md
@@ -405,6 +405,8 @@ many of them actually went.
- **`Cmd`/`Ctrl+O`** / **`Cmd`/`Ctrl+Shift+O`** — open the system file picker
(same as clicking the drop zone; both bindings do the same thing; files and
folders on macOS/Linux, files only on Windows — see above)
+- **`Cmd`/`Ctrl+1`** through **`Cmd`/`Ctrl+9`** — open sorted favorites 1
+ through 9; **`Cmd`/`Ctrl+0`** opens favorite 10 (see "Menu" below)
- **`→`** / **`↓`** — next image
- **`←`** / **`↑`** — previous image
- **`Home`** / **`End`** — first / last image
@@ -493,6 +495,20 @@ needed. macOS and Windows need nothing extra either way.
neither is installed
- **File -> Close Files** — returns to the drop zone without quitting
- **File -> Settings…** — opens the settings window
+- **Favorites -> Add Current List to Favorites…** — saves the complete
+ currently open file list as a named collection. Favorites remain available
+ after restarting PicFetch. This stores references to the original files,
+ not copies of the images; moving or deleting an original means it can no
+ longer be loaded from the favorite
+- **Favorites -> _favorite name_** — opens that saved list through the same
+ scan, sort, and merge behavior as Open Files. Entries are sorted by name,
+ case-insensitively. The first nine show `Cmd/Ctrl+1` through
+ `Cmd/Ctrl+9`; the tenth shows `Cmd/Ctrl+0`. Saving another collection with
+ an existing name asks before replacing its stored list
+- **Favorites -> Manage Favorites…** — lists every saved collection and lets
+ you remove one after confirmation. Removing a favorite moves the
+ collection's own folder to the system Trash; it does **not** move or delete
+ any of the original images
- **Help -> Manual** — opens this manual, same as `F1`
---
@@ -613,6 +629,10 @@ Things PicFetch deliberately does not do (yet):
- **Open** — click the drop zone, or press `Cmd`/`Ctrl+O` (or
`Cmd`/`Ctrl+Shift+O`, same thing), for the system file picker (files and
folders on macOS/Linux, files only on Windows)
+- **Favorites** — Favorites -> Add Current List to Favorites… saves the open
+ list; choose its name to reopen it, or use `Cmd`/`Ctrl+1`–`9` for the first
+ nine sorted favorites and `Cmd`/`Ctrl+0` for the tenth; Manage Favorites…
+ removes collections without touching their original images
- **Merge mode** — `M` toggles it on/off; while on, drops add to the set
instead of replacing it, and the title bar shows `[merge]`
- **Next / previous** — `→` `↓` / `←` `↑` (wraps around)
diff --git a/internal/ui/help/manual_de.md b/internal/ui/help/manual_de.md
index 792e7dd..7822ae3 100644
--- a/internal/ui/help/manual_de.md
+++ b/internal/ui/help/manual_de.md
@@ -459,6 +459,8 @@ tatsächlich verschoben wurden.
öffnen (dasselbe wie ein Klick in den Ablegebereich; beide
Tastenkombinationen bewirken dasselbe; Dateien und Ordner unter
macOS/Linux, nur Dateien unter Windows — siehe oben)
+- **`Cmd`/`Strg+1`** bis **`Cmd`/`Strg+9`** — die sortierten Favoriten 1 bis
+ 9 öffnen; **`Cmd`/`Strg+0`** öffnet Favorit 10 (siehe „Menü“ unten)
- **`→`** / **`↓`** — nächstes Bild
- **`←`** / **`↑`** — vorheriges Bild
- **`Home`** / **`End`** — erstes / letztes Bild
@@ -561,6 +563,23 @@ benötigen in beiden Fällen nichts Zusätzliches.
- **Datei -> Dateien schließen** — zurück zum Ablagebereich, ohne das
Programm zu beenden
- **Datei -> Einstellungen…** — öffnet das Einstellungsfenster
+- **Favoriten -> Aktuelle Liste zu Favoriten hinzufügen…** — speichert die
+ gesamte aktuell geöffnete Dateiliste als benannte Sammlung. Favoriten
+ bleiben nach einem Neustart von PicFetch erhalten. Gespeichert werden
+ Verweise auf die Originaldateien, keine Kopien der Bilder; wird ein
+ Original verschoben oder gelöscht, kann es aus dem Favoriten nicht mehr
+ geladen werden
+- **Favoriten -> _Favoritenname_** — öffnet die gespeicherte Liste mit
+ demselben Scan-, Sortier- und Zusammenführen-Verhalten wie „Dateien
+ öffnen“. Die Einträge sind ohne Beachtung der Groß-/Kleinschreibung nach
+ Namen sortiert. Die ersten neun zeigen `Cmd`/`Strg+1` bis
+ `Cmd`/`Strg+9`, der zehnte zeigt `Cmd`/`Strg+0`. Wird eine Sammlung unter
+ einem bereits vorhandenen Namen gespeichert, fragt PicFetch vor dem
+ Ersetzen der gespeicherten Liste nach
+- **Favoriten -> Favoriten verwalten…** — zeigt alle gespeicherten Sammlungen
+ an und ermöglicht, eine davon nach Bestätigung zu entfernen. Dabei wird
+ nur der eigene Ordner der Sammlung in den Papierkorb verschoben; die
+ Originalbilder werden **nicht** verschoben oder gelöscht
- **Hilfe -> Handbuch** — öffnet dieses Handbuch, genau wie `F1`
---
@@ -702,6 +721,11 @@ Dinge, die PicFetch absichtlich (noch) nicht tut:
- **Öffnen** — auf den Ablegebereich klicken, oder `Cmd`/`Strg+O` drücken
(oder `Cmd`/`Strg+Shift+O`, dasselbe), für den System-Dateidialog (Dateien
und Ordner unter macOS/Linux, nur Dateien unter Windows)
+- **Favoriten** — Favoriten -> Aktuelle Liste zu Favoriten hinzufügen…
+ speichert die geöffnete Liste; der Favoritenname öffnet sie wieder,
+ `Cmd`/`Strg+1`–`9` öffnet die ersten neun sortierten Favoriten und
+ `Cmd`/`Strg+0` den zehnten; „Favoriten verwalten…“ entfernt Sammlungen,
+ ohne ihre Originalbilder anzutasten
- **Zusammenführen-Modus** — `M` schaltet ihn ein/aus; solange aktiv,
ergänzen Ablagen die Auswahl, statt sie zu ersetzen, und die Titelzeile
zeigt `[Zusammenführen]`
diff --git a/internal/ui/menu.go b/internal/ui/menu.go
index a4d6a09..777e73a 100644
--- a/internal/ui/menu.go
+++ b/internal/ui/menu.go
@@ -1,5 +1,4 @@
-// The window's menu bar: File (open, close, settings) composed with
-// view.help's own Help menu.
+// The window's menu bar: File (open, close, settings), Favorites, and Help.
package ui
@@ -57,5 +56,5 @@ func buildMainMenu(view *viewer) *fyne.MainMenu {
fileMenu := fyne.NewMenu(lang.L("File"),
open, save, exportPNG, exportJPEG, setWallpaper, closeFiles, fyne.NewMenuItemSeparator(), settings)
- return fyne.NewMainMenu(fileMenu, view.help.Menu())
+ return fyne.NewMainMenu(fileMenu, view.favorites.Menu(), view.help.Menu())
}
diff --git a/internal/ui/menu_test.go b/internal/ui/menu_test.go
index 9136547..635b1d8 100644
--- a/internal/ui/menu_test.go
+++ b/internal/ui/menu_test.go
@@ -6,16 +6,21 @@ package ui
import (
"errors"
"image/color"
+ "slices"
"testing"
"time"
+ "fyne.io/fyne/v2"
+
+ "github.com/frathe/picfetch/internal/favstore"
"github.com/frathe/picfetch/internal/filepicker"
+ favoriteui "github.com/frathe/picfetch/internal/ui/favorites"
"github.com/frathe/picfetch/internal/uitest"
)
// TestBuildMainMenu_Structure checks the bar's shape: File (Open Files…,
// Save Changes, Export as PNG…, Export as JPEG…, Set as Wallpaper, Close
-// Files, a separator, Settings…) followed by view.help's own Help menu -
+// Files, a separator, Settings…) followed by Favorites and Help -
// mirroring help's own
// TestHelpMenu (manual_test.go), which covers the Help submenu's own
// contents.
@@ -24,8 +29,8 @@ func TestBuildMainMenu_Structure(t *testing.T) {
menu := buildMainMenu(v)
- if len(menu.Items) != 2 {
- t.Fatalf("top-level menus = %d, want 2 (File, Help)", len(menu.Items))
+ if len(menu.Items) != 3 {
+ t.Fatalf("top-level menus = %d, want 3 (File, Favorites, Help)", len(menu.Items))
}
file := menu.Items[0]
@@ -61,8 +66,11 @@ func TestBuildMainMenu_Structure(t *testing.T) {
t.Errorf("File menu item 7 = %+v, want %q with an action", got, "Settings…")
}
- if got := menu.Items[1]; got.Label != "Help" {
- t.Errorf("second menu label = %q, want %q", got.Label, "Help")
+ if got := menu.Items[1]; got.Label != "Favorites" {
+ t.Errorf("second menu label = %q, want %q", got.Label, "Favorites")
+ }
+ if got := menu.Items[2]; got.Label != "Help" {
+ t.Errorf("third menu label = %q, want %q", got.Label, "Help")
}
}
@@ -125,6 +133,63 @@ func TestBuildMainMenu_SettingsItemOpensTheSettingsWindow(t *testing.T) {
}
}
+func TestFavoritesMenuItemOpensStoredFilesThroughViewer(t *testing.T) {
+ v := newTestViewer(t)
+ dir := t.TempDir()
+ image := uitest.TempJPEGURI(t, "favorite.jpg", 4, 4, color.White)
+ if err := favstore.Save(dir, "Trip", []fyne.URI{image}); err != nil {
+ t.Fatalf("favstore.Save: %v", err)
+ }
+ v.favorites.SetDir(dir)
+
+ v.favorites.Menu().Items[2].Action()
+ waitForScan(t, v)
+ 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())
+ }
+}
+
+func TestWireFavoriteShortcutsMapsDigitsToFavoriteSlots(t *testing.T) {
+ handler := &fyne.ShortcutHandler{}
+ var opened []int
+ wireFavoriteShortcuts(handler, func(index int) {
+ opened = append(opened, index)
+ })
+
+ for i := 0; i < favoriteui.ShortcutCount; i++ {
+ handler.TypedShortcut(favoriteui.ShortcutForIndex(i))
+ }
+
+ want := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
+ if !slices.Equal(opened, want) {
+ t.Errorf("opened slots = %v, want %v", opened, want)
+ }
+}
+
+func TestFavoriteShortcutOpensStoredFilesThroughViewer(t *testing.T) {
+ v := newTestViewer(t)
+ dir := t.TempDir()
+ image := uitest.TempJPEGURI(t, "shortcut-favorite.jpg", 4, 4, color.White)
+ if err := favstore.Save(dir, "Trip", []fyne.URI{image}); err != nil {
+ t.Fatalf("favstore.Save: %v", err)
+ }
+ v.favorites.SetDir(dir)
+
+ handler := &fyne.ShortcutHandler{}
+ wireFavoriteShortcuts(handler, v.favorites.Open)
+ handler.TypedShortcut(favoriteui.ShortcutForIndex(0))
+ waitForScan(t, v)
+ 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())
+ }
+}
+
// --- Close Files menu item state ------------------------------------------
// TestCloseFilesItem_DisabledWithNoFilesLoaded mirrors the other three
@@ -148,6 +213,9 @@ func TestCloseFilesItem_EnabledAfterFilesLoaded(t *testing.T) {
if v.closeFilesItem.Disabled {
t.Error("Close Files menu item should be enabled once a file is loaded")
}
+ if v.favorites.Menu().Items[0].Disabled {
+ t.Error("Add Current List to Favorites should be enabled once files are loaded")
+ }
}
func TestCloseFilesItem_DisabledAgainAfterCloseFiles(t *testing.T) {
@@ -160,6 +228,9 @@ func TestCloseFilesItem_DisabledAgainAfterCloseFiles(t *testing.T) {
if !v.closeFilesItem.Disabled {
t.Error("Close Files menu item should be disabled again once files are closed")
}
+ if !v.favorites.Menu().Items[0].Disabled {
+ t.Error("Add Current List to Favorites should be disabled again once files are closed")
+ }
}
// --- closeFiles ----------------------------------------------------------
diff --git a/internal/ui/run.go b/internal/ui/run.go
index d48a6c8..450a808 100644
--- a/internal/ui/run.go
+++ b/internal/ui/run.go
@@ -6,6 +6,7 @@ package ui
import (
"fyne.io/fyne/v2"
+ "github.com/frathe/picfetch/internal/favstore"
"github.com/frathe/picfetch/internal/preferences"
"github.com/frathe/picfetch/internal/session"
)
@@ -27,6 +28,7 @@ const (
// the caller); empty for a plain launch.
func Run(application fyne.App, initial []fyne.URI) {
view, window := buildViewer(application)
+ view.favorites.SetDir(favstore.DefaultDir())
// Deferred to SetOnStarted rather than called right away: it ends up
// calling handleDrop, which touches widgets directly (no fyne.Do) the
diff --git a/internal/ui/save.go b/internal/ui/save.go
index 5e1c2e6..026a23c 100644
--- a/internal/ui/save.go
+++ b/internal/ui/save.go
@@ -96,6 +96,7 @@ func (v *viewer) updateFileMenuState() {
v.wallpaperItem.Disabled = !v.canSetWallpaper()
v.closeFilesItem.Disabled = v.FileCount() == 0
+ v.favorites.SetHasFiles(v.FileCount() > 0)
v.win.MainMenu().Refresh()
}
diff --git a/internal/ui/viewer.go b/internal/ui/viewer.go
index 86dcc5d..ce04ad3 100644
--- a/internal/ui/viewer.go
+++ b/internal/ui/viewer.go
@@ -17,6 +17,7 @@ import (
"github.com/frathe/picfetch/internal/imaging"
"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"
@@ -69,6 +70,9 @@ type viewer struct {
// internal/ui/help, which needs nothing from the viewer at all.
help *help.Help
+ // favorites owns the Favorites menu and its add/open/remove dialogs.
+ favorites *favorites.Feature
+
// 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
@@ -752,8 +756,8 @@ func (v *viewer) Modifiers() fyne.KeyModifier {
return v.keyModifiers()
}
-// FileCount, FileAt, CurrentIndex, Generation, Unfocus, Modifiers, and
-// Advance complete the exported vocabulary the feature packages' Host
+// FileCount, FileAt, OpenFiles, CurrentIndex, Generation, Unfocus, Modifiers,
+// and Advance complete the exported vocabulary the feature packages' Host
// interfaces bind to (see the note above CurrentFile). internal/ui/grid uses
// the first six: the first three to draw the right cells, Generation to
// discard a decode whose file set has since been replaced, Unfocus to
@@ -771,6 +775,12 @@ func (v *viewer) FileAt(i int) fyne.URI {
return v.files[i]
}
+// OpenFiles sends a file list through the same scan, merge, sort, and display
+// path as a drag-and-drop or the native file chooser.
+func (v *viewer) OpenFiles(files []fyne.URI) {
+ v.handleDrop(files)
+}
+
// CurrentIndex is the index of the file on screen.
func (v *viewer) CurrentIndex() int {
return v.index
diff --git a/todos.md b/todos.md
index e1f923d..66d3deb 100644
--- a/todos.md
+++ b/todos.md
@@ -2,6 +2,67 @@
## Done
+- favorites menu
+ - Save the currently open file list as a named collection.
+ - Reopen and remove saved collections from a startup-populated menu.
+ - Move removed collection folders to the OS recycle bin.
+
## 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
+ }
+ ```
+
+ - 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
+ generate and persist only missing entries.
+
## not deemed worth implementing (edgecases)
diff --git a/translations/de.json b/translations/de.json
index 8d269cc..0282d78 100644
--- a/translations/de.json
+++ b/translations/de.json
@@ -92,5 +92,27 @@
"Search: %s": "Suche: %s",
"%d of %d": "%d von %d",
"No file names match": "Keine Dateinamen passen",
- "%d selected": "%d ausgewählt"
+ "%d selected": "%d ausgewählt",
+ "Favorites": "Favoriten",
+ "Add Current List to Favorites…": "Aktuelle Liste zu Favoriten hinzufügen…",
+ "Manage Favorites…": "Favoriten verwalten…",
+ "could not list favorites: %v": "Favoriten konnten nicht aufgelistet werden: %v",
+ "enter a name without / \\ : * ? \" < > |": "Namen ohne / \\ : * ? \" < > | eingeben",
+ "Add to Favorites": "Zu Favoriten hinzufügen",
+ "Add": "Hinzufügen",
+ "Replace Favorite": "Favorit ersetzen",
+ "A favorite named %q already exists. Replace it?": "Ein Favorit namens %q ist bereits vorhanden. Ersetzen?",
+ "Replace": "Ersetzen",
+ "there are no open files to add to favorites": "Es sind keine geöffneten Dateien vorhanden, die zu Favoriten hinzugefügt werden können",
+ "could not save favorite %q: %v": "Favorit %q konnte nicht gespeichert werden: %v",
+ "saved favorite %q": "Favorit %q gespeichert",
+ "could not open favorite %q: %v": "Favorit %q konnte nicht geöffnet werden: %v",
+ "No favorites yet": "Noch keine Favoriten",
+ "Remove": "Entfernen",
+ "Manage Favorites": "Favoriten verwalten",
+ "Close": "Schließen",
+ "Remove Favorite": "Favorit entfernen",
+ "Remove %q from favorites? Its folder will be moved to the Trash.": "%q aus den Favoriten entfernen? Der Ordner wird in den Papierkorb verschoben.",
+ "could not remove favorite %q: %v": "Favorit %q konnte nicht entfernt werden: %v",
+ "removed favorite %q": "Favorit %q entfernt"
}
diff --git a/translations/en.json b/translations/en.json
index fe67193..d6d09b0 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -92,5 +92,27 @@
"Search: %s": "Search: %s",
"%d of %d": "%d of %d",
"No file names match": "No file names match",
- "%d selected": "%d selected"
+ "%d selected": "%d selected",
+ "Favorites": "Favorites",
+ "Add Current List to Favorites…": "Add Current List to Favorites…",
+ "Manage Favorites…": "Manage Favorites…",
+ "could not list favorites: %v": "could not list favorites: %v",
+ "enter a name without / \\ : * ? \" < > |": "enter a name without / \\ : * ? \" < > |",
+ "Add to Favorites": "Add to Favorites",
+ "Add": "Add",
+ "Replace Favorite": "Replace Favorite",
+ "A favorite named %q already exists. Replace it?": "A favorite named %q already exists. Replace it?",
+ "Replace": "Replace",
+ "there are no open files to add to favorites": "there are no open files to add to favorites",
+ "could not save favorite %q: %v": "could not save favorite %q: %v",
+ "saved favorite %q": "saved favorite %q",
+ "could not open favorite %q: %v": "could not open favorite %q: %v",
+ "No favorites yet": "No favorites yet",
+ "Remove": "Remove",
+ "Manage Favorites": "Manage Favorites",
+ "Close": "Close",
+ "Remove Favorite": "Remove Favorite",
+ "Remove %q from favorites? Its folder will be moved to the Trash.": "Remove %q from favorites? Its folder will be moved to the Trash.",
+ "could not remove favorite %q: %v": "could not remove favorite %q: %v",
+ "removed favorite %q": "removed favorite %q"
}