diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 10:47:33 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-06 10:47:33 +0300 |
| commit | 95dd36d28d18615ad3f8dd7122a404850dcb39f8 (patch) | |
| tree | 14c56ec1252d8bfe81faa46ea512d9aff8308da5 /internal | |
| parent | 05bddac137607102f12c1c464db34a1e10707af6 (diff) | |
refactor: decompose gui.Application god object into focused services
Extract CardService (file I/O, persistence, card directory management) and
GenerationOrchestrator (audio/image/phonetics generation) from the 2852-line
Application struct. Application is now thin UI event-wiring. Also split all
functions over 50 lines into focused helpers throughout app.go.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/gui/app.go | 2666 | ||||
| -rw-r--r-- | internal/gui/audio_paths.go | 41 | ||||
| -rw-r--r-- | internal/gui/card_service.go | 492 | ||||
| -rw-r--r-- | internal/gui/generator.go | 496 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 652 | ||||
| -rw-r--r-- | internal/gui/orchestrator.go | 705 | ||||
| -rw-r--r-- | internal/gui/persistence.go | 105 |
7 files changed, 2705 insertions, 2452 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index b66d66e..126a50b 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -107,9 +107,15 @@ type Application struct { activeOpMu sync.Mutex // Mutex for activeOperations map // Injectable factory functions — replaced in tests to avoid real API calls. + // These are kept on Application so tests can set them before construction + // of the orchestrator; New() copies them into the orchestrator. newOpenAIImageClient func(*image.OpenAIConfig) promptAwareImageClient newNanoBananaImageClient func(*image.NanoBananaConfig) promptAwareImageClient newAudioProvider func(*audio.Config) (audio.Provider, error) + + // Service layer — decoupled from the UI event-wiring in Application. + cardSvc *CardService // file discovery, directory management, persistence + gen *GenerationOrchestrator // audio, image, and phonetics generation } // Config holds GUI application configuration @@ -169,52 +175,21 @@ func DefaultConfig() *Config { } // New creates a new GUI application +// New constructs and returns a fully initialised Application for the given config. +// A nil config receives all defaults. The Fyne application and UI are created here; +// callers should call Run() to start the event loop. func New(config *Config) *Application { - if config == nil { - config = DefaultConfig() - } else { - // Fill in missing fields with defaults - defaults := DefaultConfig() - if config.AudioProvider == "" { - config.AudioProvider = defaults.AudioProvider - } - if config.OutputDir == "" { - config.OutputDir = defaults.OutputDir - } - if config.AudioFormat == "" { - if strings.EqualFold(config.AudioProvider, "gemini") { - config.AudioFormat = defaults.AudioFormat - } else { - config.AudioFormat = "mp3" - } - } - if config.ImageProvider == "" { - config.ImageProvider = defaults.ImageProvider - } - if config.NanoBananaModel == "" { - config.NanoBananaModel = defaults.NanoBananaModel - } - if config.NanoBananaTextModel == "" { - config.NanoBananaTextModel = defaults.NanoBananaTextModel - } - if config.GeminiTTSModel == "" { - config.GeminiTTSModel = defaults.GeminiTTSModel - } - // Don't override AutoPlay if it's explicitly set to false - // (since bool zero value is false, we can't distinguish between unset and false) - } + config = applyConfigDefaults(config) - // Ensure output directory exists if err := os.MkdirAll(config.OutputDir, 0755); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to create output directory %q: %v\n", config.OutputDir, err) } ctx, cancel := context.WithCancel(context.Background()) - myApp := app.NewWithID("org.codeberg.snonux.totalrecall") myApp.SetIcon(GetAppIcon()) - app := &Application{ + a := &Application{ app: myApp, config: config, ctx: ctx, @@ -222,48 +197,98 @@ func New(config *Config) *Application { savedCards: make([]anki.Card, 0), cardContexts: make(map[string]context.CancelFunc), activeOperations: make(map[string]int), - autoPlayEnabled: config.AutoPlay, // Use config setting + autoPlayEnabled: config.AutoPlay, - // Production defaults for factory functions; replaced in tests. + // Production-default factory functions; replaced in tests. newOpenAIImageClient: func(c *image.OpenAIConfig) promptAwareImageClient { return image.NewOpenAIClient(c) }, newNanoBananaImageClient: func(c *image.NanoBananaConfig) promptAwareImageClient { return image.NewNanoBananaClient(c) }, newAudioProvider: audio.NewProvider, } - // Initialize the word processing queue - app.queue = NewWordQueue(ctx) - app.queue.SetCallbacks(app.onQueueStatusUpdate, app.onJobComplete) + a.initAppServices(config) + a.setupUI() + a.scanExistingWords() + a.updateQueueStatus() + + return a +} + +// applyConfigDefaults returns config filled with defaults for any zero-value fields. +// When config is nil the full DefaultConfig is returned. +func applyConfigDefaults(config *Config) *Config { + if config == nil { + return DefaultConfig() + } + + defaults := DefaultConfig() + if config.AudioProvider == "" { + config.AudioProvider = defaults.AudioProvider + } + if config.OutputDir == "" { + config.OutputDir = defaults.OutputDir + } + if config.AudioFormat == "" { + // Gemini uses a different default format; everything else gets mp3. + if strings.EqualFold(config.AudioProvider, "gemini") { + config.AudioFormat = defaults.AudioFormat + } else { + config.AudioFormat = "mp3" + } + } + if config.ImageProvider == "" { + config.ImageProvider = defaults.ImageProvider + } + if config.NanoBananaModel == "" { + config.NanoBananaModel = defaults.NanoBananaModel + } + if config.NanoBananaTextModel == "" { + config.NanoBananaTextModel = defaults.NanoBananaTextModel + } + if config.GeminiTTSModel == "" { + config.GeminiTTSModel = defaults.GeminiTTSModel + } + // AutoPlay is not defaulted: bool zero value (false) cannot be distinguished + // from an explicit false, so we leave it as-is. + return config +} - // Set up audio configuration - app.audioConfig = audioConfigForApp(config) +// initAppServices wires the queue, audio config, phonetic fetcher, translator, +// CardService, and GenerationOrchestrator onto the Application. This is called +// once from New() after the struct is created. +func (a *Application) initAppServices(config *Config) { + a.queue = NewWordQueue(a.ctx) + a.queue.SetCallbacks(a.onQueueStatusUpdate, a.onJobComplete) - // Use injected phonetic fetcher when provided; otherwise construct from config fields. + a.audioConfig = audioConfigForApp(config) + + // Prefer injected phonetic fetcher (useful in tests); fall back to real one. if config.PhoneticFetcher != nil { - app.phoneticFetcher = config.PhoneticFetcher + a.phoneticFetcher = config.PhoneticFetcher } else { - app.phoneticFetcher = phonetic.NewFetcher(&phonetic.Config{ + a.phoneticFetcher = phonetic.NewFetcher(&phonetic.Config{ Provider: config.PhoneticProvider, OpenAIKey: config.OpenAIKey, GoogleAPIKey: config.GoogleAPIKey, }) } - // Use injected translator when provided; otherwise construct from config fields. + // Prefer injected translator (useful in tests); fall back to real one. if config.Translator != nil { - app.translator = config.Translator + a.translator = config.Translator } else { - app.translator = translation.NewTranslator(translationConfigForApp(config)) - } - - app.setupUI() - - // Scan existing words in output directory - app.scanExistingWords() - - // Update initial queue status - app.updateQueueStatus() - - return app + a.translator = translation.NewTranslator(translationConfigForApp(config)) + } + + a.cardSvc = NewCardService(config) + a.gen = NewGenerationOrchestrator( + config, + a.audioConfig, + a.phoneticFetcher, + a.translator, + a.newOpenAIImageClient, + a.newNanoBananaImageClient, + a.newAudioProvider, + ) } // translationConfigForApp normalizes the GUI translation settings. @@ -328,27 +353,126 @@ func audioConfigForApp(config *Config) *audio.Config { return audioConfig } +// getOrchestrator returns the GenerationOrchestrator, constructing one +// on-demand when the field is nil. The nil case occurs when tests create an +// Application struct literal directly without going through New(). +func (a *Application) getOrchestrator() *GenerationOrchestrator { + if a.gen != nil { + return a.gen + } + + // Build a temporary orchestrator from the Application's own fields so + // that tests which set those fields directly still work correctly. + return NewGenerationOrchestrator( + a.config, + a.audioConfig, + a.phoneticFetcher, + a.translator, + a.newOpenAIImageClient, + a.newNanoBananaImageClient, + a.newAudioProvider, + ) +} + +// getCardService returns the CardService, constructing one on-demand when the +// field is nil. The nil case occurs when tests create an Application struct +// literal directly without going through New(). +func (a *Application) getCardService() *CardService { + if a.cardSvc != nil { + return a.cardSvc + } + + return NewCardService(a.config) +} + // setupUI creates the main user interface +// setupUI creates the main user interface and wires all event handlers. func (a *Application) setupUI() { a.window = a.app.NewWindow("TotalRecall") a.window.SetIcon(GetAppIcon()) a.window.Resize(fyne.NewSize(880, 770)) - // Create input section with navigation + inputSection := a.buildInputSection() + displaySection := a.buildDisplaySection() + exportButton, archiveButton, helpButton, toolbar := a.buildToolbar() + statusSection := a.buildStatusSection() + + // Combine all sections — toolbar and input at top, status at bottom. + content := container.NewBorder( + container.NewVBox(toolbar, widget.NewSeparator(), inputSection), + statusSection, + nil, nil, + displaySection, + ) + + // Wrap in the tooltip layer and wire shortcuts. + a.window.SetContent(fynetooltip.AddWindowToolTipLayer(content, a.window.Canvas())) + a.setupTooltips() + + // Secondary toolbar button tooltips need a short delay to initialise. + time.AfterFunc(500*time.Millisecond, func() { + fyne.Do(func() { + if exportButton != nil { + exportButton.SetToolTip("Export to Anki (x)") + } + if archiveButton != nil { + archiveButton.SetToolTip("Archive all cards (v)") + } + if helpButton != nil { + helpButton.SetToolTip("Show hotkeys (?)") + } + }) + }) + + a.window.SetOnClosed(a.onWindowClosed) + a.setupKeyboardShortcuts() +} + +// buildInputSection constructs and returns the word/translation/card-type input +// row together with the submit button. +// buildInputSection constructs the top row of the UI: Bulgarian word field, +// translation field, card-type selector, and the submit/navigation buttons. +func (a *Application) buildInputSection() fyne.CanvasObject { + a.buildWordInput() + a.buildTranslationInput() + + 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" + + a.submitButton = ttwidget.NewButton("", a.onSubmit) + a.submitButton.Icon = theme.ConfirmIcon() + a.prevWordBtn = ttwidget.NewButton("", a.onPrevWord) + a.prevWordBtn.Icon = theme.NavigateBackIcon() + a.nextWordBtn = ttwidget.NewButton("", a.onNextWord) + a.nextWordBtn.Icon = theme.NavigateNextIcon() + + inputGrid := container.New(layout.NewGridLayout(3), + a.wordInput, a.translationEntry, a.cardTypeSelect, + ) + return container.NewBorder(nil, nil, nil, a.submitButton, inputGrid) +} + +// buildWordInput creates and wires the Bulgarian word entry field. The OnChanged +// handler debounces word changes and triggers image regeneration when the user +// edits an existing word. +func (a *Application) buildWordInput() { a.wordInput = NewCustomEntry() a.wordInput.SetPlaceHolder("Bulgarian word...") a.wordInput.OnSubmitted = func(string) { a.onSubmit() - // Remove focus from input field after submit a.window.Canvas().Unfocus() } - // Set escape handler to unfocus - a.wordInput.SetOnEscape(func() { - a.window.Canvas().Unfocus() - }) + a.wordInput.SetOnEscape(func() { a.window.Canvas().Unfocus() }) 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() oldWord := a.currentWord if a.currentJobID != 0 && text != a.currentWord { @@ -356,9 +480,8 @@ func (a *Application) setupUI() { } a.mu.Unlock() - // Check for word change when user stops typing + // Debounce: trigger image regeneration 1 s after the last keystroke. if oldWord != "" && text != "" && oldWord != text { - // Set a timer to detect when user stops typing if a.wordChangeTimer != nil { a.wordChangeTimer.Stop() } @@ -370,251 +493,132 @@ func (a *Application) setupUI() { }) } } +} - // Create translation entry +// buildTranslationInput creates and wires the translation entry field. OnChanged +// saves the translation and clears the current job ID when the text differs from +// the in-progress translation. +func (a *Application) buildTranslationInput() { a.translationEntry = NewCustomEntry() 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() } a.translationEntry.OnSubmitted = func(string) { a.onSubmit() - // Remove focus from input field after submit a.window.Canvas().Unfocus() } - // Set escape handler to unfocus - a.translationEntry.SetOnEscape(func() { - 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() - - a.prevWordBtn = ttwidget.NewButton("", a.onPrevWord) - a.prevWordBtn.Icon = theme.NavigateBackIcon() - - a.nextWordBtn = ttwidget.NewButton("", a.onNextWord) - a.nextWordBtn.Icon = theme.NavigateNextIcon() - - // 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( - nil, nil, - nil, - a.submitButton, - inputGrid, - ) + a.translationEntry.SetOnEscape(func() { a.window.Canvas().Unfocus() }) +} - // Create display section +// buildDisplaySection constructs and returns the image/prompt and log/audio +// display area. +func (a *Application) buildDisplaySection() fyne.CanvasObject { a.imageDisplay = NewImageDisplay() a.audioPlayer = NewAudioPlayer() a.audioPlayer.SetAutoPlayEnabled(&a.autoPlayEnabled) - // Create image prompt entry with custom escape handling a.imagePromptEntry = NewCustomMultiLineEntry() a.imagePromptEntry.SetPlaceHolder("Custom image prompt (optional)... Press Escape to exit field") - a.imagePromptEntry.Wrapping = fyne.TextWrapWord // Enable word wrapping - a.imagePromptEntry.OnChanged = func(text string) { - // Save the image prompt immediately when changed - a.saveImagePrompt() - } - // Set escape handler to unfocus - a.imagePromptEntry.SetOnEscape(func() { - a.window.Canvas().Unfocus() - }) // Create container for image and prompt with proper sizing + a.imagePromptEntry.Wrapping = fyne.TextWrapWord + a.imagePromptEntry.OnChanged = func(_ string) { a.saveImagePrompt() } + a.imagePromptEntry.SetOnEscape(func() { a.window.Canvas().Unfocus() }) + promptContainer := container.NewBorder( - widget.NewLabel("Image prompt:"), - nil, - nil, - nil, + 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 + imageSection := container.NewHSplit(a.imageDisplay, promptContainer) + imageSection.SetOffset(0.5) - // Create log viewer a.logViewer = NewLogViewer() - a.logViewer.StartCapture() // Start capturing stdout/stderr + a.logViewer.StartCapture() - // Create a container for log viewer and audio player - audioLogSection := container.NewVSplit( - a.logViewer, - a.audioPlayer, - ) - audioLogSection.SetOffset(0.7) // Give more space to log viewer (70/30 split) + audioLogSection := container.NewVSplit(a.logViewer, a.audioPlayer) + audioLogSection.SetOffset(0.7) - displaySection := container.NewBorder( - nil, - audioLogSection, - nil, nil, - imageSection, - ) + return container.NewBorder(nil, audioLogSection, nil, nil, imageSection) +} - // Create action buttons (tooltips will be set after tooltip layer is created) +// buildToolbar constructs action/navigation/utility buttons and the toolbar +// container. Returns the three utility buttons (for late tooltip wiring) and +// the toolbar itself. +func (a *Application) buildToolbar() (exportButton, archiveButton, helpButton *ttwidget.Button, toolbar fyne.CanvasObject) { a.keepButton = ttwidget.NewButtonWithIcon("", theme.DocumentCreateIcon(), a.onKeepAndContinue) - a.regenerateImageBtn = ttwidget.NewButtonWithIcon("", theme.ColorPaletteIcon(), a.onRegenerateImage) - a.regenerateRandomImageBtn = ttwidget.NewButtonWithIcon("", theme.ViewRefreshIcon(), a.onRegenerateRandomImage) - a.regenerateAudioBtn = ttwidget.NewButtonWithIcon("", theme.MediaRecordIcon(), a.onRegenerateAudio) - a.regenerateAllBtn = ttwidget.NewButtonWithIcon("", theme.ViewFullScreenIcon(), a.onRegenerateAll) - a.deleteButton = ttwidget.NewButtonWithIcon("", theme.DeleteIcon(), a.onDelete) a.deleteButton.Importance = widget.DangerImportance - // Initially disable action buttons a.setActionButtonsEnabled(false) - // But keep delete button enabled for cancelling operations - a.deleteButton.Enable() - - // Create export, archive and help buttons for toolbar - exportButton := ttwidget.NewButtonWithIcon("", theme.UploadIcon(), a.onExportToAnki) - archiveButton := ttwidget.NewButtonWithIcon("", theme.FolderOpenIcon(), a.onArchive) - helpButton := ttwidget.NewButtonWithIcon("", theme.HelpIcon(), a.onShowHotkeys) - - // Create toolbar with navigation buttons first, then action buttons - toolbar := container.NewHBox( - a.prevWordBtn, - a.nextWordBtn, - widget.NewSeparator(), - a.keepButton, - a.deleteButton, - widget.NewSeparator(), - a.regenerateImageBtn, - a.regenerateRandomImageBtn, - a.regenerateAudioBtn, - a.regenerateAllBtn, - widget.NewSeparator(), - exportButton, - archiveButton, - helpButton, + a.deleteButton.Enable() // Keep delete enabled for cancelling operations. + + exportButton = ttwidget.NewButtonWithIcon("", theme.UploadIcon(), a.onExportToAnki) + archiveButton = ttwidget.NewButtonWithIcon("", theme.FolderOpenIcon(), a.onArchive) + helpButton = ttwidget.NewButtonWithIcon("", theme.HelpIcon(), a.onShowHotkeys) + + toolbar = container.NewHBox( + a.prevWordBtn, a.nextWordBtn, widget.NewSeparator(), + a.keepButton, a.deleteButton, widget.NewSeparator(), + a.regenerateImageBtn, a.regenerateRandomImageBtn, a.regenerateAudioBtn, a.regenerateAllBtn, widget.NewSeparator(), + exportButton, archiveButton, helpButton, ) + return exportButton, archiveButton, helpButton, toolbar +} - // Create status section +// buildStatusSection constructs and returns the status bar at the bottom of +// the window. +func (a *Application) buildStatusSection() fyne.CanvasObject { a.statusLabel = widget.NewLabel("Ready") a.queueStatusLabel = widget.NewLabel("Queue: Empty") a.queueStatusLabel.TextStyle = fyne.TextStyle{Italic: true} - // Create version label versionLabel := widget.NewLabel(fmt.Sprintf("v%s", internal.Version)) versionLabel.TextStyle = fyne.TextStyle{Italic: true} versionLabel.Alignment = fyne.TextAlignTrailing - statusSection := container.NewBorder( + return container.NewBorder( nil, nil, nil, versionLabel, - container.NewVBox( - a.statusLabel, - widget.NewSeparator(), - a.queueStatusLabel, - ), - ) - - // No menu needed - all functions are in the toolbar - - // Combine all sections with toolbar at the top - content := container.NewBorder( - container.NewVBox( - toolbar, - widget.NewSeparator(), - inputSection, - ), - statusSection, - nil, nil, - displaySection, + container.NewVBox(a.statusLabel, widget.NewSeparator(), a.queueStatusLabel), ) +} - // Add the tooltip layer to enable tooltips - a.window.SetContent(fynetooltip.AddWindowToolTipLayer(content, a.window.Canvas())) - - // Now that tooltip layer is created, set all tooltips - a.setupTooltips() - - // Set tooltips for export, archive and help buttons after the tooltip layer - // has had time to initialize. AfterFunc avoids blocking a goroutine. - time.AfterFunc(500*time.Millisecond, func() { - fyne.Do(func() { - if exportButton != nil { - exportButton.SetToolTip("Export to Anki (x)") - } - if archiveButton != nil { - archiveButton.SetToolTip("Archive all cards (v)") - } - if helpButton != nil { - helpButton.SetToolTip("Show hotkeys (?)") - } - }) - }) - - a.window.SetOnClosed(func() { - // Stop file check ticker - if a.fileCheckTicker != nil { - a.fileCheckTicker.Stop() - } - // Restore stdio streams and close capture pipes. - if a.logViewer != nil { - a.logViewer.StopCapture() - } - // Cancel any ongoing operations - if a.cancel != nil { - a.cancel() - } - // Wait for all goroutines to finish with timeout - done := make(chan struct{}) - go func() { - a.wg.Wait() - close(done) - }() +// onWindowClosed is called when the window is closed. It stops background +// goroutines, cancels ongoing operations, and shuts down the application. +func (a *Application) onWindowClosed() { + if a.fileCheckTicker != nil { + a.fileCheckTicker.Stop() + } + if a.logViewer != nil { + a.logViewer.StopCapture() + } + if a.cancel != nil { + a.cancel() + } - select { - case <-done: - // All goroutines finished - case <-time.After(2 * time.Second): - // Timeout after 2 seconds - fmt.Println("Warning: Some operations did not complete before window close") - } + // Wait for all goroutines with a 2-second timeout to avoid blocking the OS. + done := make(chan struct{}) + go func() { + a.wg.Wait() + close(done) + }() - // Close the application - a.app.Quit() - }) + select { + case <-done: + case <-time.After(2 * time.Second): + fmt.Println("Warning: Some operations did not complete before window close") + } - // Set up keyboard shortcuts - a.setupKeyboardShortcuts() + a.app.Quit() } // Run starts the GUI application @@ -623,114 +627,133 @@ func (a *Application) Run() { a.window.ShowAndRun() } -// onSubmit handles word submission +// submitInputs holds the parsed result of a word-submission attempt. +type submitInputs struct { + wordToProcess string + needsTranslation bool + translationDirection string // "bg-to-en" | "en-to-bg" | "" + isBgBg bool + secondaryText string // used for validation and bg-bg back text +} + +// onSubmit handles word submission by parsing the input fields, optionally +// performing a pre-submission translation, validating, and enqueuing the job. func (a *Application) onSubmit() { bulgarianText := strings.TrimSpace(a.wordInput.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 + inputs, ok := a.resolveSubmitInputs(bulgarianText, secondaryText, isBgBg) + if !ok { + return + } + + // Perform any pre-queue translation (en→bg or bg→en). + if !a.applyPreSubmitTranslation(&inputs, bulgarianText, secondaryText) { + return + } - if isBgBg { - // Bulgarian-Bulgarian mode: both fields should be Bulgarian - if bulgarianText == "" { + // Validate the word text before enqueueing. + if err := audio.ValidateBulgarianText(inputs.wordToProcess); err != nil { + dialog.ShowError(err, a.window) + return + } + if inputs.isBgBg && inputs.secondaryText != "" { + if err := audio.ValidateBulgarianText(inputs.secondaryText); err != nil { + dialog.ShowError(fmt.Errorf("invalid back text: %w", err), a.window) return } - wordToProcess = bulgarianText - needsTranslation = false + } + + // Enqueue the job and start processing. + job := a.queue.AddWordWithPrompt(inputs.wordToProcess, a.imagePromptEntry.Text) + job.NeedsTranslation = inputs.needsTranslation + job.CardType = a.currentCardType + if a.currentTranslation != "" { + job.Translation = a.currentTranslation + } + + a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", inputs.wordToProcess, job.ID)) + a.updateQueueStatus() + a.processNextInQueue() +} + +// resolveSubmitInputs determines the word to process and translation direction +// from the two input fields. Returns (inputs, true) on success or (_, false) +// when no processable input is available. +func (a *Application) resolveSubmitInputs(bulgarianText, secondaryText string, isBgBg bool) (submitInputs, bool) { + var inp submitInputs + inp.isBgBg = isBgBg + inp.secondaryText = secondaryText + + switch { + case isBgBg: + if bulgarianText == "" { + return inp, false + } + inp.wordToProcess = bulgarianText a.currentTranslation = secondaryText - } else if bulgarianText != "" && secondaryText != "" { - // Both provided - use Bulgarian as primary, no translation needed - wordToProcess = bulgarianText - needsTranslation = false + case bulgarianText != "" && secondaryText != "": + inp.wordToProcess = bulgarianText a.currentTranslation = secondaryText - } else if bulgarianText != "" && secondaryText == "" { - // Only Bulgarian provided - translate to English - wordToProcess = bulgarianText - needsTranslation = true - translationDirection = "bg-to-en" - } else if bulgarianText == "" && secondaryText != "" { - // Only English provided - translate to Bulgarian - needsTranslation = true - translationDirection = "en-to-bg" - } else { - return + case bulgarianText != "" && secondaryText == "": + inp.wordToProcess = bulgarianText + inp.needsTranslation = true + inp.translationDirection = "bg-to-en" + case bulgarianText == "" && secondaryText != "": + inp.needsTranslation = true + inp.translationDirection = "en-to-bg" + default: + return inp, false } - // Handle translation first if needed. - switch translationDirection { + return inp, true +} + +// applyPreSubmitTranslation performs any translation that must complete before +// the word is enqueued (en→bg or bg→en). Updates inputs.wordToProcess and the +// UI in place. Returns false when the translation failed. +func (a *Application) applyPreSubmitTranslation(inputs *submitInputs, bulgarianText, secondaryText string) bool { + switch inputs.translationDirection { case "en-to-bg": 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 + return false } - wordToProcess = bulgarian + inputs.wordToProcess = bulgarian a.wordInput.SetText(bulgarian) a.currentTranslation = secondaryText a.currentWord = bulgarian a.saveTranslation() - needsTranslation = false + inputs.needsTranslation = false + case "bg-to-en": 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 + return false } a.currentTranslation = english a.translationEntry.SetText(english) - needsTranslation = false + inputs.needsTranslation = false a.saveTranslation() } - // Validate Bulgarian text - if err := audio.ValidateBulgarianText(wordToProcess); err != nil { - dialog.ShowError(err, a.window) - 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 - - // Add word to processing queue with custom prompt - job := a.queue.AddWordWithPrompt(wordToProcess, customPrompt) - - // 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 - } - - // Update status to show word was queued - a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", wordToProcess, job.ID)) - - // Update queue status immediately - a.updateQueueStatus() - - // Start processing if not already processing - a.processNextInQueue() + return true } -// generateMaterials generates all materials for a word (used by regenerate functions) +// generateMaterials orchestrates audio, image, and phonetics generation for a +// word (used by all regenerate functions). Delegates to GenerationOrchestrator +// for the actual generation work and updates UI state after each step. +// generateMaterials is the foreground (non-queue) entry point for generating all +// card materials for a word. It resolves the translation, fires parallel generation +// via the orchestrator, and applies the result to the UI. func (a *Application) generateMaterials(word string) { - // Get or create context for this card cardCtx, _ := a.getOrCreateCardContext(word) - // Ensure card directory exists cardDir, err := a.ensureCardDirectory(word) if err != nil { fyne.Do(func() { @@ -739,280 +762,221 @@ func (a *Application) generateMaterials(word string) { }) return } - // Check if we already have a translation - if a.currentTranslation == "" { - // Translate word - fyne.Do(func() { - 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 - } - // 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 using the pre-determined directory - if translation != "" { - translationFile := filepath.Join(cardDir, "translation.txt") - content := fmt.Sprintf("%s = %s\n", word, translation) - if err := os.WriteFile(translationFile, []byte(content), 0644); err != nil { - fmt.Printf("Warning: Failed to save translation for '%s': %v\n", word, err) - } - } - } - // Create channels for parallel operations - type audioResult struct { - file string - err error - } - type imageResult struct { - file string - err error - } - type phoneticResult struct { - info string - err error + translation, ok := a.resolveTranslation(word, cardDir) + if !ok { + return // error already shown in resolveTranslation } - audioChan := make(chan audioResult, 1) - imageChan := make(chan imageResult, 1) - phoneticChan := make(chan phoneticResult, 1) - - // Get custom prompt and translation before starting goroutines + // Snapshot prompt and translation before the goroutine to avoid data races. customPrompt := a.imagePromptEntry.Text - translation := a.currentTranslation if translation == "" { - // Use the text from translationEntry if currentTranslation is not set translation = strings.TrimSpace(a.translationEntry.Text) } - // Update status to show parallel processing - fyne.Do(func() { - a.updateStatus("Generating audio, images, and phonetics in parallel...") - }) - - // Start all three operations in parallel - - // 1. Audio generation - go func() { - a.startOperation(word) // Track operation start - defer a.endOperation(word) // Track operation end - - fyne.Do(func() { - a.incrementProcessing() // Audio processing starts - }) - - audioFile, err := a.generateAudio(cardCtx, word, cardDir) - a.decrementProcessing() // Audio processing ends - - audioChan <- audioResult{file: audioFile, err: err} - }() - - // 2. Image generation - go func() { - a.startOperation(word) // Track operation start - defer a.endOperation(word) // Track operation end + result, err := a.runMaterialsGeneration(cardCtx, word, translation, cardDir, customPrompt) + if err != nil { fyne.Do(func() { - a.incrementProcessing() // Image processing starts - // Show generating status if this is still the current word - a.mu.Lock() - if a.currentWord == word { - a.imageDisplay.SetGenerating() - } - a.mu.Unlock() + a.showError(err) + a.setUIEnabled(true) }) + return + } - imageFile, err := a.generateImagesWithPrompt(cardCtx, word, customPrompt, translation, cardDir) - a.decrementProcessing() // Image processing ends + a.applyMaterialsResult(word, result) - imageChan <- imageResult{file: imageFile, err: err} - }() + fyne.Do(func() { + a.hideProgress() + a.updateStatus("Ready - Review and decide") + a.setUIEnabled(true) + a.setActionButtonsEnabled(true) + }) +} - // 3. Phonetic information fetching - go func() { - a.startOperation(word) // Track operation start - defer a.endOperation(word) // Track operation end +// runMaterialsGeneration starts the three parallel operations (audio, image, phonetics) +// via the orchestrator and manages the processing counter. Returns the generation +// result or the fi |
