diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-17 14:45:55 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-17 14:45:55 +0300 |
| commit | 094447b570c5c5a7c751e0e60279cfa08e945755 (patch) | |
| tree | 0d4b0a486e5bd48cf6e81f2faa2b9160be0ee0fc /internal | |
| parent | 81dabe63bbd5c90819dff5219c0d81880b3bdc8a (diff) | |
feat: add manual translation editing and bidirectional translation
- Replace translation label with editable entry field
- Move translation field next to Bulgarian input for better UX
- Add bidirectional translation support:
- Bulgarian → English: auto-translate when only Bulgarian provided
- English → Bulgarian: auto-translate when only English provided
- No translation when both fields are filled
- Show translations immediately upon generation
- Auto-save translations when edited
- Keep input fields populated during processing
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/gui/app.go | 192 | ||||
| -rw-r--r-- | internal/gui/generator.go | 33 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 4 | ||||
| -rw-r--r-- | internal/gui/queue.go | 21 |
4 files changed, 189 insertions, 61 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index 37c7b9d..a586d73 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "fyne.io/fyne/v2" @@ -31,7 +32,7 @@ type Application struct { submitButton *widget.Button imageDisplay *ImageDisplay audioPlayer *AudioPlayer - translationText *widget.Label + translationEntry *widget.Entry progressBar *widget.ProgressBar statusLabel *widget.Label queueStatusLabel *widget.Label @@ -150,25 +151,39 @@ func (a *Application) setupUI() { // Create input section with navigation a.wordInput = widget.NewEntry() - a.wordInput.SetPlaceHolder("Enter Bulgarian word...") + a.wordInput.SetPlaceHolder("Bulgarian word...") a.wordInput.OnSubmitted = func(string) { a.onSubmit() } + // Create translation entry + a.translationEntry = widget.NewEntry() + a.translationEntry.SetPlaceHolder("English translation...") + a.translationEntry.OnChanged = func(text string) { + a.currentTranslation = text + // Save the updated translation immediately + a.saveTranslation() + } + a.translationEntry.OnSubmitted = func(string) { a.onSubmit() } + a.submitButton = widget.NewButton("Generate (g)", a.onSubmit) a.prevWordBtn = widget.NewButton("◀ Prev (←)", a.onPrevWord) a.nextWordBtn = widget.NewButton("Next (→) ▶", a.onNextWord) + // Create a grid layout for inputs + inputGrid := container.New(layout.NewGridLayout(2), + a.wordInput, + a.translationEntry, + ) + inputSection := container.NewBorder( nil, nil, a.prevWordBtn, container.NewHBox(a.submitButton, a.nextWordBtn), - a.wordInput, + inputGrid, ) // Create display section a.imageDisplay = NewImageDisplay() a.audioPlayer = NewAudioPlayer() - a.translationText = widget.NewLabel("") - a.translationText.Alignment = fyne.TextAlignCenter // Create image prompt entry a.imagePromptEntry = widget.NewMultiLineEntry() @@ -192,7 +207,7 @@ func (a *Application) setupUI() { imageSection.SetOffset(0.5) // Equal 50/50 split displaySection := container.NewBorder( - a.translationText, + nil, a.audioPlayer, nil, nil, imageSection, @@ -279,13 +294,66 @@ func (a *Application) Run() { // onSubmit handles word submission func (a *Application) onSubmit() { - word := a.wordInput.Text - if word == "" { + bulgarianText := strings.TrimSpace(a.wordInput.Text) + englishText := strings.TrimSpace(a.translationEntry.Text) + + // Determine which word to process and if translation is needed + var wordToProcess string + var needsTranslation bool + var translationDirection string + + if bulgarianText != "" && englishText != "" { + // Both provided - use Bulgarian as primary, no translation needed + wordToProcess = bulgarianText + needsTranslation = false + a.currentTranslation = englishText + } else if bulgarianText != "" && englishText == "" { + // Only Bulgarian provided - translate to English + wordToProcess = bulgarianText + needsTranslation = true + translationDirection = "bg-to-en" + } else if bulgarianText == "" && englishText != "" { + // Only English provided - translate to Bulgarian + needsTranslation = true + translationDirection = "en-to-bg" + // We'll get the Bulgarian word after translation + } else { + // Both empty return } + // Handle English to Bulgarian translation first if needed + if translationDirection == "en-to-bg" { + a.updateStatus(fmt.Sprintf("Translating '%s' to Bulgarian...", englishText)) + bulgarian, err := a.translateEnglishToBulgarian(englishText) + if err != nil { + dialog.ShowError(fmt.Errorf("Translation failed: %w", err), a.window) + return + } + wordToProcess = bulgarian + a.wordInput.SetText(bulgarian) + a.currentTranslation = englishText + // Update current word for saving + a.currentWord = bulgarian + // Save the translation immediately + a.saveTranslation() + } else if translationDirection == "bg-to-en" { + // Handle Bulgarian to English translation immediately + a.updateStatus(fmt.Sprintf("Translating '%s' to English...", bulgarianText)) + english, err := a.translateWord(bulgarianText) + if err != nil { + dialog.ShowError(fmt.Errorf("Translation failed: %w", err), a.window) + return + } + a.currentTranslation = english + a.translationEntry.SetText(english) + needsTranslation = false // We've already done the translation + // Save the translation immediately + a.saveTranslation() + } + // Validate Bulgarian text - if err := audio.ValidateBulgarianText(word); err != nil { + if err := audio.ValidateBulgarianText(wordToProcess); err != nil { dialog.ShowError(err, a.window) return } @@ -294,13 +362,19 @@ func (a *Application) onSubmit() { customPrompt := a.imagePromptEntry.Text // Add word to processing queue with custom prompt - job := a.queue.AddWordWithPrompt(word, customPrompt) + job := a.queue.AddWordWithPrompt(wordToProcess, customPrompt) - // Clear the input field for next word - a.wordInput.SetText("") + // Store whether translation is needed and the translation if already provided + job.NeedsTranslation = needsTranslation + if a.currentTranslation != "" { + job.Translation = a.currentTranslation + } + + // Don't clear the input fields yet - they should stay populated + // until the user is ready to enter a new word // Update status to show word was queued - a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", word, job.ID)) + a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", wordToProcess, job.ID)) // Update queue status immediately a.updateQueueStatus() @@ -311,22 +385,25 @@ func (a *Application) onSubmit() { // generateMaterials generates all materials for a word (used by regenerate functions) func (a *Application) generateMaterials(word string) { - // Translate word - fyne.Do(func() { - a.updateStatus("Translating...") - }) - translation, err := a.translateWord(word) - if err != nil { + // Check if we already have a translation + if a.currentTranslation == "" { + // Translate word fyne.Do(func() { - a.showError(fmt.Errorf("Translation failed: %w", err)) - a.setUIEnabled(true) + 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.translationEntry.SetText(translation) }) - return } - a.currentTranslation = translation - fyne.Do(func() { - a.translationText.SetText(fmt.Sprintf("%s = %s", word, translation)) - }) // Generate audio fyne.Do(func() { @@ -401,12 +478,7 @@ func (a *Application) onKeepAndContinue() { 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) - } + a.saveTranslation() // Rescan existing words to include the new one a.scanExistingWords() @@ -425,9 +497,10 @@ func (a *Application) onKeepAndContinue() { a.updateStatus("Previous word continues processing in background") } - // Clear UI for next word + // Clear UI and input fields for next word a.clearUI() a.wordInput.SetText("") + a.translationEntry.SetText("") a.wordInput.FocusGained() // Focus input for next word // Hide progress bar if it was showing @@ -635,7 +708,7 @@ func (a *Application) showError(err error) { func (a *Application) clearUI() { a.imageDisplay.Clear() a.audioPlayer.Clear() - a.translationText.SetText("") + // Don't clear the word input or translation entry - they should stay populated a.imagePromptEntry.SetText("") a.setActionButtonsEnabled(false) } @@ -676,24 +749,33 @@ func (a *Application) processNextInQueue() { // processWordJob processes a single word job func (a *Application) processWordJob(job *WordJob) { - // Translate word - fyne.Do(func() { - a.updateStatus(fmt.Sprintf("Translating '%s'...", job.Word)) - }) + // Handle translation + var translation string + var err error - translation, err := a.translateWord(job.Word) - if err != nil { - a.queue.FailJob(job.ID, fmt.Errorf("translation failed: %w", err)) - a.finishCurrentJob() - return + if job.NeedsTranslation { + // Translate word + fyne.Do(func() { + a.updateStatus(fmt.Sprintf("Translating '%s'...", job.Word)) + }) + + translation, err = a.translateWord(job.Word) + if err != nil { + a.queue.FailJob(job.ID, fmt.Errorf("translation failed: %w", err)) + a.finishCurrentJob() + return + } + } else if job.Translation != "" { + // Use provided translation + translation = job.Translation } // Update UI with translation immediately if this is still the current job a.mu.Lock() - if a.currentJobID == job.ID { + if a.currentJobID == job.ID && translation != "" { a.currentTranslation = translation fyne.Do(func() { - a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, translation)) + a.translationEntry.SetText(translation) }) } a.mu.Unlock() @@ -761,7 +843,7 @@ func (a *Application) processWordJob(job *WordJob) { } fyne.Do(func() { - a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, translation)) + a.translationEntry.SetText(translation) if imageFile != "" { a.imageDisplay.SetImages([]string{imageFile}) } @@ -828,7 +910,7 @@ func (a *Application) onJobComplete(job *WordJob) { // Update each component individually to show progress if job.Translation != "" && a.currentTranslation == "" { a.currentTranslation = job.Translation - a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, job.Translation)) + a.translationEntry.SetText(job.Translation) } if job.AudioFile != "" && a.currentAudioFile == "" { a.currentAudioFile = job.AudioFile @@ -910,8 +992,9 @@ func (a *Application) decrementProcessing() { 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 { + // Don't process shortcuts if any input field is focused + focused := a.window.Canvas().Focused() + if focused == a.wordInput || focused == a.imagePromptEntry || focused == a.translationEntry { return } @@ -980,3 +1063,14 @@ func (a *Application) setupKeyboardShortcuts() { }) } + +// saveTranslation saves the current translation to a file +func (a *Application) saveTranslation() { + if a.currentWord != "" && 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) + } +} + diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 86b7013..0d30f79 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -48,6 +48,39 @@ func (a *Application) translateWord(word string) (string, error) { return translation, nil } +// translateEnglishToBulgarian translates an English word to Bulgarian +func (a *Application) translateEnglishToBulgarian(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 English word '%s' to Bulgarian. Respond with only the Bulgarian translation in Cyrillic script, 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 diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index bc65c8f..59c656e 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -179,7 +179,7 @@ func (a *Application) loadWordByIndex(index int) { fyne.Do(func() { if job.Translation != "" { - a.translationText.SetText(fmt.Sprintf("%s = %s", word, job.Translation)) + a.translationEntry.SetText(job.Translation) } if job.AudioFile != "" { a.audioPlayer.SetAudioFile(job.AudioFile) @@ -220,7 +220,7 @@ func (a *Application) loadExistingFiles(word string) { if len(parts) >= 2 { a.currentTranslation = strings.TrimSpace(parts[1]) fyne.Do(func() { - a.translationText.SetText(fmt.Sprintf("%s = %s", word, a.currentTranslation)) + a.translationEntry.SetText(a.currentTranslation) }) } } diff --git a/internal/gui/queue.go b/internal/gui/queue.go index df39908..f72b059 100644 --- a/internal/gui/queue.go +++ b/internal/gui/queue.go @@ -9,16 +9,17 @@ import ( // WordJob represents a single word processing job type WordJob struct { - ID int - Word string - Translation string - AudioFile string - ImageFile string // Changed from ImageFiles []string to single image - Status JobStatus - Error error - StartedAt time.Time - CompletedAt time.Time - CustomPrompt string // Custom prompt for image generation + ID int + Word string + Translation string + AudioFile string + ImageFile string // Changed from ImageFiles []string to single image + Status JobStatus + Error error + StartedAt time.Time + CompletedAt time.Time + CustomPrompt string // Custom prompt for image generation + NeedsTranslation bool // Whether translation is needed } // JobStatus represents the current state of a job |
