summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-16 13:13:38 +0300
committerPaul Buetow <paul@buetow.org>2025-07-16 13:13:38 +0300
commit7187e7464f16a9d2991ba2da3c672fdb3cf5de72 (patch)
tree208d8e301dc55512a078f836f4f0c9ad2a927427
parentb105333c061ea165b3b79317415cbb8b9cfb7c75 (diff)
feat: add Fyne GUI mode with interactive flashcard management
- Add --gui flag to launch interactive GUI mode - Implement word navigation with prev/next buttons through existing cards - Add delete functionality to remove unwanted flashcards - Add fine-grained regeneration (image-only, audio-only, or both) - Implement audio playback using mpg123 on Linux - Auto-load first word on startup if cards exist - Save translation files for navigation persistence - Use DALL-E 2 with 512x512 images (half size) - Update audio speed to 0.9 (from 0.8) - Add comprehensive GUI documentation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--GUI.md71
-rw-r--r--TODO.md22
-rw-r--r--cmd/totalrecall/main.go32
-rw-r--r--go.mod32
-rw-r--r--go.sum72
-rw-r--r--internal/audio/provider.go2
-rw-r--r--internal/gui/app.go539
-rw-r--r--internal/gui/audio_player.go168
-rw-r--r--internal/gui/generator.go198
-rw-r--r--internal/gui/navigation.go268
-rw-r--r--internal/gui/widgets.go122
11 files changed, 1515 insertions, 11 deletions
diff --git a/GUI.md b/GUI.md
new file mode 100644
index 0000000..17b66cc
--- /dev/null
+++ b/GUI.md
@@ -0,0 +1,71 @@
+# GUI Mode for TotalRecall
+
+TotalRecall now includes an interactive GUI mode for a more user-friendly flashcard generation experience.
+
+## Prerequisites
+
+The GUI mode requires Fyne, which has the following system dependencies:
+
+### Linux
+```bash
+# Debian/Ubuntu
+sudo apt-get install gcc libgl1-mesa-dev xorg-dev
+
+# Fedora
+sudo dnf install gcc mesa-libGL-devel libXcursor-devel libXrandr-devel libXinerama-devel libXi-devel libXxf86vm-devel
+```
+
+### macOS
+No additional dependencies required (uses system frameworks).
+
+### Windows
+No additional dependencies required if using MinGW or similar.
+
+## Running GUI Mode
+
+```bash
+./totalrecall --gui
+```
+
+## Features
+
+The GUI provides:
+
+1. **Interactive Input**: Enter Bulgarian words one at a time
+2. **Live Preview**: See generated images and hear audio pronunciation
+3. **Fine-grained Regeneration**:
+ - Regenerate just the image (cycles through different results)
+ - Regenerate just the audio (uses a different voice)
+ - Regenerate both
+4. **Session Management**: Keep track of all generated cards in a session
+5. **Export to Anki**: Export all saved cards to CSV format
+
+## GUI Layout
+
+- **Top Section**: Input field for Bulgarian words with submit button
+- **Middle Section**:
+ - Image display with navigation (if multiple images)
+ - Audio player with play controls
+ - Translation display
+- **Bottom Section**: Action buttons
+ - "Keep & Continue" - saves the current card
+ - "Regenerate Image" - gets a new image
+ - "Regenerate Audio" - generates with a different voice
+ - "Regenerate All" - regenerates everything
+
+## Building from Source
+
+If you're building from source and encounter issues with the GUI:
+
+1. Ensure you have the system dependencies installed (see Prerequisites)
+2. The build might take longer the first time as it compiles Fyne
+3. If the build times out, try building without the GUI first:
+ ```bash
+ go build -tags nogui ./cmd/totalrecall
+ ```
+
+## Troubleshooting
+
+- **Build fails**: Check that you have the required system dependencies
+- **GUI doesn't start**: Ensure you're running in a graphical environment
+- **Audio doesn't play**: The current implementation shows audio controls but actual playback requires additional audio libraries \ No newline at end of file
diff --git a/TODO.md b/TODO.md
index 9b41e0a..d89b2bb 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,2 +1,24 @@
# TODO's
+## Completed
+- [x] Added Fyne GUI mode with `--gui` flag
+- [x] Interactive word input with Bulgarian validation
+- [x] Live preview of generated images and audio
+- [x] Fine-grained regeneration (image-only, audio-only, or both)
+- [x] Session management with "Keep & Continue" functionality
+- [x] Export to Anki CSV from GUI session
+- [x] Progress indicators and status updates
+
+## GUI Enhancements (Future)
+- [ ] Implement actual audio playback (currently shows controls only)
+- [ ] Add preferences dialog for GUI settings
+- [ ] Add drag & drop support for batch word lists
+- [ ] Add recent words history
+- [ ] Add dark/light theme toggle
+- [ ] Keyboard shortcuts (Enter to submit, Space to play audio)
+- [ ] Save/restore GUI window size and position
+
+## Known Limitations
+- Audio playback requires additional audio library integration (e.g., github.com/hajimehoshi/oto)
+- First build with GUI may be slow due to Fyne compilation
+
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go
index 658301d..89d0ad9 100644
--- a/cmd/totalrecall/main.go
+++ b/cmd/totalrecall/main.go
@@ -17,6 +17,7 @@ import (
"codeberg.org/snonux/totalrecall/internal"
"codeberg.org/snonux/totalrecall/internal/anki"
"codeberg.org/snonux/totalrecall/internal/audio"
+ "codeberg.org/snonux/totalrecall/internal/gui"
"codeberg.org/snonux/totalrecall/internal/image"
)
@@ -34,6 +35,7 @@ var (
generateAnki bool
listModels bool
allVoices bool
+ guiMode bool
// Audio provider flags removed - now only OpenAI
// OpenAI flags
openAIModel string
@@ -84,13 +86,14 @@ func init() {
rootCmd.Flags().BoolVar(&generateAnki, "anki", false, "Generate Anki import CSV file")
rootCmd.Flags().BoolVar(&listModels, "list-models", false, "List available OpenAI models for the current API key")
rootCmd.Flags().BoolVar(&allVoices, "all-voices", false, "Generate audio in all available voices (creates multiple files)")
+ rootCmd.Flags().BoolVar(&guiMode, "gui", false, "Launch interactive GUI mode")
// Audio provider removed - now only OpenAI
// OpenAI flags
rootCmd.Flags().StringVar(&openAIModel, "openai-model", "gpt-4o-mini-tts", "OpenAI TTS model: tts-1, tts-1-hd, gpt-4o-mini-tts")
rootCmd.Flags().StringVar(&openAIVoice, "openai-voice", "", "OpenAI voice: alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse (default: random)")
- rootCmd.Flags().Float64Var(&openAISpeed, "openai-speed", 0.8, "OpenAI speech speed (0.25 to 4.0, may be ignored by gpt-4o-mini-tts)")
+ rootCmd.Flags().Float64Var(&openAISpeed, "openai-speed", 0.9, "OpenAI speech speed (0.25 to 4.0, may be ignored by gpt-4o-mini-tts)")
rootCmd.Flags().StringVar(&openAIInstruction, "openai-instruction", "", "Voice instructions for gpt-4o-mini-tts model (e.g., 'speak slowly with a Bulgarian accent')")
// OpenAI Image Generation flags
@@ -151,6 +154,11 @@ func runCommand(cmd *cobra.Command, args []string) error {
return listAvailableModels()
}
+ // Handle --gui flag
+ if guiMode {
+ return runGUIMode()
+ }
+
// Determine words to process
var words []string
@@ -309,7 +317,7 @@ func generateAudioWithVoice(word, voice string) error {
if openAIVoice == "nova" && viper.IsSet("audio.openai_voice") {
providerConfig.OpenAIVoice = viper.GetString("audio.openai_voice")
}
- if openAISpeed == 0.8 && viper.IsSet("audio.openai_speed") {
+ if openAISpeed == 0.9 && viper.IsSet("audio.openai_speed") {
providerConfig.OpenAISpeed = viper.GetFloat64("audio.openai_speed")
}
if openAIInstruction == "" && viper.IsSet("audio.openai_instruction") {
@@ -712,6 +720,26 @@ func saveAudioAttribution(word, audioFile string, config *audio.Config) error {
return nil
}
+func runGUIMode() error {
+ // Create GUI configuration from command line flags and viper config
+ guiConfig := &gui.Config{
+ OutputDir: outputDir,
+ AudioFormat: audioFormat,
+ ImageProvider: imageAPI,
+ ImagesPerWord: imagesPerWord,
+ EnableCache: viper.GetBool("cache.enable"),
+ OpenAIKey: getOpenAIKey(),
+ PixabayKey: viper.GetString("image.pixabay_key"),
+ UnsplashKey: viper.GetString("image.unsplash_key"),
+ }
+
+ // Create and run GUI application
+ app := gui.New(guiConfig)
+ app.Run()
+
+ return nil
+}
+
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
diff --git a/go.mod b/go.mod
index 185ca8f..f507d7a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,25 +3,53 @@ module codeberg.org/snonux/totalrecall
go 1.24.4
require (
+ fyne.io/fyne/v2 v2.6.1
github.com/sashabaranov/go-openai v1.40.5
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
)
require (
+ fyne.io/systray v1.11.0 // indirect
+ github.com/BurntSushi/toml v1.4.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
+ github.com/fyne-io/gl-js v0.1.0 // indirect
+ github.com/fyne-io/glfw-js v0.2.0 // indirect
+ github.com/fyne-io/image v0.1.1 // indirect
+ github.com/fyne-io/oksvg v0.1.0 // indirect
+ github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
+ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
+ github.com/go-text/render v0.2.0 // indirect
+ github.com/go-text/typesetting v0.2.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/hack-pad/go-indexeddb v0.3.2 // indirect
+ github.com/hack-pad/safejs v0.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
+ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
+ github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rymdport/portal v0.4.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
+ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
+ github.com/stretchr/testify v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/yuin/goldmark v1.7.8 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
- golang.org/x/sys v0.29.0 // indirect
- golang.org/x/text v0.21.0 // indirect
+ golang.org/x/image v0.24.0 // indirect
+ golang.org/x/net v0.35.0 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 6f00a23..091aae2 100644
--- a/go.sum
+++ b/go.sum
@@ -1,28 +1,78 @@
+fyne.io/fyne/v2 v2.6.1 h1:kjPJD4/rBS9m2nHJp+npPSuaK79yj6ObMTuzR6VQ1Is=
+fyne.io/fyne/v2 v2.6.1/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
+fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
+fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
+github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
+github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
+github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
+github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
+github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
+github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
+github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
+github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
+github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
+github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
+github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
+github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
+github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
+github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
+github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
+github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
+github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
+github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
+github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
+github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
+github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
+github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
+github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
+github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
+github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
+github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
+github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sashabaranov/go-openai v1.40.5 h1:SwIlNdWflzR1Rxd1gv3pUg6pwPc6cQ2uMoHs8ai+/NY=
@@ -39,22 +89,32 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
+golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/audio/provider.go b/internal/audio/provider.go
index 3508121..fd47ef4 100644
--- a/internal/audio/provider.go
+++ b/internal/audio/provider.go
@@ -43,7 +43,7 @@ func DefaultProviderConfig() *Config {
OutputFormat: "mp3",
OpenAIModel: "gpt-4o-mini-tts", // New model with voice instructions support
OpenAIVoice: "nova",
- OpenAISpeed: 0.8, // Slightly slower for clarity (note: may be ignored by gpt-4o-mini-tts)
+ OpenAISpeed: 0.9, // Slightly slower for clarity (note: may be ignored by gpt-4o-mini-tts)
OpenAIInstruction: "You are speaking Bulgarian language (български език). Pronounce the Bulgarian text with authentic Bulgarian phonetics, not Russian. Speak slowly and clearly for language learners.",
EnableCache: true,
CacheDir: "./.audio_cache",
diff --git a/internal/gui/app.go b/internal/gui/app.go
new file mode 100644
index 0000000..73dcd37
--- /dev/null
+++ b/internal/gui/app.go
@@ -0,0 +1,539 @@
+package gui
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/app"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/widget"
+
+ "codeberg.org/snonux/totalrecall/internal/anki"
+ "codeberg.org/snonux/totalrecall/internal/audio"
+)
+
+// Application represents the main GUI application
+type Application struct {
+ // Fyne components
+ app fyne.App
+ window fyne.Window
+
+ // UI elements
+ wordInput *widget.Entry
+ submitButton *widget.Button
+ imageDisplay *ImageDisplay
+ audioPlayer *AudioPlayer
+ translationText *widget.Label
+ progressBar *widget.ProgressBar
+ statusLabel *widget.Label
+
+ // Navigation buttons
+ prevWordBtn *widget.Button
+ nextWordBtn *widget.Button
+
+ // Action buttons
+ keepButton *widget.Button
+ regenerateImageBtn *widget.Button
+ regenerateAudioBtn *widget.Button
+ regenerateAllBtn *widget.Button
+ deleteButton *widget.Button
+
+ // State management
+ currentWord string
+ currentAudioFile string
+ currentImages []string
+ currentTranslation string
+ savedCards []anki.Card
+ existingWords []string // Words already in anki_cards folder
+ currentWordIndex int
+
+ // Configuration
+ config *Config
+ audioConfig *audio.Config
+
+ // Background processing
+ ctx context.Context
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+ mu sync.Mutex
+}
+
+// Config holds GUI application configuration
+type Config struct {
+ OutputDir string
+ AudioFormat string
+ ImageProvider string
+ ImagesPerWord int
+ EnableCache bool
+ OpenAIKey string
+ PixabayKey string
+ UnsplashKey string
+}
+
+// DefaultConfig returns default GUI configuration
+func DefaultConfig() *Config {
+ return &Config{
+ OutputDir: "./anki_cards",
+ AudioFormat: "mp3",
+ ImageProvider: "openai",
+ ImagesPerWord: 1,
+ EnableCache: true,
+ }
+}
+
+// New creates a new GUI application
+func New(config *Config) *Application {
+ if config == nil {
+ config = DefaultConfig()
+ }
+
+ // Ensure output directory exists
+ os.MkdirAll(config.OutputDir, 0755)
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ app := &Application{
+ app: app.New(),
+ config: config,
+ ctx: ctx,
+ cancel: cancel,
+ savedCards: make([]anki.Card, 0),
+ }
+
+ // Set up audio configuration
+ app.audioConfig = &audio.Config{
+ Provider: "openai",
+ OutputDir: config.OutputDir,
+ OutputFormat: config.AudioFormat,
+ OpenAIKey: config.OpenAIKey,
+ OpenAIModel: "gpt-4o-mini-tts",
+ OpenAIVoice: "nova",
+ OpenAISpeed: 0.9,
+ OpenAIInstruction: "You are speaking Bulgarian language (български език). Pronounce the Bulgarian text with authentic Bulgarian phonetics, not Russian. Speak slowly and clearly for language learners.",
+ EnableCache: config.EnableCache,
+ CacheDir: "./.audio_cache",
+ }
+
+ app.setupUI()
+
+ // Scan existing words in output directory
+ app.scanExistingWords()
+
+ return app
+}
+
+// setupUI creates the main user interface
+func (a *Application) setupUI() {
+ a.window = a.app.NewWindow("TotalRecall - Bulgarian Flashcard Generator")
+ a.window.Resize(fyne.NewSize(800, 600))
+
+ // Create input section with navigation
+ a.wordInput = widget.NewEntry()
+ a.wordInput.SetPlaceHolder("Enter Bulgarian word...")
+ a.wordInput.OnSubmitted = func(string) { a.onSubmit() }
+
+ a.submitButton = widget.NewButton("Generate", a.onSubmit)
+ a.prevWordBtn = widget.NewButton("◀ Prev", a.onPrevWord)
+ a.nextWordBtn = widget.NewButton("Next ▶", a.onNextWord)
+
+ inputSection := container.NewBorder(
+ nil, nil,
+ a.prevWordBtn,
+ container.NewHBox(a.submitButton, a.nextWordBtn),
+ a.wordInput,
+ )
+
+ // Create display section
+ a.imageDisplay = NewImageDisplay()
+ a.audioPlayer = NewAudioPlayer()
+ a.translationText = widget.NewLabel("")
+ a.translationText.Alignment = fyne.TextAlignCenter
+
+ displaySection := container.NewBorder(
+ a.translationText,
+ a.audioPlayer,
+ nil, nil,
+ a.imageDisplay,
+ )
+
+ // Create action buttons
+ a.keepButton = widget.NewButton("Keep & Continue", a.onKeepAndContinue)
+ a.regenerateImageBtn = widget.NewButton("Regenerate Image", a.onRegenerateImage)
+ a.regenerateAudioBtn = widget.NewButton("Regenerate Audio", a.onRegenerateAudio)
+ a.regenerateAllBtn = widget.NewButton("Regenerate All", a.onRegenerateAll)
+ a.deleteButton = widget.NewButton("Delete", a.onDelete)
+ a.deleteButton.Importance = widget.DangerImportance
+
+ // Initially disable action buttons
+ a.setActionButtonsEnabled(false)
+
+ actionSection := container.NewHBox(
+ a.keepButton,
+ layout.NewSpacer(),
+ a.deleteButton,
+ widget.NewSeparator(),
+ a.regenerateImageBtn,
+ a.regenerateAudioBtn,
+ a.regenerateAllBtn,
+ )
+
+ // Create status section
+ a.progressBar = widget.NewProgressBar()
+ a.progressBar.Hide()
+ a.statusLabel = widget.NewLabel("Ready")
+
+ statusSection := container.NewBorder(
+ nil, nil, nil, nil,
+ container.NewVBox(
+ a.progressBar,
+ a.statusLabel,
+ ),
+ )
+
+ // Create menu
+ fileMenu := fyne.NewMenu("File",
+ fyne.NewMenuItem("Export to Anki...", a.onExportToAnki),
+ fyne.NewMenuItemSeparator(),
+ fyne.NewMenuItem("Preferences...", a.onPreferences),
+ fyne.NewMenuItemSeparator(),
+ fyne.NewMenuItem("Quit", a.app.Quit),
+ )
+
+ mainMenu := fyne.NewMainMenu(fileMenu)
+ a.window.SetMainMenu(mainMenu)
+
+ // Combine all sections
+ content := container.NewBorder(
+ inputSection,
+ container.NewVBox(
+ widget.NewSeparator(),
+ actionSection,
+ widget.NewSeparator(),
+ statusSection,
+ ),
+ nil, nil,
+ displaySection,
+ )
+
+ a.window.SetContent(content)
+ a.window.SetOnClosed(func() {
+ a.cancel()
+ a.wg.Wait()
+ })
+}
+
+// Run starts the GUI application
+func (a *Application) Run() {
+ a.window.ShowAndRun()
+}
+
+// onSubmit handles word submission
+func (a *Application) onSubmit() {
+ word := a.wordInput.Text
+ if word == "" {
+ return
+ }
+
+ // Validate Bulgarian text
+ if err := audio.ValidateBulgarianText(word); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+
+ // Clear previous content
+ a.clearUI()
+
+ a.currentWord = word
+ a.setUIEnabled(false)
+ a.showProgress("Generating materials for: " + word)
+
+ // Generate in background
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+ a.generateMaterials(word)
+ }()
+}
+
+// generateMaterials generates all materials for a word
+func (a *Application) generateMaterials(word string) {
+ // Translate word
+ fyne.Do(func() {
+ a.updateStatus("Translating...")
+ })
+ translation, err := a.translateWord(word)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Translation failed: %w", err))
+ a.setUIEnabled(true)
+ })
+ return
+ }
+ a.currentTranslation = translation
+ fyne.Do(func() {
+ a.translationText.SetText(fmt.Sprintf("%s = %s", word, translation))
+ })
+
+ // Generate audio
+ fyne.Do(func() {
+ a.updateStatus("Generating audio...")
+ })
+ audioFile, err := a.generateAudio(word)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Audio generation failed: %w", err))
+ a.setUIEnabled(true)
+ })
+ return
+ }
+ a.currentAudioFile = audioFile
+ fyne.Do(func() {
+ a.audioPlayer.SetAudioFile(audioFile)
+ })
+
+ // Generate images
+ fyne.Do(func() {
+ a.updateStatus("Downloading images...")
+ })
+ images, err := a.generateImages(word)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Image download failed: %w", err))
+ a.setUIEnabled(true)
+ })
+ return
+ }
+ a.currentImages = images
+ fyne.Do(func() {
+ a.imageDisplay.SetImages(images)
+ })
+
+ // Enable action buttons
+ fyne.Do(func() {
+ a.hideProgress()
+ a.updateStatus("Ready - Review and decide")
+ a.setUIEnabled(true)
+ a.setActionButtonsEnabled(true)
+ })
+}
+
+// onKeepAndContinue saves the current card and clears for next
+func (a *Application) onKeepAndContinue() {
+ // Save current card
+ card := anki.Card{
+ Bulgarian: a.currentWord,
+ AudioFile: a.currentAudioFile,
+ ImageFiles: a.currentImages,
+ Translation: a.currentTranslation,
+ }
+
+ a.mu.Lock()
+ a.savedCards = append(a.savedCards, card)
+ count := len(a.savedCards)
+ a.mu.Unlock()
+
+ // Save translation file for future navigation
+ if a.currentTranslation != "" {
+ filename := sanitizeFilename(a.currentWord)
+ translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename))
+ content := fmt.Sprintf("%s = %s\n", a.currentWord, a.currentTranslation)
+ os.WriteFile(translationFile, []byte(content), 0644)
+ }
+
+ // Rescan existing words to include the new one
+ a.scanExistingWords()
+
+ // Clear UI for next word
+ a.clearUI()
+ a.updateStatus(fmt.Sprintf("Card saved! Total cards: %d", count))
+ a.wordInput.SetText("")
+ a.wordInput.FocusGained() // Focus input for next word
+}
+
+// onRegenerateImage regenerates only the image
+func (a *Application) onRegenerateImage() {
+ a.setActionButtonsEnabled(false)
+ a.showProgress("Regenerating image...")
+
+ // Clear the current image immediately
+ a.imageDisplay.Clear()
+
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+
+ images, err := a.generateImages(a.currentWord)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Image regeneration failed: %w", err))
+ })
+ } else {
+ a.currentImages = images
+ fyne.Do(func() {
+ a.imageDisplay.SetImages(images)
+ })
+ }
+
+ fyne.Do(func() {
+ a.hideProgress()
+ a.setActionButtonsEnabled(true)
+ })
+ }()
+}
+
+// onRegenerateAudio regenerates audio with a different voice
+func (a *Application) onRegenerateAudio() {
+ a.setActionButtonsEnabled(false)
+ a.showProgress("Regenerating audio...")
+
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+
+ audioFile, err := a.generateAudio(a.currentWord)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Audio regeneration failed: %w", err))
+ })
+ } else {
+ a.currentAudioFile = audioFile
+ fyne.Do(func() {
+ a.audioPlayer.SetAudioFile(audioFile)
+ })
+ }
+
+ fyne.Do(func() {
+ a.hideProgress()
+ a.setActionButtonsEnabled(true)
+ })
+ }()
+}
+
+// onRegenerateAll regenerates both audio and images
+func (a *Application) onRegenerateAll() {
+ a.setUIEnabled(false)
+ a.showProgress("Regenerating all materials...")
+
+ // Clear the current image immediately
+ a.imageDisplay.Clear()
+
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+ a.generateMaterials(a.currentWord)
+ }()
+}
+
+// onExportToAnki exports saved cards to Anki CSV
+func (a *Application) onExportToAnki() {
+ if len(a.savedCards) == 0 {
+ dialog.ShowInformation("No Cards", "No cards to export. Generate some cards first!", a.window)
+ return
+ }
+
+ // Create save dialog
+ saveDialog := dialog.NewFileSave(func(writer fyne.URIWriteCloser, err error) {
+ if err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ if writer == nil {
+ return
+ }
+ defer writer.Close()
+
+ // Generate Anki CSV
+ outputPath := writer.URI().Path()
+ gen := anki.NewGenerator(&anki.GeneratorOptions{
+ OutputPath: outputPath,
+ MediaFolder: a.config.OutputDir,
+ IncludeHeaders: true,
+ AudioFormat: a.config.AudioFormat,
+ })
+
+ // Add all saved cards
+ for _, card := range a.savedCards {
+ gen.AddCard(card)
+ }
+
+ // Generate CSV
+ if err := gen.GenerateCSV(); err != nil {
+ dialog.ShowError(fmt.Errorf("Failed to generate CSV: %w", err), a.window)
+ return
+ }
+
+ dialog.ShowInformation("Export Complete",
+ fmt.Sprintf("Exported %d cards to:\n%s", len(a.savedCards), outputPath),
+ a.window)
+ }, a.window)
+
+ saveDialog.SetFileName("anki_import.csv")
+ saveDialog.SetFilter(storage.NewExtensionFileFilter([]string{".csv"}))
+ saveDialog.Show()
+}
+
+// onPreferences shows the preferences dialog
+func (a *Application) onPreferences() {
+ // This will be implemented in preferences.go
+ dialog.ShowInformation("Preferences", "Preferences dialog coming soon!", a.window)
+}
+
+// Helper methods
+func (a *Application) setUIEnabled(enabled bool) {
+ if enabled {
+ a.wordInput.Enable()
+ a.submitButton.Enable()
+ } else {
+ a.wordInput.Disable()
+ a.submitButton.Disable()
+ }
+}
+
+func (a *Application) setActionButtonsEnabled(enabled bool) {
+ if enabled {
+ a.keepButton.Enable()
+ a.regenerateImageBtn.Enable()
+ a.regenerateAudioBtn.Enable()
+ a.regenerateAllBtn.Enable()
+ a.deleteButton.Enable()
+ } else {
+ a.keepButton.Disable()
+ a.regenerateImageBtn.Disable()
+ a.regenerateAudioBtn.Disable()
+ a.regenerateAllBtn.Disable()
+ a.deleteButton.Disable()
+ }
+}
+
+func (a *Application) showProgress(message string) {
+ a.progressBar.Show()
+ a.progressBar.SetValue(0.5) // Indeterminate progress
+ a.statusLabel.SetText(message)
+}
+
+func (a *Application) hideProgress() {
+ a.progressBar.Hide()
+}
+
+func (a *Application) updateStatus(message string) {
+ a.statusLabel.SetText(message)
+}
+
+func (a *Application) showError(err error) {
+ dialog.ShowError(err, a.window)
+ a.updateStatus("Error: " + err.Error())
+}
+
+func (a *Application) clearUI() {
+ a.imageDisplay.Clear()
+ a.audioPlayer.Clear()
+ a.translationText.SetText("")
+ a.setActionButtonsEnabled(false)
+} \ No newline at end of file
diff --git a/internal/gui/audio_player.go b/internal/gui/audio_player.go
new file mode 100644
index 0000000..161c635
--- /dev/null
+++ b/internal/gui/audio_player.go
@@ -0,0 +1,168 @@
+package gui
+
+import (
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/widget"
+)
+
+// AudioPlayer is a custom widget for playing audio files
+type AudioPlayer struct {
+ widget.BaseWidget
+
+ container *fyne.Container
+ playButton *widget.Button
+ stopButton *widget.Button
+ statusLabel *widget.Label
+
+ audioFile string
+ isPlaying bool
+ playCmd *exec.Cmd
+}
+
+// NewAudioPlayer creates a new audio player widget
+func NewAudioPlayer() *AudioPlayer {
+ p := &AudioPlayer{}
+
+ // Create controls
+ p.playButton = widget.NewButton("▶ Play", p.onPlay)
+ p.stopButton = widget.NewButton("■ Stop", p.onStop)
+ p.statusLabel = widget.NewLabel("No audio loaded")
+
+ // Initially disable controls
+ p.playButton.Disable()
+ p.stopButton.Disable()
+
+ // Create container
+ p.container = container.NewHBox(
+ p.playButton,
+ p.stopButton,
+ layout.NewSpacer(),
+ p.statusLabel,
+ )
+
+ p.ExtendBaseWidget(p)
+ return p
+}
+
+// CreateRenderer implements fyne.Widget
+func (p *AudioPlayer) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(p.container)
+}
+
+// SetAudioFile sets the audio file to play
+func (p *AudioPlayer) SetAudioFile(audioFile string) {
+ p.audioFile = audioFile
+ p.isPlaying = false
+
+ if audioFile != "" {
+ p.playButton.Enable()
+ p.statusLabel.SetText(fmt.Sprintf("Audio: %s", filepath.Base(audioFile)))
+ } else {
+ p.Clear()
+ }
+}
+
+// Clear clears the audio player
+func (p *AudioPlayer) Clear() {
+ p.onStop() // Stop any playing audio
+ p.audioFile = ""
+ p.isPlaying = false
+ p.playButton.Disable()
+ p.stopButton.Disable()
+ p.statusLabel.SetText("No audio loaded")
+}
+
+// onPlay handles play button click
+func (p *AudioPlayer) onPlay() {
+ if p.audioFile == "" {
+ return
+ }
+
+ if p.isPlaying {
+ // Pause functionality - just stop for now
+ p.onStop()
+ return
+ }
+
+ // Start playing
+ if err := p.startPlayback(); err != nil {
+ p.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
+ return
+ }
+
+ p.isPlaying = true
+ p.playButton.SetText("⏸ Pause")
+ p.stopButton.Enable()
+ p.statusLabel.SetText("Playing: " + filepath.Base(p.audioFile))
+}
+
+// onStop handles stop button click
+func (p *AudioPlayer) onStop() {
+ if p.playCmd != nil && p.playCmd.Process != nil {
+ p.playCmd.Process.Kill()
+ p.playCmd = nil
+ }
+
+ p.isPlaying = false
+ p.playButton.SetText("▶ Play")
+ p.stopButton.Disable()
+ p.statusLabel.SetText("Stopped: " + filepath.Base(p.audioFile))
+}
+
+// startPlayback starts audio playback using platform-specific commands
+func (p *AudioPlayer) startPlayback() error {
+ var cmd *exec.Cmd
+
+ switch runtime.GOOS {
+ case "darwin": // macOS
+ cmd = exec.Command("afplay", p.audioFile)
+ case "linux":
+ // Try multiple commands in order of preference
+ // mpg123 first since it handles MP3 files best
+ if _, err := exec.LookPath("mpg123"); err == nil {
+ cmd = exec.Command("mpg123", "-q", p.audioFile) // -q for quiet mode
+ } else if _, err := exec.LookPath("ffplay"); err == nil {
+ cmd = exec.Command("ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", p.audioFile)
+ } else if _, err := exec.LookPath("play"); err == nil {
+ // SoX play command
+ cmd = exec.Command("play", "-q", p.audioFile)
+ } else if _, err := exec.LookPath("paplay"); err == nil {
+ cmd = exec.Command("paplay", p.audioFile)
+ } else if _, err := exec.LookPath("aplay"); err == nil {
+ cmd = exec.Command("aplay", "-q", p.audioFile)
+ } else {
+ return fmt.Errorf("no audio player found. Install mpg123, ffplay, sox, paplay, or aplay")
+ }
+ case "windows":
+ // Use Windows Media Player
+ cmd = exec.Command("cmd", "/c", "start", "/min", p.audioFile)
+ default:
+ return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
+ }
+
+ // Store the command so we can stop it later
+ p.playCmd = cmd
+
+ // Start playback in background
+ go func() {
+ err := cmd.Run()
+ if err == nil {
+ // Playback finished normally
+ fyne.Do(func() {
+ p.isPlaying = false
+ p.playButton.SetText("▶ Play")
+ p.stopButton.Disable()
+ p.statusLabel.SetText("Finished: " + filepath.Base(p.audioFile))
+ })
+ }
+ }()
+
+ return nil
+} \ No newline at end of file
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
new file mode 100644
index 0000000..7656bcd
--- /dev/null
+++ b/internal/gui/generator.go
@@ -0,0 +1,198 @@
+package gui
+
+import (
+ "fmt"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/sashabaranov/go-openai"
+
+ "codeberg.org/snonux/totalrecall/internal/audio"
+ "codeberg.org/snonux/totalrecall/internal/image"
+)
+
+// translateWord translates a Bulgarian word to English
+func (a *Application) translateWord(word string) (string, error) {
+ if a.config.OpenAIKey == "" {
+ return "", fmt.Errorf("OpenAI API key not configured")
+ }
+
+ client := openai.NewClient(a.config.OpenAIKey)
+
+ req := openai.ChatCompletionRequest{
+ Model: openai.GPT4oMini,
+ Messages: []openai.ChatCompletionMessage{
+ {
+ Role: openai.ChatMessageRoleUser,
+ Content: fmt.Sprintf("Translate the Bulgarian word '%s' to English. Respond with only the English translation, nothing else.", word),
+ },
+ },
+ MaxTokens: 50,
+ Temperature: 0.3,
+ }
+
+ resp, err := client.CreateChatCompletion(a.ctx, req)
+ if err != nil {
+ return "", fmt.Errorf("OpenAI API error: %w", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ return "", fmt.Errorf("no translation returned")
+ }
+
+ translation := strings.TrimSpace(resp.Choices[0].Message.Content)
+ return translation, nil
+}
+
+// generateAudio generates audio for a word
+func (a *Application) generateAudio(word string) (string, error) {
+ // Get available voices
+ allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"}
+
+ // Select a random voice
+ rand.Seed(time.Now().UnixNano())
+ voice := allVoices[rand.Intn(len(allVoices))]
+
+ // Update audio config with random voice
+ a.audioConfig.OpenAIVoice = voice
+
+ // Create audio provider
+ provider, err := audio.NewProvider(a.audioConfig)
+ if err != nil {
+ return "", err
+ }
+
+ // Generate filename
+ filename := sanitizeFilename(word)
+ outputFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s.%s", filename, a.config.AudioFormat))
+
+ // Generate audio
+ err = provider.GenerateAudio(a.ctx, word, outputFile)
+ if err != nil {
+ return "", err
+ }
+
+ // Save audio attribution
+ if err := a.saveAudioAttribution(word, outputFile, voice); err != nil {
+ // Non-fatal error, just log it
+ fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
+ }
+
+ return outputFile, nil
+}
+
+// generateImages downloads images for a word
+func (a *Application) generateImages(word string) ([]string, error) {
+ // Create image searcher based on provider
+ var searcher image.ImageSearcher
+ var err error
+
+ switch a.config.ImageProvider {
+ case "pixabay":
+ searcher = image.NewPixabayClient(a.config.PixabayKey)
+
+ case "unsplash":
+ if a.config.UnsplashKey == "" {
+ return nil, fmt.Errorf("Unsplash API key is required")
+ }
+ searcher, err = image.NewUnsplashClient(a.config.UnsplashKey)
+ if err != nil {
+ return nil, err
+ }
+
+ case "openai":
+ openaiConfig := &image.OpenAIConfig{
+ APIKey: a.config.OpenAIKey,
+ Model: "dall-e-2", // DALL-E 2 supports 512x512
+ Size: "512x512", // Half of 1024x1024
+ Quality: "standard",
+ Style: "natural",
+ CacheDir: "./.image_cache",
+ EnableCache: a.config.EnableCache,
+ }
+
+ searcher = image.NewOpenAIClient(openaiConfig)
+ if openaiConfig.APIKey == "" {
+ // Fall back to Pixabay
+ searcher = image.NewPixabayClient("")
+ }
+
+ default:
+ return nil, fmt.Errorf("unknown image provider: %s", a.config.ImageProvider)
+ }
+
+ // Create downloader
+ downloadOpts := &image.DownloadOptions{
+ OutputDir: a.config.OutputDir,
+ OverwriteExisting: true,
+ CreateDir: true,
+ FileNamePattern: "{word}_{index}",
+ MaxSizeBytes: 5 * 1024 * 1024, // 5MB
+ }
+
+ downloader := image.NewDownloader(searcher, downloadOpts)
+
+ // Download images
+ var paths []string
+
+ if a.config.ImagesPerWord == 1 {
+ _, path, err := downloader.DownloadBestMatch(a.ctx, word)
+ if err != nil {
+ return nil, err
+ }
+ paths = []string{path}
+ } else {
+ paths, err = downloader.DownloadMultiple(a.ctx, word, a.config.ImagesPerWord)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return paths, nil
+}
+
+// saveAudioAttribution saves attribution info for generated audio
+func (a *Application) saveAudioAttribution(word, audioFile, voice string) error {
+ attribution := fmt.Sprintf("Audio generated by OpenAI TTS\n\n")
+ attribution += fmt.Sprintf("Bulgarian word: %s\n", word)
+ attribution += fmt.Sprintf("Model: %s\n", a.audioConfig.OpenAIModel)
+ attribution += fmt.Sprintf("Voice: %s\n", voice)
+ attribution += fmt.Sprintf("Speed: %.2f\n", a.audioConfig.OpenAISpeed)
+
+ if a.audioConfig.OpenAIInstruction != "" {
+ attribution += fmt.Sprintf("\nVoice instructions:\n%s\n", a.audioConfig.OpenAIInstruction)
+ }
+
+ attribution += fmt.Sprintf("\nGenerated at: %s\n", time.Now().Format("2006-01-02 15:04:05"))
+
+ // Save to file
+ attrPath := strings.TrimSuffix(audioFile, filepath.Ext(audioFile)) + "_attribution.txt"
+ if err := os.WriteFile(attrPath, []byte(attribution), 0644); err != nil {
+ return fmt.Errorf("failed to write audio attribution file: %w", err)
+ }
+
+ return nil
+}
+
+// sanitizeFilename creates a safe filename from a string
+func sanitizeFilename(s string) string {
+ result := ""
+ for _, r := range s {
+ if isAlphaNumeric(r) || r == '-' || r == '_' {
+ result += string(r)
+ } else {
+ result += "_"
+ }
+ }
+ return result
+}
+
+// isAlphaNumeric checks if a rune is alphanumeric
+func isAlphaNumeric(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
+ (r >= '0' && r <= '9') || (r >= 'а' && r <= 'я') ||
+ (r >= 'А' && r <= 'Я')
+} \ No newline at end of file
diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go
new file mode 100644
index 0000000..f8931ba
--- /dev/null
+++ b/internal/gui/navigation.go
@@ -0,0 +1,268 @@
+package gui
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/dialog"
+)
+
+// scanExistingWords scans the output directory for existing words
+func (a *Application) scanExistingWords() {
+ a.existingWords = []string{}
+
+ // Read directory
+ entries, err := os.ReadDir(a.config.OutputDir)
+ if err != nil {
+ // Directory doesn't exist yet, that's OK
+ return
+ }
+
+ // Collect unique words
+ wordMap := make(map[string]bool)
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ name := entry.Name()
+ // Skip attribution and translation files
+ if strings.Contains(name, "_attribution") || strings.Contains(name, "_translation") {
+ continue
+ }
+
+ // Extract word from filename (before first underscore or dot)
+ base := strings.TrimSuffix(name, filepath.Ext(name))
+ parts := strings.Split(base, "_")
+ if len(parts) > 0 {
+ word := parts[0]
+ wordMap[word] = true
+ }
+ }
+
+ // Convert map to sorted slice
+ for word := range wordMap {
+ a.existingWords = append(a.existingWords, word)
+ }
+ sort.Strings(a.existingWords)
+
+ // Update navigation buttons
+ a.updateNavigation()
+
+ // Load first word if available and nothing is loaded yet
+ if len(a.existingWords) > 0 && a.currentWord == "" {
+ a.loadWordByIndex(0)
+ }
+}
+
+// updateNavigation updates the navigation button states
+func (a *Application) updateNavigation() {
+ if len(a.existingWords) > 0 {
+ a.prevWordBtn.Enable()
+ a.nextWordBtn.Enable()
+
+ // Find current word index
+ a.currentWordIndex = -1
+ for i, word := range a.existingWords {
+ if word == a.currentWord {
+ a.currentWordIndex = i
+ break
+ }
+ }
+
+ // Disable at boundaries
+ if a.currentWordIndex <= 0 {
+ a.prevWordBtn.Disable()
+ }
+ if a.currentWordIndex >= len(a.existingWords)-1 || a.currentWordIndex == -1 {
+ a.nextWordBtn.Disable()
+ }
+ } else {
+ a.prevWordBtn.Disable()
+ a.nextWordBtn.Disable()
+ }
+}
+
+// onPrevWord loads the previous word
+func (a *Application) onPrevWord() {
+ if a.currentWordIndex > 0 {
+ a.loadWordByIndex(a.currentWordIndex - 1)
+ }
+}
+
+// onNextWord loads the next word
+func (a *Application) onNextWord() {
+ if a.currentWordIndex < len(a.existingWords)-1 && a.currentWordIndex >= 0 {
+ a.loadWordByIndex(a.currentWordIndex + 1)
+ }
+}
+
+// loadWordByIndex loads a word by its index in existingWords
+func (a *Application) loadWordByIndex(index int) {
+ if index < 0 || index >= len(a.existingWords) {
+ return
+ }
+
+ word := a.existingWords[index]
+ a.currentWord = word
+ a.currentWordIndex = index
+
+ // Update input field
+ a.wordInput.SetText(word)
+
+ // Clear UI
+ a.clearUI()
+
+ // Load existing files
+ a.loadExistingFiles(word)
+
+ // Update navigation
+ a.updateNavigation()
+
+ // Enable action buttons since we have loaded content
+ a.setActionButtonsEnabled(true)
+}
+
+// loadExistingFiles loads existing files for a word
+func (a *Application) loadExistingFiles(word string) {
+ sanitized := sanitizeFilename(word)
+
+ // Load translation
+ translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", sanitized))
+ if data, err := os.ReadFile(translationFile); err == nil {
+ // Parse translation from "word = translation" format
+ content := string(data)
+ parts := strings.Split(content, "=")
+ if len(parts) >= 2 {
+ a.currentTranslation = strings.TrimSpace(parts[1])
+ fyne.Do(func() {
+ a.translationText.SetText(fmt.Sprintf("%s = %s", word, a.currentTranslation))
+ })
+ }
+ }
+
+ // Load audio file
+ audioFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s.%s", sanitized, a.config.AudioFormat))
+ if _, err := os.Stat(audioFile); err == nil {
+ a.currentAudioFile = audioFile
+ fyne.Do(func() {
+ a.audioPlayer.SetAudioFile(audioFile)
+ })
+ }
+
+ // Load image files
+ a.currentImages = []string{}
+ // Try to find images with different patterns
+ patterns := []string{
+ fmt.Sprintf("%s.jpg", sanitized),
+ fmt.Sprintf("%s.png", sanitized),
+ fmt.Sprintf("%s_0.jpg", sanitized),
+ fmt.Sprintf("%s_0.png", sanitized),
+ fmt.Sprintf("%s_1.jpg", sanitized),
+ fmt.Sprintf("%s_1.png", sanitized),
+ }
+
+ for _, pattern := range patterns {
+ imagePath := filepath.Join(a.config.OutputDir, pattern)
+ if _, err := os.Stat(imagePath); err == nil {
+ a.currentImages = append(a.currentImages, imagePath)
+ break // Just load the first image found
+ }
+ }
+
+ if len(a.currentImages) > 0 {
+ fyne.Do(func() {
+ a.imageDisplay.SetImages(a.currentImages)
+ })
+ }
+
+ fyne.Do(func() {
+ a.updateStatus(fmt.Sprintf("Loaded: %s", word))
+ })
+}
+
+// onDelete deletes the current word's files
+func (a *Application) onDelete() {
+ if a.currentWord == "" {
+ return
+ }
+
+ // Confirm deletion
+ dialog.ShowConfirm("Delete Word",
+ fmt.Sprintf("Delete all files for '%s'?", a.currentWord),
+ func(confirm bool) {
+ if confirm {
+ a.deleteCurrentWord()
+ }
+ }, a.window)
+}
+
+// deleteCurrentWord deletes all files for the current word
+func (a *Application) deleteCurrentWord() {
+ sanitized := sanitizeFilename(a.currentWord)
+ deletedCount := 0
+
+ // List of possible files to delete
+ patterns := []string{
+ fmt.Sprintf("%s.mp3", sanitized),
+ fmt.Sprintf("%s.wav", sanitized),
+ fmt.Sprintf("%s.jpg", sanitized),
+ fmt.Sprintf("%s.png", sanitized),
+ fmt.Sprintf("%s.gif", sanitized),
+ fmt.Sprintf("%s_*.jpg", sanitized),
+ fmt.Sprintf("%s_*.png", sanitized),
+ fmt.Sprintf("%s_translation.txt", sanitized),
+ fmt.Sprintf("%s_attribution.txt", sanitized),
+ fmt.Sprintf("%s_*_attribution.txt", sanitized),
+ }
+
+ // Delete files matching patterns
+ for _, pattern := range patterns {
+ matches, err := filepath.Glob(filepath.Join(a.config.OutputDir, pattern))
+ if err != nil {
+ continue
+ }
+ for _, match := range matches {
+ if err := os.Remove(match); err == nil {
+ deletedCount++
+ }
+ }
+ }
+
+ // Remove from existingWords
+ newWords := []string{}
+ for _, w := range a.existingWords {
+ if w != a.currentWord {
+ newWords = append(newWords, w)
+ }
+ }
+ a.existingWords = newWords
+
+ // Clear UI
+ a.clearUI()
+
+ // Update status
+ fyne.Do(func() {
+ a.updateStatus(fmt.Sprintf("Deleted %d files for '%s'", deletedCount, a.currentWord))
+ })
+
+ // Clear current word
+ a.currentWord = ""
+ a.wordInput.SetText("")
+
+ // Try to load previous or next word
+ if a.currentWordIndex > 0 && a.currentWordIndex <= len(a.existingWords) {
+ a.loadWordByIndex(a.currentWordIndex - 1)
+ } else if len(a.existingWords) > 0 {
+ a.loadWordByIndex(0)
+ } else {
+ // No more words
+ a.updateNavigation()
+ a.setActionButtonsEnabled(false)
+ }
+} \ No newline at end of file
diff --git a/internal/gui/widgets.go b/internal/gui/widgets.go
new file mode 100644
index 0000000..eb9818c
--- /dev/null
+++ b/internal/gui/widgets.go
@@ -0,0 +1,122 @@
+package gui
+
+import (
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "os"
+ "path/filepath"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/widget"
+)
+
+// ImageDisplay is a custom widget for displaying images
+type ImageDisplay struct {
+ widget.BaseWidget
+
+ container *fyne.Container
+ imageCanvas *canvas.Image
+ imageLabel *widget.Label
+
+ currentImage string
+}
+
+// NewImageDisplay creates a new image display widget
+func NewImageDisplay() *ImageDisplay {
+ d := &ImageDisplay{}
+
+ // Create image canvas
+ d.imageCanvas = canvas.NewImageFromResource(nil)
+ d.imageCanvas.FillMode = canvas.ImageFillContain
+ d.imageCanvas.SetMinSize(fyne.NewSize(200, 150)) // Half the size
+
+ // Create label
+ d.imageLabel = widget.NewLabel("No image")
+ d.imageLabel.Alignment = fyne.TextAlignCenter
+
+ // Create main container - no navigation buttons here
+ d.container = container.NewBorder(
+ nil,
+ d.imageLabel,
+ nil, nil,
+ d.imageCanvas,
+ )
+
+ d.ExtendBaseWidget(d)
+ return d
+}
+
+// CreateRenderer implements fyne.Widget
+func (d *ImageDisplay) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(d.container)
+}
+
+// SetImage sets a single image to display
+func (d *ImageDisplay) SetImage(imagePath string) {
+ if imagePath == "" {
+ d.Clear()
+ return
+ }
+
+ d.currentImage = imagePath
+
+ // Load image from file
+ file, err := os.Open(imagePath)
+ if err != nil {
+ d.imageLabel.SetText(fmt.Sprintf("Error loading image: %v", err))
+ return
+ }
+ defer file.Close()
+
+ img, _, err := image.Decode(file)
+ if err != nil {
+ d.imageLabel.SetText(fmt.Sprintf("Error decoding image: %v", err))
+ return
+ }
+
+ // Update canvas
+ d.imageCanvas.Image = img
+ d.imageCanvas.Refresh()
+
+ // Update label
+ d.imageLabel.SetText(filepath.Base(imagePath))
+}
+
+// SetImages sets multiple images but only displays the first one
+func (d *ImageDisplay) SetImages(images []string) {
+ if len(images) > 0 {
+ d.SetImage(images[0])
+ } else {
+ d.Clear()
+ }
+}
+
+// Clear clears the display
+func (d *ImageDisplay) Clear() {
+ d.currentImage = ""
+ d.imageCanvas.Image = nil
+ d.imageCanvas.Refresh()
+ d.imageLabel.SetText("No image")
+}
+
+
+// ResourceFromPath creates a Fyne resource from a file path
+func ResourceFromPath(path string) (fyne.Resource, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ return fyne.NewStaticResource(filepath.Base(path), data), nil
+} \ No newline at end of file