From 915e435679a5539a75acaffe1324dc45f0f76364 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 17 Jul 2025 21:43:52 +0300 Subject: fix: prevent UI element mix-ups during concurrent processing and rapid navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed one-way translation bug: English to Bulgarian no longer triggers reverse translation - Prevent mix-ups when rapidly entering new words while previous words are processing - Ensure all UI elements update only for their associated job: * Bulgarian/English text fields protected by job ID checks * Phonetic info saved to disk immediately and UI updates restricted * Audio file associations protected from background job interference * Image prompts saved immediately and UI updates controlled - Add mutex protection for thread-safe UI updates - Disconnect from background jobs when user starts typing new input - Save all generated data to disk immediately, not just on UI update - Improve navigation to properly load all associated data from disk This ensures correct flashcard generation even with rapid word entry and navigation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/gui/app.go | 172 +++++++++++++++++++++++++++++++-------------- internal/gui/generator.go | 20 ++++-- internal/gui/navigation.go | 9 +++ 3 files changed, 144 insertions(+), 57 deletions(-) (limited to 'internal/gui') diff --git a/internal/gui/app.go b/internal/gui/app.go index 45eec63..50c9746 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -155,11 +155,28 @@ func (a *Application) setupUI() { a.wordInput = widget.NewEntry() a.wordInput.SetPlaceHolder("Bulgarian word...") a.wordInput.OnSubmitted = func(string) { a.onSubmit() } + a.wordInput.OnChanged = func(text string) { + // When user starts typing a new word, disconnect from any previous job + // to prevent mix-ups with background processing + a.mu.Lock() + if a.currentJobID != 0 && text != a.currentWord { + a.currentJobID = 0 + } + a.mu.Unlock() + } // Create translation entry a.translationEntry = widget.NewEntry() a.translationEntry.SetPlaceHolder("English translation...") a.translationEntry.OnChanged = func(text string) { + // When user starts typing in translation field, disconnect from any previous job + // to prevent mix-ups with background processing + a.mu.Lock() + if a.currentJobID != 0 && a.currentTranslation != text { + a.currentJobID = 0 + } + a.mu.Unlock() + a.currentTranslation = text // Save the updated translation immediately a.saveTranslation() @@ -364,6 +381,7 @@ func (a *Application) onSubmit() { a.currentWord = bulgarian // Save the translation immediately a.saveTranslation() + needsTranslation = false // We've already done the translation, don't translate back } else if translationDirection == "bg-to-en" { // Handle Bulgarian to English translation immediately a.updateStatus(fmt.Sprintf("Translating '%s' to English...", bulgarianText)) @@ -426,10 +444,23 @@ func (a *Application) generateMaterials(word string) { }) return } - a.currentTranslation = translation - fyne.Do(func() { - a.translationEntry.SetText(translation) - }) + // Only update if this word is still the current word + a.mu.Lock() + if a.currentWord == word { + a.currentTranslation = translation + fyne.Do(func() { + a.translationEntry.SetText(translation) + }) + } + a.mu.Unlock() + + // Save translation to disk regardless + if translation != "" { + filename := sanitizeFilename(word) + translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename)) + content := fmt.Sprintf("%s = %s\n", word, translation) + os.WriteFile(translationFile, []byte(content), 0644) + } } // Generate audio @@ -447,10 +478,16 @@ func (a *Application) generateMaterials(word string) { }) return } - a.currentAudioFile = audioFile - fyne.Do(func() { - a.audioPlayer.SetAudioFile(audioFile) - }) + + // Only update UI if this word is still the current word + a.mu.Lock() + if a.currentWord == word { + a.currentAudioFile = audioFile + fyne.Do(func() { + a.audioPlayer.SetAudioFile(audioFile) + }) + } + a.mu.Unlock() // Generate images with custom prompt if provided fyne.Do(func() { @@ -477,11 +514,17 @@ func (a *Application) generateMaterials(word string) { }) return } + + // Only update UI if this word is still the current word if imageFile != "" { - a.currentImage = imageFile - fyne.Do(func() { - a.imageDisplay.SetImages([]string{imageFile}) - }) + a.mu.Lock() + if a.currentWord == word { + a.currentImage = imageFile + fyne.Do(func() { + a.imageDisplay.SetImages([]string{imageFile}) + }) + } + a.mu.Unlock() } // Enable action buttons @@ -536,6 +579,15 @@ func (a *Application) onKeepAndContinue() { a.clearUI() a.wordInput.SetText("") a.translationEntry.SetText("") + + // Clear current state to prevent mix-ups with background jobs + a.mu.Lock() + a.currentWord = "" + a.currentTranslation = "" + a.currentAudioFile = "" + a.currentImage = "" + a.mu.Unlock() + a.wordInput.FocusGained() // Focus input for next word // Hide progress bar if it was showing @@ -748,6 +800,7 @@ func (a *Application) clearUI() { a.imageDisplay.Clear() a.audioPlayer.Clear() // Don't clear the word input or translation entry - they should stay populated + // Clear the image prompt entry - it will be loaded from disk if available a.imagePromptEntry.SetText("") a.phoneticDisplay.SetText("Phonetic information will appear here...") a.setActionButtonsEnabled(false) @@ -766,10 +819,14 @@ func (a *Application) processNextInQueue() { return } - // Set current job + // Set current job and clear any previous state a.mu.Lock() a.currentJobID = job.ID a.currentWord = job.Word + // Clear previous file associations to prevent mix-ups + a.currentTranslation = "" + a.currentAudioFile = "" + a.currentImage = "" a.mu.Unlock() // Clear UI for new word @@ -810,6 +867,14 @@ func (a *Application) processWordJob(job *WordJob) { translation = job.Translation } + // Save translation to disk immediately for this specific word + if translation != "" { + filename := sanitizeFilename(job.Word) + translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename)) + content := fmt.Sprintf("%s = %s\n", job.Word, translation) + os.WriteFile(translationFile, []byte(content), 0644) + } + // Update UI with translation immediately if this is still the current job a.mu.Lock() if a.currentJobID == job.ID && translation != "" { @@ -836,13 +901,18 @@ func (a *Application) processWordJob(job *WordJob) { phoneticInfo = "Failed to fetch phonetic information" } + // Save phonetic info to disk immediately for this specific word + if phoneticInfo != "" && phoneticInfo != "Failed to fetch phonetic information" { + filename := sanitizeFilename(job.Word) + phoneticFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_phonetic.txt", filename)) + os.WriteFile(phoneticFile, []byte(phoneticInfo), 0644) + } + // Update UI with phonetic info if this is still the current job a.mu.Lock() if a.currentJobID == job.ID { fyne.Do(func() { a.phoneticDisplay.SetText(phoneticInfo) - // Save phonetic info to file - a.savePhoneticInfo() }) } a.mu.Unlock() @@ -867,15 +937,19 @@ func (a *Application) processWordJob(job *WordJob) { // Update UI with audio immediately if this is still the current job a.mu.Lock() - if a.currentJobID == job.ID { + isCurrentJob := a.currentJobID == job.ID + if isCurrentJob { a.currentAudioFile = audioFile + } + a.mu.Unlock() + + if isCurrentJob { fyne.Do(func() { a.audioPlayer.SetAudioFile(audioFile) // Enable audio-related actions a.regenerateAudioBtn.Enable() }) } - a.mu.Unlock() // Generate images fyne.Do(func() { @@ -906,13 +980,17 @@ func (a *Application) processWordJob(job *WordJob) { // Update UI with results if this is still the current job a.mu.Lock() - if a.currentJobID == job.ID { + isCurrentJob = a.currentJobID == job.ID + if isCurrentJob { a.currentTranslation = translation a.currentAudioFile = audioFile if imageFile != "" { a.currentImage = imageFile } - + } + a.mu.Unlock() + + if isCurrentJob { fyne.Do(func() { a.translationEntry.SetText(translation) if imageFile != "" { @@ -924,7 +1002,6 @@ func (a *Application) processWordJob(job *WordJob) { a.updateStatus(fmt.Sprintf("Completed: %s", job.Word)) }) } - a.mu.Unlock() // Finish this job a.finishCurrentJob() @@ -970,39 +1047,17 @@ func (a *Application) onJobComplete(job *WordJob) { if job.Status == StatusCompleted { a.updateNavigation() - // Check if the completed job is for the currently displayed word - // Only update UI if the current word is still empty (waiting for this job) - if job.Word == a.currentWord && job.ID != a.currentJobID { - // Check if the UI is still empty/waiting for content - hasContent := a.currentAudioFile != "" || a.currentImage != "" - - if !hasContent { - // Update the UI with the completed results since it's still waiting - // Update each component individually to show progress - if job.Translation != "" && a.currentTranslation == "" { - a.currentTranslation = job.Translation - a.translationEntry.SetText(job.Translation) - } - if job.AudioFile != "" && a.currentAudioFile == "" { - a.currentAudioFile = job.AudioFile - a.audioPlayer.SetAudioFile(job.AudioFile) - a.regenerateAudioBtn.Enable() - } - if job.ImageFile != "" && a.currentImage == "" { - a.currentImage = job.ImageFile - a.imageDisplay.SetImages([]string{job.ImageFile}) - a.regenerateImageBtn.Enable() - } - - // Enable all action buttons since we now have complete content - a.setActionButtonsEnabled(true) - a.updateStatus(fmt.Sprintf("Processing completed: %s", job.Word)) - } else { - // Word already has content, just show notification - a.updateStatus(fmt.Sprintf("Background processing completed: %s", job.Word)) - } - } else if job.ID != a.currentJobID { - // Show a subtle notification for other background completions + // Only show status updates, don't update UI for background jobs + // This prevents mix-ups when user has moved on to a new word + a.mu.Lock() + isCurrentJob := job.ID == a.currentJobID + a.mu.Unlock() + + if isCurrentJob { + // This is still the current job, UI update is already handled in processWordJob + a.updateStatus(fmt.Sprintf("Processing completed: %s", job.Word)) + } else { + // This is a background job that completed a.updateStatus(fmt.Sprintf("Background processing completed: %s", job.Word)) } } @@ -1166,6 +1221,17 @@ func (a *Application) savePhoneticInfo() { } } +// savePhoneticInfoForWord saves the phonetic information for a specific word +func (a *Application) savePhoneticInfoForWord(word, phoneticText string) { + if word != "" && phoneticText != "" && + phoneticText != "Failed to fetch phonetic information" && + phoneticText != "Phonetic information will appear here..." { + filename := sanitizeFilename(word) + phoneticFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_phonetic.txt", filename)) + os.WriteFile(phoneticFile, []byte(phoneticText), 0644) + } +} + // loadPhoneticInfo loads phonetic information from a file if it exists func (a *Application) loadPhoneticInfo(word string) { filename := sanitizeFilename(word) diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 6d51359..d26bb81 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -176,14 +176,26 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, return "", err } - // If using OpenAI, get the last used prompt and update the UI + // If using OpenAI, get the last used prompt if a.config.ImageProvider == "openai" { if openaiClient, ok := searcher.(*image.OpenAIClient); ok { usedPrompt := openaiClient.GetLastPrompt() if usedPrompt != "" { - fyne.Do(func() { - a.imagePromptEntry.SetText(usedPrompt) - }) + // Save the prompt to disk immediately for this word + filename := sanitizeFilename(word) + promptFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_prompt.txt", filename)) + os.WriteFile(promptFile, []byte(usedPrompt), 0644) + + // Only update UI if this word is still the current word + a.mu.Lock() + isCurrentWord := a.currentWord == word + a.mu.Unlock() + + if isCurrentWord { + fyne.Do(func() { + a.imagePromptEntry.SetText(usedPrompt) + }) + } } } } diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index 62a326e..1466466 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -189,6 +189,15 @@ func (a *Application) loadWordByIndex(index int) { } // Load phonetic info from disk if it exists a.loadPhoneticInfo(word) + + // Load image prompt from disk if it exists + sanitized := sanitizeFilename(word) + promptFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_prompt.txt", sanitized)) + if data, err := os.ReadFile(promptFile); err == nil { + prompt := strings.TrimSpace(string(data)) + a.imagePromptEntry.SetText(prompt) + } + a.updateStatus(fmt.Sprintf("Loaded from queue: %s", word)) }) -- cgit v1.2.3