summaryrefslogtreecommitdiff
path: root/internal/gui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-16 20:38:22 +0300
committerPaul Buetow <paul@buetow.org>2025-07-16 20:38:22 +0300
commitd46669426aa6b0ece71d0d05d0b6f2966686b17a (patch)
tree9f927c3a8bc763943764ad63e3badafe8a9a7f62 /internal/gui
parente49ecfe601c924fa68671477331a860acf8a62f7 (diff)
feat: add custom image prompt support and keyboard shortcuts
- Add text area next to image display for custom image generation prompts - Users can specify their own prompts or leave empty for auto-generation - Display the used prompt in the text area after generation - Load prompts from attribution files when navigating to existing cards - Add keyboard shortcuts for all GUI buttons: - G: Generate, N: New Word, I: Regenerate Image, A: Regenerate Audio - R: Regenerate All, D: Delete, P: Play audio - Left/Right arrows: Navigate between words - Y/N: Confirm/cancel delete dialog - Update UI layout with equal 50/50 split between image and prompt - Enable text wrapping in prompt text area - Add 25% chance to ask OpenAI for creative photo style suggestions - Fix concurrent processing to properly use custom prompts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal/gui')
-rw-r--r--internal/gui/app.go142
-rw-r--r--internal/gui/audio_player.go15
-rw-r--r--internal/gui/generator.go28
-rw-r--r--internal/gui/navigation.go61
-rw-r--r--internal/gui/queue.go31
5 files changed, 238 insertions, 39 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 28c703e..3783eb9 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -35,6 +35,7 @@ type Application struct {
progressBar *widget.ProgressBar
statusLabel *widget.Label
queueStatusLabel *widget.Label
+ imagePromptEntry *widget.Entry
// Navigation buttons
prevWordBtn *widget.Button
@@ -56,6 +57,7 @@ type Application struct {
savedCards []anki.Card
existingWords []string // Words already in anki_cards folder
currentWordIndex int
+ deleteConfirming bool // Track if we're in delete confirmation mode
// Word processing queue
queue *WordQueue
@@ -155,9 +157,9 @@ func (a *Application) setupUI() {
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)
+ a.submitButton = widget.NewButton("Generate (G)", a.onSubmit)
+ a.prevWordBtn = widget.NewButton("◀ Prev (←)", a.onPrevWord)
+ a.nextWordBtn = widget.NewButton("Next (→) ▶", a.onNextWord)
inputSection := container.NewBorder(
nil, nil,
@@ -172,19 +174,40 @@ func (a *Application) setupUI() {
a.translationText = widget.NewLabel("")
a.translationText.Alignment = fyne.TextAlignCenter
+ // Create image prompt entry
+ a.imagePromptEntry = widget.NewMultiLineEntry()
+ a.imagePromptEntry.SetPlaceHolder("Custom image prompt (optional)...")
+ a.imagePromptEntry.Wrapping = fyne.TextWrapWord // Enable word wrapping
+
+ // Create container for image and prompt with proper sizing
+ promptContainer := container.NewBorder(
+ widget.NewLabel("Image Prompt:"),
+ nil,
+ nil,
+ nil,
+ container.NewScroll(a.imagePromptEntry),
+ )
+
+ // Use a split container to give equal space to image and prompt
+ imageSection := container.NewHSplit(
+ a.imageDisplay,
+ promptContainer,
+ )
+ imageSection.SetOffset(0.5) // Equal 50/50 split
+
displaySection := container.NewBorder(
a.translationText,
a.audioPlayer,
nil, nil,
- a.imageDisplay,
+ imageSection,
)
// Create action buttons
- a.keepButton = widget.NewButton("New Word", 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.keepButton = widget.NewButton("New Word (N)", a.onKeepAndContinue)
+ a.regenerateImageBtn = widget.NewButton("Regenerate Image (I)", a.onRegenerateImage)
+ a.regenerateAudioBtn = widget.NewButton("Regenerate Audio (A)", a.onRegenerateAudio)
+ a.regenerateAllBtn = widget.NewButton("Regenerate All (R)", a.onRegenerateAll)
+ a.deleteButton = widget.NewButton("Delete (D)", a.onDelete)
a.deleteButton.Importance = widget.DangerImportance
// Initially disable action buttons
@@ -248,6 +271,9 @@ func (a *Application) setupUI() {
a.queue.Stop()
a.wg.Wait()
})
+
+ // Set up keyboard shortcuts
+ a.setupKeyboardShortcuts()
}
// Run starts the GUI application
@@ -268,8 +294,11 @@ func (a *Application) onSubmit() {
return
}
- // Add word to processing queue
- job := a.queue.AddWord(word)
+ // Get custom prompt from the UI
+ customPrompt := a.imagePromptEntry.Text
+
+ // Add word to processing queue with custom prompt
+ job := a.queue.AddWordWithPrompt(word, customPrompt)
// Clear the input field for next word
a.wordInput.SetText("")
@@ -323,12 +352,16 @@ func (a *Application) generateMaterials(word string) {
a.audioPlayer.SetAudioFile(audioFile)
})
- // Generate images
+ // Generate images with custom prompt if provided
fyne.Do(func() {
a.updateStatus("Downloading images...")
a.incrementProcessing() // Image processing starts
})
- images, err := a.generateImages(word)
+
+ // Get custom prompt from UI
+ customPrompt := a.imagePromptEntry.Text
+
+ images, err := a.generateImagesWithPrompt(word, customPrompt)
a.decrementProcessing() // Image processing ends
if err != nil {
@@ -414,6 +447,9 @@ func (a *Application) onRegenerateImage() {
// Clear the current image immediately
a.imageDisplay.Clear()
+ // Get custom prompt from UI
+ customPrompt := a.imagePromptEntry.Text
+
a.incrementProcessing() // Image processing starts
a.wg.Add(1)
@@ -421,7 +457,7 @@ func (a *Application) onRegenerateImage() {
defer a.wg.Done()
defer a.decrementProcessing() // Image processing ends
- images, err := a.generateImages(a.currentWord)
+ images, err := a.generateImagesWithPrompt(a.currentWord, customPrompt)
if err != nil {
fyne.Do(func() {
a.showError(fmt.Errorf("Image regeneration failed: %w", err))
@@ -591,6 +627,7 @@ func (a *Application) clearUI() {
a.imageDisplay.Clear()
a.audioPlayer.Clear()
a.translationText.SetText("")
+ a.imagePromptEntry.SetText("")
a.setActionButtonsEnabled(false)
}
@@ -687,7 +724,8 @@ func (a *Application) processWordJob(job *WordJob) {
a.incrementProcessing() // Image processing starts
})
- imageFiles, err := a.generateImages(job.Word)
+ // Use the custom prompt from the job
+ imageFiles, err := a.generateImagesWithPrompt(job.Word, job.CustomPrompt)
a.decrementProcessing() // Image processing ends
if err != nil {
@@ -855,3 +893,77 @@ func (a *Application) decrementProcessing() {
})
}
+// setupKeyboardShortcuts sets up keyboard shortcuts for the application
+func (a *Application) setupKeyboardShortcuts() {
+ // Create a custom shortcut handler
+ a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) {
+ // Don't process shortcuts if the word input is focused
+ if a.window.Canvas().Focused() == a.wordInput || a.window.Canvas().Focused() == a.imagePromptEntry {
+ return
+ }
+
+ // Don't process if we're in delete confirmation mode (handled by dialog)
+ if a.deleteConfirming {
+ return
+ }
+
+ switch ev.Name {
+ case fyne.KeyG: // Generate
+ if a.submitButton.Disabled() {
+ return
+ }
+ a.onSubmit()
+
+ case fyne.KeyN: // New Word
+ if a.keepButton.Disabled() {
+ return
+ }
+ a.onKeepAndContinue()
+
+ case fyne.KeyI: // Regenerate Image
+ if a.regenerateImageBtn.Disabled() {
+ return
+ }
+ a.onRegenerateImage()
+
+ case fyne.KeyA: // Regenerate Audio
+ if a.regenerateAudioBtn.Disabled() {
+ return
+ }
+ a.onRegenerateAudio()
+
+ case fyne.KeyR: // Regenerate All
+ if a.regenerateAllBtn.Disabled() {
+ return
+ }
+ a.onRegenerateAll()
+
+ case fyne.KeyD: // Delete
+ if a.deleteButton.Disabled() {
+ return
+ }
+ a.onDelete()
+
+ case fyne.KeyLeft: // Previous word
+ if a.prevWordBtn.Disabled() {
+ return
+ }
+ a.onPrevWord()
+
+ case fyne.KeyRight: // Next word
+ if a.nextWordBtn.Disabled() {
+ return
+ }
+ a.onNextWord()
+
+ case fyne.KeyP: // Play audio
+ if a.currentAudioFile != "" {
+ a.audioPlayer.Play()
+ }
+
+ case fyne.KeyEscape: // Cancel any operation
+ a.deleteConfirming = false
+ }
+ })
+}
+
diff --git a/internal/gui/audio_player.go b/internal/gui/audio_player.go
index 161c635..2d4b2da 100644
--- a/internal/gui/audio_player.go
+++ b/internal/gui/audio_player.go
@@ -31,7 +31,7 @@ func NewAudioPlayer() *AudioPlayer {
p := &AudioPlayer{}
// Create controls
- p.playButton = widget.NewButton("▶ Play", p.onPlay)
+ p.playButton = widget.NewButton("▶ Play (P)", p.onPlay)
p.stopButton = widget.NewButton("■ Stop", p.onStop)
p.statusLabel = widget.NewLabel("No audio loaded")
@@ -98,7 +98,7 @@ func (p *AudioPlayer) onPlay() {
}
p.isPlaying = true
- p.playButton.SetText("⏸ Pause")
+ p.playButton.SetText("⏸ Pause (P)")
p.stopButton.Enable()
p.statusLabel.SetText("Playing: " + filepath.Base(p.audioFile))
}
@@ -111,11 +111,18 @@ func (p *AudioPlayer) onStop() {
}
p.isPlaying = false
- p.playButton.SetText("▶ Play")
+ p.playButton.SetText("▶ Play (P)")
p.stopButton.Disable()
p.statusLabel.SetText("Stopped: " + filepath.Base(p.audioFile))
}
+// Play triggers audio playback
+func (p *AudioPlayer) Play() {
+ if !p.playButton.Disabled() {
+ p.onPlay()
+ }
+}
+
// startPlayback starts audio playback using platform-specific commands
func (p *AudioPlayer) startPlayback() error {
var cmd *exec.Cmd
@@ -157,7 +164,7 @@ func (p *AudioPlayer) startPlayback() error {
// Playback finished normally
fyne.Do(func() {
p.isPlaying = false
- p.playButton.SetText("▶ Play")
+ p.playButton.SetText("▶ Play (P)")
p.stopButton.Disable()
p.statusLabel.SetText("Finished: " + filepath.Base(p.audioFile))
})
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index 7656bcd..9738d88 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -8,6 +8,7 @@ import (
"strings"
"time"
+ "fyne.io/fyne/v2"
"github.com/sashabaranov/go-openai"
"codeberg.org/snonux/totalrecall/internal/audio"
@@ -86,6 +87,11 @@ func (a *Application) generateAudio(word string) (string, error) {
// generateImages downloads images for a word
func (a *Application) generateImages(word string) ([]string, error) {
+ return a.generateImagesWithPrompt(word, "")
+}
+
+// generateImagesWithPrompt downloads images for a word with optional custom prompt
+func (a *Application) generateImagesWithPrompt(word string, customPrompt string) ([]string, error) {
// Create image searcher based on provider
var searcher image.ImageSearcher
var err error
@@ -135,22 +141,40 @@ func (a *Application) generateImages(word string) ([]string, error) {
downloader := image.NewDownloader(searcher, downloadOpts)
+ // Create search options with custom prompt if provided
+ searchOpts := image.DefaultSearchOptions(word)
+ if customPrompt != "" {
+ searchOpts.CustomPrompt = customPrompt
+ }
+
// Download images
var paths []string
if a.config.ImagesPerWord == 1 {
- _, path, err := downloader.DownloadBestMatch(a.ctx, word)
+ _, path, err := downloader.DownloadBestMatchWithOptions(a.ctx, searchOpts)
if err != nil {
return nil, err
}
paths = []string{path}
} else {
- paths, err = downloader.DownloadMultiple(a.ctx, word, a.config.ImagesPerWord)
+ paths, err = downloader.DownloadMultipleWithOptions(a.ctx, searchOpts, a.config.ImagesPerWord)
if err != nil {
return nil, err
}
}
+ // If using OpenAI, get the last used prompt and update the UI
+ if a.config.ImageProvider == "openai" {
+ if openaiClient, ok := searcher.(*image.OpenAIClient); ok {
+ usedPrompt := openaiClient.GetLastPrompt()
+ if usedPrompt != "" {
+ fyne.Do(func() {
+ a.imagePromptEntry.SetText(usedPrompt)
+ })
+ }
+ }
+ }
+
return paths, nil
}
diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go
index f24dcc1..e59b817 100644
--- a/internal/gui/navigation.go
+++ b/internal/gui/navigation.go
@@ -258,6 +258,28 @@ func (a *Application) loadExistingFiles(word string) {
fyne.Do(func() {
a.imageDisplay.SetImages(a.currentImages)
})
+
+ // Try to load the prompt from attribution file if using OpenAI
+ if a.config.ImageProvider == "openai" && len(a.currentImages) > 0 {
+ // Look for attribution file
+ baseImagePath := a.currentImages[0]
+ attrPath := strings.TrimSuffix(baseImagePath, filepath.Ext(baseImagePath)) + "_attribution.txt"
+ if data, err := os.ReadFile(attrPath); err == nil {
+ // Parse prompt from attribution file
+ content := string(data)
+ lines := strings.Split(content, "\n")
+ for i, line := range lines {
+ if strings.HasPrefix(line, "Prompt used:") && i+1 < len(lines) {
+ // The prompt is on the next line
+ prompt := strings.TrimSpace(lines[i+1])
+ fyne.Do(func() {
+ a.imagePromptEntry.SetText(prompt)
+ })
+ break
+ }
+ }
+ }
+ }
}
fyne.Do(func() {
@@ -271,14 +293,41 @@ func (a *Application) onDelete() {
return
}
- // Confirm deletion
- dialog.ShowConfirm("Delete Word",
- fmt.Sprintf("Delete all files for '%s'?", a.currentWord),
- func(confirm bool) {
- if confirm {
+ // Create custom confirmation dialog with keyboard support
+ message := fmt.Sprintf("Delete all files for '%s'?\n\nPress Y to confirm or N to cancel", a.currentWord)
+ confirmDialog := dialog.NewConfirm("Delete Word", message, func(confirm bool) {
+ a.deleteConfirming = false
+ if confirm {
+ a.deleteCurrentWord()
+ }
+ }, a.window)
+
+ // Set up keyboard handler for the dialog
+ a.deleteConfirming = true
+
+ // Create a custom key handler for the dialog window
+ oldKeyHandler := a.window.Canvas().OnTypedKey()
+ a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) {
+ if a.deleteConfirming {
+ switch ev.Name {
+ case fyne.KeyY:
+ confirmDialog.Hide()
+ a.deleteConfirming = false
a.deleteCurrentWord()
+ // Restore original key handler
+ a.window.Canvas().SetOnTypedKey(oldKeyHandler)
+ case fyne.KeyN, fyne.KeyEscape:
+ confirmDialog.Hide()
+ a.deleteConfirming = false
+ // Restore original key handler
+ a.window.Canvas().SetOnTypedKey(oldKeyHandler)
}
- }, a.window)
+ } else if oldKeyHandler != nil {
+ oldKeyHandler(ev)
+ }
+ })
+
+ confirmDialog.Show()
}
// deleteCurrentWord deletes all files for the current word
diff --git a/internal/gui/queue.go b/internal/gui/queue.go
index 7b1c5de..aaa0c55 100644
--- a/internal/gui/queue.go
+++ b/internal/gui/queue.go
@@ -9,15 +9,16 @@ import (
// WordJob represents a single word processing job
type WordJob struct {
- ID int
- Word string
- Translation string
- AudioFile string
- ImageFiles []string
- Status JobStatus
- Error error
- StartedAt time.Time
- CompletedAt time.Time
+ ID int
+ Word string
+ Translation string
+ AudioFile string
+ ImageFiles []string
+ Status JobStatus
+ Error error
+ StartedAt time.Time
+ CompletedAt time.Time
+ CustomPrompt string // Custom prompt for image generation
}
// JobStatus represents the current state of a job
@@ -93,11 +94,17 @@ func (q *WordQueue) SetCallbacks(onStatusUpdate func(*WordJob), onJobComplete fu
// AddWord adds a word to the processing queue
func (q *WordQueue) AddWord(word string) *WordJob {
+ return q.AddWordWithPrompt(word, "")
+}
+
+// AddWordWithPrompt adds a word to the processing queue with a custom prompt
+func (q *WordQueue) AddWordWithPrompt(word, customPrompt string) *WordJob {
q.mu.Lock()
job := &WordJob{
- ID: q.nextID,
- Word: word,
- Status: StatusQueued,
+ ID: q.nextID,
+ Word: word,
+ Status: StatusQueued,
+ CustomPrompt: customPrompt,
}
q.nextID++
q.results[job.ID] = job