diff options
Diffstat (limited to 'internal/gui/app.go')
| -rw-r--r-- | internal/gui/app.go | 292 |
1 files changed, 222 insertions, 70 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index 9374973..cb0a4fe 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -39,6 +39,7 @@ type Application struct { imageDisplay *ImageDisplay audioPlayer *AudioPlayer translationEntry *CustomEntry + cardTypeSelect *widget.Select statusLabel *widget.Label queueStatusLabel *widget.Label imagePromptEntry *CustomMultiLineEntry @@ -57,19 +58,21 @@ type Application struct { deleteButton *ttwidget.Button // State management - currentWord string - currentAudioFile string - currentImage string - currentTranslation string - currentPhonetic string // Full phonetic information - currentJobID int - savedCards []anki.Card - existingWords []string // Words already in anki_cards folder - currentWordIndex int - deleteConfirming bool // Track if we're in delete confirmation mode - quitConfirming bool // Track if we're in quit confirmation mode - wordChangeTimer *time.Timer // Timer for detecting word changes - fileCheckTicker *time.Ticker // Ticker for checking missing files + currentWord string + currentAudioFile string + currentAudioFileBack string // Back audio file for bg-bg cards + currentImage string + currentTranslation string + currentPhonetic string // Full phonetic information + currentCardType string // Card type: "en-bg" or "bg-bg" + currentJobID int + savedCards []anki.Card + existingWords []string // Words already in anki_cards folder + currentWordIndex int + deleteConfirming bool // Track if we're in delete confirmation mode + quitConfirming bool // Track if we're in quit confirmation mode + wordChangeTimer *time.Timer // Timer for detecting word changes + fileCheckTicker *time.Ticker // Ticker for checking missing files // Word processing queue queue *WordQueue @@ -257,6 +260,19 @@ func (a *Application) setupUI() { a.window.Canvas().Unfocus() }) + // Create card type selector + a.cardTypeSelect = widget.NewSelect([]string{"English → Bulgarian", "Bulgarian → Bulgarian"}, func(selected string) { + if selected == "Bulgarian → Bulgarian" { + a.currentCardType = "bg-bg" + a.translationEntry.SetPlaceHolder("Bulgarian definition...") + } else { + a.currentCardType = "en-bg" + a.translationEntry.SetPlaceHolder("English translation...") + } + }) + a.cardTypeSelect.SetSelected("English → Bulgarian") + a.currentCardType = "en-bg" + // Create navigation buttons (tooltips will be set after tooltip layer is created) a.submitButton = ttwidget.NewButton("", a.onSubmit) a.submitButton.Icon = theme.ConfirmIcon() @@ -267,10 +283,11 @@ func (a *Application) setupUI() { a.nextWordBtn = ttwidget.NewButton("", a.onNextWord) a.nextWordBtn.Icon = theme.NavigateNextIcon() - // Create a grid layout for inputs - inputGrid := container.New(layout.NewGridLayout(2), + // Create a grid layout for inputs with card type selector + inputGrid := container.New(layout.NewGridLayout(3), a.wordInput, a.translationEntry, + a.cardTypeSelect, ) inputSection := container.NewBorder( @@ -468,51 +485,55 @@ func (a *Application) Run() { // onSubmit handles word submission func (a *Application) onSubmit() { bulgarianText := strings.TrimSpace(a.wordInput.Text) - englishText := strings.TrimSpace(a.translationEntry.Text) + secondaryText := strings.TrimSpace(a.translationEntry.Text) + isBgBg := a.currentCardType == "bg-bg" // Determine which word to process and if translation is needed var wordToProcess string var needsTranslation bool var translationDirection string - if bulgarianText != "" && englishText != "" { + if isBgBg { + // Bulgarian-Bulgarian mode: both fields should be Bulgarian + if bulgarianText == "" { + return + } + wordToProcess = bulgarianText + needsTranslation = false + a.currentTranslation = secondaryText + } else if bulgarianText != "" && secondaryText != "" { // Both provided - use Bulgarian as primary, no translation needed wordToProcess = bulgarianText needsTranslation = false - a.currentTranslation = englishText - } else if bulgarianText != "" && englishText == "" { + a.currentTranslation = secondaryText + } else if bulgarianText != "" && secondaryText == "" { // Only Bulgarian provided - translate to English wordToProcess = bulgarianText needsTranslation = true translationDirection = "bg-to-en" - } else if bulgarianText == "" && englishText != "" { + } else if bulgarianText == "" && secondaryText != "" { // 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) + a.updateStatus(fmt.Sprintf("Translating '%s' to Bulgarian...", secondaryText)) + bulgarian, err := a.translateEnglishToBulgarian(secondaryText) 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.currentTranslation = secondaryText a.currentWord = bulgarian - // Save the translation immediately a.saveTranslation() - needsTranslation = false // We've already done the translation, don't translate back + needsTranslation = false } 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 { @@ -521,8 +542,7 @@ func (a *Application) onSubmit() { } a.currentTranslation = english a.translationEntry.SetText(english) - needsTranslation = false // We've already done the translation - // Save the translation immediately + needsTranslation = false a.saveTranslation() } @@ -532,6 +552,14 @@ func (a *Application) onSubmit() { return } + // For bg-bg cards, also validate the back text + if isBgBg && secondaryText != "" { + if err := audio.ValidateBulgarianText(secondaryText); err != nil { + dialog.ShowError(fmt.Errorf("invalid back text: %w", err), a.window) + return + } + } + // Get custom prompt from the UI customPrompt := a.imagePromptEntry.Text @@ -540,13 +568,11 @@ func (a *Application) onSubmit() { // Store whether translation is needed and the translation if already provided job.NeedsTranslation = needsTranslation + job.CardType = a.currentCardType 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)", wordToProcess, job.ID)) @@ -1004,36 +1030,128 @@ func (a *Application) onRegenerateRandomImage() { }() } -// onRegenerateAudio regenerates audio with a different voice +// onRegenerateAudio regenerates front audio (or single audio for en-bg cards) func (a *Application) onRegenerateAudio() { // Only disable the audio-related buttons a.regenerateAudioBtn.Disable() a.regenerateAllBtn.Disable() - a.showProgress("Regenerating audio...") + + isBgBg := a.currentCardType == "bg-bg" + if isBgBg { + a.showProgress("Regenerating front audio...") + } else { + a.showProgress("Regenerating audio...") + } a.incrementProcessing() // Audio processing starts a.wg.Add(1) go func() { defer a.wg.Done() - defer a.decrementProcessing() // Image processing ends + defer a.decrementProcessing() + + // Store the word we're generating for + wordForGeneration := a.currentWord + + a.startOperation(wordForGeneration) + defer a.endOperation(wordForGeneration) + + // Get or create context for this card + cardCtx, _ := a.getOrCreateCardContext(wordForGeneration) + + // Ensure card directory exists + cardDir, err := a.ensureCardDirectory(wordForGeneration) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + }) + return + } + + if isBgBg { + // For bg-bg cards, regenerate only front audio + audioFile, err := a.generateAudioFront(cardCtx, wordForGeneration, cardDir) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Front audio regeneration failed: %w", err)) + }) + } else { + a.mu.Lock() + if a.currentWord == wordForGeneration { + a.currentAudioFile = audioFile + a.mu.Unlock() + fyne.Do(func() { + a.mu.Lock() + if a.currentWord == wordForGeneration { + a.audioPlayer.SetAudioFile(audioFile) + } + a.mu.Unlock() + }) + } else { + a.mu.Unlock() + } + } + } else { + // For en-bg cards, regenerate single audio file + audioFile, err := a.generateAudio(cardCtx, wordForGeneration, cardDir) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Audio regeneration failed: %w", err)) + }) + } else { + a.mu.Lock() + if a.currentWord == wordForGeneration { + a.currentAudioFile = audioFile + a.mu.Unlock() + fyne.Do(func() { + a.mu.Lock() + if a.currentWord == wordForGeneration { + a.audioPlayer.SetAudioFile(audioFile) + } + a.mu.Unlock() + }) + } else { + a.mu.Unlock() + } + } + } + + fyne.Do(func() { + a.hideProgress() + a.regenerateAudioBtn.Enable() + a.regenerateAllBtn.Enable() + }) + }() +} + +// onRegenerateBackAudio regenerates back audio for bg-bg cards +func (a *Application) onRegenerateBackAudio() { + if a.currentCardType != "bg-bg" { + return + } + + a.regenerateAudioBtn.Disable() + a.regenerateAllBtn.Disable() + a.showProgress("Regenerating back audio...") + + a.incrementProcessing() + + a.wg.Add(1) + go func() { + defer a.wg.Done() + defer a.decrementProcessing() - // Use the current translation to avoid re-translating translation := a.currentTranslation if translation == "" { - // Use the text from translationEntry if currentTranslation is not set translation = strings.TrimSpace(a.translationEntry.Text) } - // Store the word we're generating for wordForGeneration := a.currentWord - a.startOperation(wordForGeneration) // Track operation start - defer a.endOperation(wordForGeneration) // Track operation end + a.startOperation(wordForGeneration) + defer a.endOperation(wordForGeneration) - // Get or create context for this card cardCtx, _ := a.getOrCreateCardContext(wordForGeneration) - // Ensure card directory exists cardDir, err := a.ensureCardDirectory(wordForGeneration) if err != nil { fyne.Do(func() { @@ -1042,22 +1160,20 @@ func (a *Application) onRegenerateAudio() { return } - audioFile, err := a.generateAudio(cardCtx, wordForGeneration, cardDir) + audioFile, err := a.generateAudioBack(cardCtx, translation, cardDir) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Audio regeneration failed: %w", err)) + a.showError(fmt.Errorf("Back audio regeneration failed: %w", err)) }) } else { - // Only update if we're still on the same word a.mu.Lock() if a.currentWord == wordForGeneration { - a.currentAudioFile = audioFile + a.currentAudioFileBack = audioFile a.mu.Unlock() fyne.Do(func() { - // Double-check inside the UI update that we're still on the same word a.mu.Lock() if a.currentWord == wordForGeneration { - a.audioPlayer.SetAudioFile(audioFile) + a.audioPlayer.SetBackAudioFile(audioFile) } a.mu.Unlock() }) @@ -1068,7 +1184,6 @@ func (a *Application) onRegenerateAudio() { fyne.Do(func() { a.hideProgress() - // Re-enable audio-related buttons a.regenerateAudioBtn.Enable() a.regenerateAllBtn.Enable() }) @@ -1416,9 +1531,13 @@ func (a *Application) onShowHotkeys() { ## Regeneration **i/и** Regenerate image **m/м** Random image -**a/а** Regenerate audio +**a/а** Regenerate audio (front for bg-bg) +**A/А** Regenerate back audio (bg-bg only) **r/р** Regenerate all -**p/п** Play audio + +## Playback +**p/п** Play audio (front for bg-bg) +**P/П** Play back audio (bg-bg only) **u/у** Toggle auto-play ## Export & Archive @@ -1694,7 +1813,7 @@ func (a *Application) setupTooltips() { a.regenerateRandomImageBtn.SetToolTip("Random image (m)") } if a.regenerateAudioBtn != nil { - a.regenerateAudioBtn.SetToolTip("Regenerate audio (a)") + a.regenerateAudioBtn.SetToolTip("Regenerate audio (a/A for back)") } if a.regenerateAllBtn != nil { a.regenerateAllBtn.SetToolTip("Regenerate all (r)") @@ -1708,7 +1827,10 @@ func (a *Application) setupTooltips() { // Audio player tooltips if a.audioPlayer != nil && a.audioPlayer.playButton != nil { - a.audioPlayer.playButton.SetToolTip("Play audio (p)") + a.audioPlayer.playButton.SetToolTip("Play audio (p/P for back)") + } + if a.audioPlayer != nil && a.audioPlayer.playBackButton != nil { + a.audioPlayer.playBackButton.SetToolTip("Play back audio (P)") } if a.audioPlayer != nil && a.audioPlayer.stopButton != nil { a.audioPlayer.stopButton.SetToolTip("Stop audio") @@ -1860,12 +1982,22 @@ func (a *Application) processWordJob(job *WordJob) { return } + // Determine if this is a bg-bg card + isBgBg := job.CardType == "bg-bg" + + // Save card type + if isBgBg { + internal.SaveCardType(cardDir, internal.CardTypeBgBg) + } else { + internal.SaveCardType(cardDir, internal.CardTypeEnBg) + } + // Handle translation var translation string var err error - if job.NeedsTranslation { - // Translate word + if job.NeedsTranslation && !isBgBg { + // Translate word (only for en-bg cards) fyne.Do(func() { a.updateStatus(fmt.Sprintf("Translating '%s'...", job.Word)) }) @@ -1900,8 +2032,9 @@ func (a *Application) processWordJob(job *WordJob) { // Create channels for parallel operations type audioResult struct { - file string - err error + file string + fileBack string + err error } type imageResult struct { file string @@ -1925,17 +2058,25 @@ func (a *Application) processWordJob(job *WordJob) { // 1. Audio generation go func() { - a.startOperation(job.Word) // Track operation start - defer a.endOperation(job.Word) // Track operation end + a.startOperation(job.Word) + defer a.endOperation(job.Word) fyne.Do(func() { - a.incrementProcessing() // Audio processing starts + a.incrementProcessing() }) - audioFile, err := a.generateAudio(cardCtx, job.Word, cardDir) - a.decrementProcessing() // Audio processing ends + var audioFile, audioFileBack string + var err error - audioChan <- audioResult{file: audioFile, err: err} + if isBgBg && translation != "" { + // Generate audio for both sides + audioFile, audioFileBack, err = a.generateAudioBgBg(cardCtx, job.Word, translation, cardDir) + } else { + audioFile, err = a.generateAudio(cardCtx, job.Word, cardDir) + } + a.decrementProcessing() + + audioChan <- audioResult{file: audioFile, fileBack: audioFileBack, err: err} }() // 2. Image generation (includes scene description) @@ -2010,7 +2151,7 @@ func (a *Application) processWordJob(job *WordJob) { }() // Wait for all operations to complete - var audioFile, imageFile string + var audioFile, audioFileBack, imageFile string var phoneticInfo string var hasError bool @@ -2021,18 +2162,19 @@ func (a *Application) processWordJob(job *WordJob) { hasError = true } else { audioFile = audioRes.file + audioFileBack = audioRes.fileBack // Update UI with audio immediately if this is still the current job a.mu.Lock() isCurrentJob := a.currentJobID == job.ID if isCurrentJob { a.currentAudioFile = audioFile + a.currentAudioFileBack = audioFileBack } a.mu.Unlock() if isCurrentJob { fyne.Do(func() { - // Double-check that we're still on the same job before updating UI a.mu.Lock() if a.currentJobID != job.ID { a.mu.Unlock() @@ -2041,7 +2183,9 @@ func (a *Application) processWordJob(job *WordJob) { a.mu.Unlock() a.audioPlayer.SetAudioFile(audioFile) - // Enable audio-related actions + if isBgBg && audioFileBack != "" { + a.audioPlayer.SetBackAudioFile(audioFileBack) + } a.regenerateAudioBtn.Enable() }) } @@ -2282,10 +2426,14 @@ func (a *Application) setupKeyboardShortcuts() { if !a.regenerateRandomImageBtn.Disabled() { a.onRegenerateRandomImage() } - case 'а', 'А': // а = a + case 'a', 'а': // a = regenerate front audio if !a.regenerateAudioBtn.Disabled() { a.onRegenerateAudio() } + case 'A', 'А': // A = regenerate back audio (for bg-bg cards) + if a.currentCardType == "bg-bg" && !a.regenerateAudioBtn.Disabled() { + a.onRegenerateBackAudio() + } case 'р', 'Р': // р = r if !a.regenerateAllBtn.Disabled() { a.onRegenerateAll() @@ -2294,10 +2442,14 @@ func (a *Application) setupKeyboardShortcuts() { if !a.deleteButton.Disabled() { a.onDelete() } - case 'п', 'П': // п = p (play audio) + case 'p', 'п': // p = play front audio if a.currentAudioFile != "" { a.audioPlayer.Play() } + case 'P', 'П': // P = play back audio (for bg-bg cards) + if a.currentAudioFileBack != "" { + a.audioPlayer.PlayBack() + } case 'ж', 'Ж': // ж = x a.onExportToAnki() case 'в', 'В': // в = v |
