summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 10:47:33 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 10:47:33 +0300
commit95dd36d28d18615ad3f8dd7122a404850dcb39f8 (patch)
tree14c56ec1252d8bfe81faa46ea512d9aff8308da5 /internal
parent05bddac137607102f12c1c464db34a1e10707af6 (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.go2666
-rw-r--r--internal/gui/audio_paths.go41
-rw-r--r--internal/gui/card_service.go492
-rw-r--r--internal/gui/generator.go496
-rw-r--r--internal/gui/navigation.go652
-rw-r--r--internal/gui/orchestrator.go705
-rw-r--r--internal/gui/persistence.go105
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