diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-16 20:38:22 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-16 20:38:22 +0300 |
| commit | d46669426aa6b0ece71d0d05d0b6f2966686b17a (patch) | |
| tree | 9f927c3a8bc763943764ad63e3badafe8a9a7f62 /internal/gui | |
| parent | e49ecfe601c924fa68671477331a860acf8a62f7 (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.go | 142 | ||||
| -rw-r--r-- | internal/gui/audio_player.go | 15 | ||||
| -rw-r--r-- | internal/gui/generator.go | 28 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 61 | ||||
| -rw-r--r-- | internal/gui/queue.go | 31 |
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 |
