From acec1e0668084715dc1e981b11b1562243283f58 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 8 Apr 2026 10:07:06 +0300 Subject: refactor(gui): extract NavigationHandler, ExportHandler, QueueManager, KeyboardShortcuts Move navigation, export dialog, queue processing, and keyboard wiring out of Application into focused types with app *Application for shared state. Add ensureHandlers() for lazy init so tests that build Application literals still work. Wire queue callbacks to QueueManager; keep thin Application delegates for entry points used across the GUI. Made-with: Cursor --- internal/gui/app.go | 1005 +++--------------------------------- internal/gui/export_handler.go | 212 ++++++++ internal/gui/keyboard_shortcuts.go | 322 ++++++++++++ internal/gui/navigation.go | 593 --------------------- internal/gui/navigation_handler.go | 572 ++++++++++++++++++++ internal/gui/queue_manager.go | 449 ++++++++++++++++ 6 files changed, 1618 insertions(+), 1535 deletions(-) create mode 100644 internal/gui/export_handler.go create mode 100644 internal/gui/keyboard_shortcuts.go delete mode 100644 internal/gui/navigation.go create mode 100644 internal/gui/navigation_handler.go create mode 100644 internal/gui/queue_manager.go (limited to 'internal') diff --git a/internal/gui/app.go b/internal/gui/app.go index edb62ba..56942e3 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -14,7 +14,6 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" fynetooltip "github.com/dweymouth/fyne-tooltip" @@ -124,6 +123,12 @@ type Application struct { // Service layer — decoupled from the UI event-wiring in Application. cardSvc *CardService // file discovery, directory management, persistence gen *GenerationOrchestrator // audio, image, and phonetics generation + + // Focused sub-handlers (SRP); initialized lazily via ensureHandlers. + nav *NavigationHandler + export *ExportHandler + queueMgr *QueueManager + keys *KeyboardShortcuts } // Config holds GUI application configuration @@ -268,8 +273,9 @@ func applyConfigDefaults(config *Config) *Config { // CardService, and GenerationOrchestrator onto the Application. This is called // once from New() after the struct is created. func (a *Application) initAppServices(config *Config) { + a.ensureHandlers() a.queue = NewWordQueue(a.ctx) - a.queue.SetCallbacks(a.onQueueStatusUpdate, a.onJobComplete) + a.queue.SetCallbacks(a.queueMgr.onQueueStatusUpdate, a.queueMgr.onJobComplete) a.audioConfig = audioConfigForApp(config) @@ -1283,203 +1289,10 @@ func (a *Application) onRegenerateAll() { }() } -// onExportToAnki exports all cards from the output directory to Anki. -// Shows a format-selection dialog and performs the actual export on confirm. -// onExportToAnki opens the Export to Anki dialog where the user selects a format, -// deck name, and output directory. No-op when no exportable cards exist. +// onExportToAnki delegates to ExportHandler. func (a *Application) onExportToAnki() { - if !a.hasExportableCards() { - dialog.ShowInformation("No Cards", "No cards found in anki_cards folder. Generate some cards first!", a.window) - return - } - - formatOptions := []string{"APKG (Recommended)", "CSV (Legacy)"} - formatSelect := widget.NewSelect(formatOptions, nil) - formatSelect.SetSelected(formatOptions[0]) - deckNameEntry := widget.NewEntry() - deckNameEntry.SetPlaceHolder("Bulgarian Vocabulary") - - selectedDir := a.defaultExportDir() - dirLabel := widget.NewLabel(selectedDir) - dirButton := widget.NewButton("Browse...", func() { - a.browseExportDir(&selectedDir, dirLabel) - }) - - content := a.buildExportDialogContent(formatSelect, deckNameEntry, dirLabel, dirButton) - a.showExportDialog(content, formatOptions, formatSelect, deckNameEntry, &selectedDir) -} - -// buildExportDialogContent assembles the VBox shown inside the Export to Anki dialog. -func (a *Application) buildExportDialogContent(formatSelect *widget.Select, deckNameEntry *widget.Entry, dirLabel *widget.Label, dirButton *widget.Button) fyne.CanvasObject { - return container.NewVBox( - widget.NewLabel("Export Format:"), - formatSelect, - widget.NewSeparator(), - widget.NewLabel("Deck Name:"), - deckNameEntry, - widget.NewSeparator(), - widget.NewLabel("Export Directory:"), - container.NewBorder(nil, nil, nil, dirButton, dirLabel), - widget.NewLabel(""), - widget.NewRichTextFromMarkdown("**APKG**: Complete package with media files included\n**CSV**: Text only, requires manual media copy"), - ) -} - -// showExportDialog creates the custom confirm dialog, wires keyboard shortcuts -// (e/е = export, c/ц/Esc = cancel), and shows it. -// showExportDialog creates and shows the Export to Anki custom confirm dialog. -// The confirm callback runs performExport; keyboard shortcuts e/е confirm and -// c/ц/Esc cancel. -func (a *Application) showExportDialog(content fyne.CanvasObject, formatOptions []string, formatSelect *widget.Select, deckNameEntry *widget.Entry, selectedDir *string) { - exportDialogOpen := true - - customDialog := dialog.NewCustomConfirm("Export to Anki", "Export (e)", "Cancel (c/Esc)", content, func(export bool) { - exportDialogOpen = false - if !export { - return - } - deckName := deckNameEntry.Text - if deckName == "" { - deckName = "Bulgarian Vocabulary" - } - a.performExport(formatSelect.Selected == formatOptions[0], deckName, *selectedDir) - }, a.window) - - a.wireExportDialogKeys(customDialog, &exportDialogOpen) - customDialog.Resize(fyne.NewSize(400, 300)) - customDialog.Show() -} - -// wireExportDialogKeys attaches keyboard shortcuts to the export dialog. -// e/е triggers confirm; c/ц/Esc cancels. Original handlers are restored on close. -func (a *Application) wireExportDialogKeys(customDialog *dialog.ConfirmDialog, exportDialogOpen *bool) { - origRune := a.window.Canvas().OnTypedRune() - origKey := a.window.Canvas().OnTypedKey() - - a.window.Canvas().SetOnTypedRune(func(r rune) { - if *exportDialogOpen { - switch r { - case 'e', 'E', 'е', 'Е': - customDialog.Hide() - *exportDialogOpen = false - customDialog.Confirm() - case 'c', 'C', 'ц', 'Ц': - customDialog.Hide() - *exportDialogOpen = false - } - return - } - if origRune != nil { - origRune(r) - } - }) - a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { - if *exportDialogOpen && ev.Name == fyne.KeyEscape { - customDialog.Hide() - *exportDialogOpen = false - return - } - if origKey != nil { - origKey(ev) - } - }) - customDialog.SetOnClosed(func() { - *exportDialogOpen = false - a.window.Canvas().SetOnTypedRune(origRune) - a.window.Canvas().SetOnTypedKey(origKey) - }) -} - -// hasExportableCards returns true when the output directory has at least one -// non-hidden subdirectory (which represents a card). -func (a *Application) hasExportableCards() bool { - entries, err := os.ReadDir(a.config.OutputDir) - if err != nil || len(entries) == 0 { - return false - } - for _, entry := range entries { - if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") { - return true - } - } - return false -} - -// defaultExportDir returns the home directory as the default export location. -func (a *Application) defaultExportDir() string { - homeDir, err := appconfig.HomeDir() - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: %v\n", err) - } - return homeDir -} - -// browseExportDir opens a folder-picker dialog and updates *dir and the label -// when the user selects a directory. -func (a *Application) browseExportDir(dir *string, label *widget.Label) { - folderDialog := dialog.NewFolderOpen(func(selected fyne.ListableURI, err error) { - if err != nil || selected == nil { - return - } - *dir = selected.Path() - label.SetText(*dir) - }, a.window) - - if uri, err := storage.ParseURI("file://" + *dir); err == nil { - if listableURI, ok := uri.(fyne.ListableURI); ok { - folderDialog.SetLocation(listableURI) - } - } - folderDialog.Show() -} - -// performExport runs the actual APKG or CSV export and updates the status bar. -func (a *Application) performExport(isAPKG bool, deckName, outputDir string) { - if isAPKG { - a.exportAPKG(deckName, outputDir) - } else { - a.exportCSV(outputDir) - } -} - -// exportAPKG generates an APKG file from all cards and updates the status bar. -func (a *Application) exportAPKG(deckName, outputDir string) { - filename := fmt.Sprintf("%s.apkg", internal.SanitizeFilename(deckName)) - outputPath := filepath.Join(outputDir, filename) - - gen := anki.NewGenerator(nil) - if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil { - dialog.ShowError(fmt.Errorf("failed to load cards: %w", err), a.window) - return - } - if err := gen.GenerateAPKG(outputPath, deckName); err != nil { - dialog.ShowError(fmt.Errorf("failed to generate APKG: %w", err), a.window) - return - } - total, withAudio, withImages := gen.Stats() - a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)", total, outputDir, withAudio, withImages)) -} - -// exportCSV generates a CSV file from all cards and updates the status bar. -func (a *Application) exportCSV(outputDir string) { - outputPath := filepath.Join(outputDir, "anki_import.csv") - - gen := anki.NewGenerator(&anki.GeneratorOptions{ - OutputPath: outputPath, - MediaFolder: a.config.OutputDir, - IncludeHeaders: true, - AudioFormat: a.config.AudioFormat, - }) - if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil { - dialog.ShowError(fmt.Errorf("failed to load cards: %w", err), a.window) - return - } - if err := gen.GenerateCSV(); err != nil { - dialog.ShowError(fmt.Errorf("failed to generate CSV: %w", err), a.window) - return - } - total, withAudio, withImages := gen.Stats() - a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)", total, outputDir, withAudio, withImages)) + a.ensureHandlers() + a.export.onExportToAnki() } // onArchive shows a confirmation dialog and archives the current cards directory @@ -1577,107 +1390,10 @@ func (a *Application) showArchiveConfirmDialog(confirmDialog *dialog.ConfirmDial confirmDialog.Show() } -// onShowHotkeys displays a dialog with all available keyboard shortcuts -// hotkeysMarkdown is the markdown reference text shown in the hotkeys dialog. -const hotkeysMarkdown = `[Project Page: https://codeberg.org/snonux/totalrecall](https://codeberg.org/snonux/totalrecall) - ---- - -## Navigation -**← / h/х** Previous word (vim-style) -**→ / l/л** Next word (vim-style) -**Tab** Navigate fields -**Esc** Unfocus field - -## Focus Fields -**b/б** Focus Bulgarian input -**e/е** Focus English input -**o/о** Focus image prompt - -## Word Processing -**g/г** Generate word -**n/н** New word -**d/д** Delete word - -## Regeneration -**i/и** Regenerate image -**m/м** Random image -**a/а** Regenerate audio (front for bg-bg) -**A/А** Regenerate back audio (bg-bg only) -**r/р** Regenerate all - -## Playback -**p/п** Play front audio (or audio for en-bg) -**P/П** Play back audio (bg-bg only) -**u/у** Toggle auto-play - -## Export & Archive -**x/ж** Export to Anki -**v/в** Archive all cards - -## Help -**?** Show hotkeys -**c/ц** Close dialog -**q/ч** Quit application - -## Dialogs -**y/ъ** Confirm action -**n/н** Cancel action -**c/ц** Cancel action -**Esc** Cancel action - ---- -*All hotkeys work with both Latin and Cyrillic keyboards* - -Press **c/ц** or **Esc** to close this dialog` - -// onShowHotkeys builds the keyboard-shortcut reference dialog and wires temporary -// c/ц and Esc handlers to close it. Original handlers are restored via setupKeyboardShortcuts -// when the dialog is dismissed. +// onShowHotkeys delegates to KeyboardShortcuts. func (a *Application) onShowHotkeys() { - content := widget.NewRichTextFromMarkdown(hotkeysMarkdown) - content.Wrapping = fyne.TextWrapWord - - scroll := container.NewScroll(container.NewPadded(content)) - scroll.SetMinSize(fyne.NewSize(700, 480)) - - d := dialog.NewCustom("Keyboard Shortcuts", "Close", scroll, a.window) - a.wireHotkeysDialog(d) -} - -// wireHotkeysDialog attaches temporary c/ц and Esc key handlers that close the -// dialog, then restores normal shortcuts via setupKeyboardShortcuts on close. -func (a *Application) wireHotkeysDialog(d *dialog.CustomDialog) { - dialogOpen := true - originalRuneHandler := a.window.Canvas().OnTypedRune() - originalKeyHandler := a.window.Canvas().OnTypedKey() - - a.window.Canvas().SetOnTypedRune(func(r rune) { - if dialogOpen && (r == 'c' || r == 'C' || r == 'ц' || r == 'Ц') { - d.Hide() - return - } - if originalRuneHandler != nil { - originalRuneHandler(r) - } - }) - - a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { - if dialogOpen && ev.Name == fyne.KeyEscape { - d.Hide() - return - } - if originalKeyHandler != nil { - originalKeyHandler(ev) - } - }) - - d.SetOnClosed(func() { - dialogOpen = false - a.setupKeyboardShortcuts() - }) - - d.Show() + a.ensureHandlers() + a.keys.onShowHotkeys() } // toggleAutoPlay toggles the auto-play feature on/off @@ -1934,685 +1650,90 @@ func (a *Application) setupTooltips() { }() } -// processNextInQueue processes the next word in the queue -func (a *Application) processNextInQueue() { - // Check if we're already processing - if a.currentJobID != 0 { - return +// ensureHandlers lazily wires NavigationHandler, ExportHandler, QueueManager, and KeyboardShortcuts. +func (a *Application) ensureHandlers() { + if a.nav == nil { + a.nav = &NavigationHandler{app: a} } - - // Get next job from queue - job := a.queue.ProcessNextJob() - if job == nil { - return + if a.export == nil { + a.export = &ExportHandler{app: a} } - - // 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 - fyne.Do(func() { - a.clearUI() - a.showProgress("Processing: " + job.Word) - a.updateQueueStatus() // Update to show item moved from queued to processing - }) - - // Process in background - a.wg.Add(1) - go func() { - defer a.wg.Done() - a.processWordJob(job) - }() -} - -// getOrCreateCardContext returns a context for the given word, creating one if needed -func (a *Application) getOrCreateCardContext(word string) (context.Context, context.CancelFunc) { - a.cardMu.Lock() - defer a.cardMu.Unlock() - - // Check if we already have a cancel function for this word - if cancel, exists := a.cardContexts[word]; exists { - // Cancel the old context first - cancel() + if a.queueMgr == nil { + a.queueMgr = &QueueManager{app: a} } - - // Create new context for this word - ctx, cancel := context.WithCancel(a.ctx) - a.cardContexts[word] = cancel - - return ctx, cancel -} - -// cancelCardOperations cancels all ongoing operations for a specific word -func (a *Application) cancelCardOperations(word string) { - a.cardMu.Lock() - defer a.cardMu.Unlock() - - if cancel, exists := a.cardContexts[word]; exists { - cancel() - delete(a.cardContexts, word) + if a.keys == nil { + a.keys = &KeyboardShortcuts{app: a} } } -// startOperation marks the start of an operation for a word -func (a *Application) startOperation(word string) { - a.activeOpMu.Lock() - defer a.activeOpMu.Unlock() - a.activeOperations[word]++ +func (a *Application) scanExistingWords() { + a.ensureHandlers() + a.nav.scanExistingWords() } -// endOperation marks the end of an operation for a word -func (a *Application) endOperation(word string) { - a.activeOpMu.Lock() - defer a.activeOpMu.Unlock() - - if count, exists := a.activeOperations[word]; exists { - if count > 1 { - a.activeOperations[word]-- - } else { - delete(a.activeOperations, word) - } - } +func (a *Application) loadWordByIndex(index int) { + a.ensureHandlers() + a.nav.loadWordByIndex(index) } -// hasActiveOperations checks if a word has any active operations -func (a *Application) hasActiveOperations(word string) bool { - a.activeOpMu.Lock() - defer a.activeOpMu.Unlock() - - count, exists := a.activeOperations[word] - return exists && count > 0 +func (a *Application) loadExistingFiles(word string) { + a.ensureHandlers() + a.nav.loadExistingFiles(word) } -// processWordJob processes a single word job using the GenerationOrchestrator -// for audio/image/phonetics work and updates UI state upon completion. -// processWordJob runs a single word job: creates the card directory, resolves the -// translation, triggers parallel audio/image/phonetics generation via the orchestrator, -// and updates the UI with the results. The job is marked complete (or failed) before -// returning. -func (a *Application) processWordJob(job *WordJob) { - cardCtx, _ := a.getOrCreateCardContext(job.Word) - - // Bail early if the context was already cancelled before we started. - select { - case <-cardCtx.Done(): - a.queue.FailJob(job.ID, fmt.Errorf("job cancelled")) - a.finishCurrentJob() - return - default: - } - - cardDir, isBgBg, ok := a.prepareJobDirectory(job) - if !ok { - return - } - - translation, ok := a.resolveJobTranslation(job, isBgBg, cardDir) - if !ok { - a.finishCurrentJob() - return - } - - // Show translation in the UI before generation starts. - a.mu.Lock() - if a.currentJobID == job.ID && translation != "" { - a.currentTranslation = translation - fyne.Do(func() { a.translationEntry.SetText(translation) }) - } - a.mu.Unlock() - - result, genErr := a.runJobGeneration(job, cardCtx, translation, cardDir, isBgBg) - if genErr != nil { - a.queue.FailJob(job.ID, genErr) - a.finishCurrentJob() - return - } - - a.applyJobResult(job, result, translation, isBgBg) - - a.finishCurrentJob() - fyne.Do(func() { a.updateQueueStatus() }) +func (a *Application) onPrevWord() { + a.ensureHandlers() + a.nav.onPrevWord() } -// prepareJobDirectory ensures a card directory exists and saves the card type. -// Returns the directory path, isBgBg flag, and true on success. -func (a *Application) prepareJobDirectory(job *WordJob) (string, bool, bool) { - cardDir, dirErr := a.ensureCardDirectory(job.Word) - if dirErr != nil { - a.queue.FailJob(job.ID, fmt.Errorf("failed to create card directory: %w", dirErr)) - a.finishCurrentJob() - return "", false, false - } - - isBgBg := job.CardType == "bg-bg" - if err := a.saveJobCardType(job.ID, cardDir, isBgBg); err != nil { - a.finishCurrentJob() - return "", false, false - } - - return cardDir, isBgBg, true +func (a *Application) onNextWord() { + a.ensureHandlers() + a.nav.onNextWord() } -// runJobGeneration fires the parallel audio/image/phonetics generation for a job -// and manages the processing counter. Returns the generation result or an error. -func (a *Application) runJobGeneration(job *WordJob, cardCtx context.Context, translation, cardDir string, isBgBg bool) (GenerateResult, error) { - fyne.Do(func() { - a.updateStatus(fmt.Sprintf("Processing '%s' - generating audio, images, and phonetics in parallel...", job.Word)) - a.mu.Lock() - if a.currentJobID == job.ID { - a.imageDisplay.SetGenerating() - } - a.mu.Unlock() - }) - - // promptUI notifies the imagePromptEntry widget when the prompt is determined. - promptUI := func(prompt string) { - a.mu.Lock() - isCurrentJob := a.currentJobID == job.ID - a.mu.Unlock() - if isCurrentJob && a.imagePromptEntry != nil { - a.imagePromptEntry.SetText(prompt) - } - } - - // Three parallel operations: audio, image, phonetics. - a.startOperation(job.Word) - a.startOperation(job.Word) - a.startOperation(job.Word) - fyne.Do(func() { - a.incrementProcessing() - a.incrementProcessing() - a.incrementProcessing() - }) - - result, genErr := a.getOrchestrator().GenerateMaterials( - cardCtx, job.Word, translation, cardDir, isBgBg, job.CustomPrompt, promptUI, - ) - - a.decrementProcessing() - a.decrementProcessing() - a.decrementProcessing() - a.endOperation(job.Word) - a.endOperation(job.Word) - a.endOperation(job.Word) - - return result, genErr +func (a *Application) onDelete() { + a.ensureHandlers() + a.nav.onDelete() } -// applyJobResult writes the generation result to in-memory state and performs -// intermediate and final UI updates including audio player, image display, and -// phonetics label. -func (a *Application) applyJobResult(job *WordJob, result GenerateResult, translation string, isBgBg bool) { - // Update audio state immediately so the play button becomes available. - a.mu.Lock() - isCurrentJob := a.currentJobID == job.ID - if isCurrentJob { - a.currentAudioFile = result.AudioFile - a.currentAudioFileBack = result.AudioFileBack - } - a.mu.Unlock() - - if isCurrentJob { - fyne.Do(func() { - a.mu.Lock() - if a.currentJobID != job.ID { - a.mu.Unlock() - return - } - a.mu.Unlock() - a.audioPlayer.SetAudioFile(result.AudioFile) - if isBgBg && result.AudioFileBack != "" { - a.audioPlayer.SetBackAudioFile(result.AudioFileBack) - } - a.regenerateAudioBtn.Enable() - }) - } - - // Update phonetics immediately if available. - if result.PhoneticInfo != "" && result.PhoneticInfo != "Failed to fetch phonetic information" { - a.mu.Lock() - shouldUpdate := a.currentJobID == job.ID - if shouldUpdate { - a.currentPhonetic = result.PhoneticInfo - } - a.mu.Unlock() - if shouldUpdate { - fmt.Printf("Updating phonetic display immediately for job %d: %s\n", job.ID, result.PhoneticInfo) - fyne.Do(func() { a.audioPlayer.SetPhonetic(result.PhoneticInfo) }) - } - } - - // Mark the job complete in the queue before the final UI paint. - fyne.Do(func() { a.updateStatus(fmt.Sprintf("Finalizing '%s'...", job.Word)) }) - a.queue.CompleteJob(job.ID, translation, result.AudioFile, result.AudioFileBack, result.ImageFile) - - a.applyFinalJobUI(job, result, translation) -} - -// applyFinalJobUI updates the full UI with the completed job result (translation, -// image, audio, phonetics). No-op when the job is no longer the current one. -func (a *Application) applyFinalJobUI(job *WordJob, result GenerateResult, translation string) { - a.mu.Lock() - isCurrentJob := a.currentJobID == job.ID - if isCurrentJob { - a.currentTranslation = translation - a.currentAudioFile = result.AudioFile - if result.ImageFile != "" { - a.currentImage = result.ImageFile - } - if result.PhoneticInfo != "" && result.PhoneticInfo != "Failed to fetch phonetic information" { - a.currentPhonetic = result.PhoneticInfo - } - } - a.mu.Unlock() - - if !isCurrentJob { - return - } - - fyne.Do(func() { - a.mu.Lock() - if a.currentJobID != job.ID { - a.mu.Unlock() - return - } - a.mu.Unlock() - - a.translationEntry.SetText(translation) - if result.ImageFile != "" { - a.imageDisplay.SetImages([]string{result.ImageFile}) - } - a.audioPlayer.SetAudioFile(result.AudioFile) - if a.currentPhonetic != "" { - fmt.Printf("Setting phonetic in final UI update: %s\n", a.currentPhonetic) - a.audioPlayer.SetPhonetic(a.currentPhonetic) - } else { - fmt.Printf("No phonetic info available in final UI update\n") - } - a.hideProgress() - a.setActionButtonsEnabled(true) - a.updateStatus(fmt.Sprintf("Completed: %s", job.Word)) - }) -} - -// saveJobCardType persists the card type for job to disk, failing the job on -// error. Returns nil on success. -func (a *Application) saveJobCardType(jobID int, cardDir string, isBgBg bool) error { - cardType := internal.CardTypeEnBg - if isBgBg { - cardType = internal.CardTypeBgBg - } - if err := internal.SaveCardType(cardDir, cardType); err != nil { - a.queue.FailJob(jobID, fmt.Errorf("failed to save card type: %w", err)) - return err - } - return nil -} - -// resolveJobTranslation returns the translation for a job, translating via the -// orchestrator when needed. Returns the translation and true on success; false -// and fails the job on error. -func (a *Application) resolveJobTranslation(job *WordJob, isBgBg bool, cardDir string) (string, bool) { - var translation string - - if job.NeedsTranslation && !isBgBg { - fyne.Do(func() { - a.updateStatus(fmt.Sprintf("Translating '%s'...", job.Word)) - }) - - var err error - translation, err = a.translateWord(job.Word) - if err != nil { - a.queue.FailJob(job.ID, fmt.Errorf("translation failed: %w", err)) - return "", false - } - } else if job.Translation != "" { - translation = job.Translation - } - - if translation != "" { - if err := a.getCardService().SaveTranslation(job.Word, translation); err != nil { - a.queue.FailJob(job.ID, fmt.Errorf("failed to save translation: %w", err)) - return "", false - } - } - - _ = cardDir // kept for documentation; SaveTranslation handles the dir internally - return translation, true +func (a *Application) processNextInQueue() { + a.ensureHandlers() + a.queueMgr.processNextInQueue() } -// finishCurrentJob clears the current job and processes next in queue -func (a *Application) finishCurrentJob() { - a.mu.Lock() - a.currentJobID = 0 - a.mu.Unlock() - - // Process next in queue - fyne.Do(func() { - a.processNextInQueue() - }) +func (a *Application) updateQueueStatus() { + a.ensureHandlers() + a.queueMgr.updateQueueStatus() } -// onQueueStatusUpdate handles queue status updates -func (a *Application) onQueueStatusUpdate(job *WordJob) { - fyne.Do(func() { - a.updateQueueStatus() - }) +func (a *Application) getOrCreateCardContext(word string) (context.Context, context.CancelFunc) { + a.ensureHandlers() + return a.queueMgr.getOrCreateCardContext(word) } -// onJobComplete handles job completion -func (a *Application) onJobComplete(job *WordJob) { - fyne.Do(func() { - a.updateQueueStatus() - - // If this was the current job and it failed, show error - if job.ID == a.currentJobID && job.Status == StatusFailed { - a.showError(job.Error) - a.hideProgress() - a.finishCurrentJob() - } - - // Update navigation to include the newly completed word - if job.Status == StatusCompleted { - a.updateNavigation() - - // 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)) - - // Check if user has navigated back to this word - a.mu.Lock() - currentWord := a.currentWord - a.mu.Unlock() - - if currentWord == job.Word { - // User is currently viewing this word, reload the files - a.loadExistingFiles(job.Word) - } - } - } - }) +func (a *Application) startOperation(word string) { + a.ensureHandlers() + a.queueMgr.startOperation(word) } -// updateQueueStatus updates the queue status label -func (a *Application) updateQueueStatus() { - a.mu.Lock() - processing := a.processingCount - a.mu.Unlock() - - // Count total cards from various sources - // 1. Saved cards from the session - savedCount := len(a.savedCards) - - // 2. Existing words from disk - existingCount := len(a.existingWords) - - // 3. Completed jobs from queue - completedJobs := a.queue.GetCompletedJobs() - queueCompleted := len(completedJobs) - - totalCards := savedCount + existingCount + queueCompleted - - status := fmt.Sprintf("Processing: %d | Total cards: %d", processing, totalCards) - - a.queueStatusLabel.SetText(status) +func (a *Application) endOperation(word string) { + a.ensureHandlers() + a.queueMgr.endOperation(word) } -// incrementProcessing increments the processing count and updates the status func (a *Application) incrementProcessing() { - a.mu.Lock() - a.processingCount++ - a.mu.Unlock() - - // Update UI on main thread - fyne.Do(func() { - a.updateQueueStatus() - }) + a.ensureHandlers() + a.queueMgr.incrementProcessing() } -// decrementProcessing decrements the processing count and updates the status func (a *Application) decrementProcessing() { - a.mu.Lock() - if a.processingCount > 0 { - a.processingCount-- - } - a.mu.Unlock() - - // Update UI on main thread - fyne.Do(func() { - a.updateQueueStatus() - }) + a.ensureHandlers() + a.queueMgr.decrementProcessing() } -// setupKeyboardShortcuts registers rune and key handlers on the window canvas. -// Rune events handle focus shortcuts and Cyrillic action keys; key events handle -// Latin/function keys, Escape, and Tab navigation. func (a *Application) setupKeyboardShortcuts() { - a.window.Canvas().SetOnTypedRune(a.handleTypedRune) - a.window.Canvas().SetOnTypedKey(a.handleTypedKey) -} - -// handleTypedRune processes character-based shortcuts, supporting both Latin and -// Cyrillic keyboard layouts. No-op when an input field is focused or a confirmation -// dialog is active. -func (a *Application) handleTypedRune(r rune) { - focused := a.window.Canvas().Focused() - isInputFocused := focused == a.wordInput || focused == a.imagePromptEntry || focused == a.translationEntry - if isInputFocused || a.deleteConfirming || a.quitConfirming { - return - } - - switch r { - // Focus shortcuts — move keyboard focus without typing the character. - case 'b', 'B', 'б', 'Б': - a.window.Canvas().Focus(a.wordInput) - case 'e', 'E', 'е', 'Е': - a.window.Canvas().Focus(a.translationEntry) - case 'o', 'O', 'о', 'О': - a.window.Canvas().Focus(a.imagePromptEntry) - // Action shortcuts (Cyrillic equivalents; Latin equivalents handled in handleTypedKey). - case 'г', 'Г': // г = g — generate - if !a.submitButton.Disabled() { - a.onSubmit() - } - case 'н', 'Н': // н = n — new word - if !a.keepButton.Disabled() { - a.onKeepAndContinue() - } - case 'и', 'И': // и = i — regenerate image - if !a.regenerateImageBtn.Disabled() { - a.onRegenerateImage() - } - case 'м', 'М': // м = m — random image - if !a.regenerateRandomImageBtn.Disabled() { - a.onRegenerateRandomImage() - } - case 'a', 'а': // a — regenerate front audio - if !a.regenerateAudioBtn.Disabled() { - a.onRegenerateAudio() - } - case 'A', 'А': // A — regenerate back audio (bg-bg only) - if a.currentCardType == "bg-bg" { - a.onRegenerateBackAudio() - } - case 'р', 'Р': // р = r — regenerate all - if !a.regenerateAllBtn.Disabled() { - a.onRegenerateAll() - } - case 'д', 'Д': // д = d — delete - if !a.deleteButton.Disabled() { - a.onDelete() - } - case 'p', 'п': // p — play front audio - if a.currentAudioFile != "" { - a.audioPlayer.Play() - } - case 'P', 'П': // P — play back audio (bg-bg only) - if a.currentAudioFileBack != "" { - a.audioPlayer.PlayBack() - } - case 'ж', 'Ж': // ж = x — export to Anki - a.onExportToAnki() - case 'в', 'В': // в = v — archive cards - a.onArchive() - case '?': // show hotkey reference - a.onShowHotkeys() - case 'h', 'H', 'х', 'Х': // h/х — previous word (vim-style) - if !a.prevWordBtn.Disabled() { - a.onPrevWord() - } - case 'l', 'L', 'л', 'Л': // l/л — next word (vim-style) - if !a.nextWordBtn.Disabled() { - a.onNextWord() - } - case 'ч', 'Ч': // ч = q — quit - a.onQuitConfirm() - case 'u', 'U', 'у', 'У': // u/у — toggle auto-play - a.toggleAutoPlay() - } -} - -// handleTypedKey processes key-event shortcuts (Latin letters, arrows, Escape, Tab). -// Escape always unfocuses; Tab cycles focus. All others are ignored when an input -// field is focused or a confirmation dialog is active. -func (a *Application) handleTypedKey(ev *fyne.KeyEvent) { - focused := a.window.Canvas().Focused() - isInputFocused := focused == a.wordInput || focused == a.imagePromptEntry || focused == a.translationEntry - - // Escape unfocuses and clears confirmation state regardless of focus. - if ev.Name == fyne.KeyEscape { - a.window.Canvas().Unfocus() - a.deleteConfirming = false - a.quitConfirming = false - return - } - - // Tab cycles through input fields regardless of current focus. - if ev.Name == fyne.KeyTab { - a.handleTabNavigation() - return - } - - // Remaining shortcuts only fire when no input or dialog is active. - if isInputFocused || a.deleteConfirming || a.quitConfirming { - return - } - - // Skip b/e/o here — they are handled in handleTypedRune to avoid typing the character. - if ev.Name == fyne.KeyB || ev.Name == fyne.KeyE || ev.Name == fyne.KeyO { - return - } - - a.handleShortcutKey(ev.Name) -} - -// handleTabNavigation manages custom Tab navigation order -func (a *Application) handleTabNavigation() { - focused := a.window.Canvas().Focused() - - switch focused { - case a.wordInput: - // From Bulgarian -> English - a.window.Canvas().Focus(a.translationEntry) - case a.translationEntry: - // From English -> Image prompt - a.window.Canvas().Focus(a.imagePromptEntry) - case a.imagePromptEntry: - // From Image prompt -> Bulgarian (cycle back) - a.window.Canvas().Focus(a.wordInput) - default: - // If nothing focused, start with Bulgarian - a.window.Canvas().Focus(a.wordInput) - } -} - -// handleShortcutKey handles the actual shortcut action -func (a *Application) handleShortcutKey(key fyne.KeyName) { - // Don't process if we're in delete or quit confirmation mode - if a.deleteConfirming || a.quitConfirming { - return - } - - switch key { - case fyne.KeyG: // Generate - if a.submitButton.Disabled() { - return - } - a.onSubmit() - - case fyne.KeyN: // New Word - if a.keepButton.Disabled() { - return - } - a.onKeepAndContinue() - - case fyne.KeyI: // Regenerate Image - if a.regenerateImageBtn.Disabled() { - return - } - a.onRegenerateImage() - - case fyne.KeyM: // Random Image (M for "magic" or "mixed") - if a.regenerateRandomImageBtn.Disabled() { - return - } - a.onRegenerateRandomImage() - - case fyne.KeyA: // Regenerate Audio (handled by custom OnTypedRune for proper case sensitivity) - // NOTE: This handler is disabled to use character-based handler instead - // For bg-bg cards: shift+A = back audio, a = front audio - // For en-bg cards: a/A = regenerate audio - // See handleTypedRune for actual implementation - - case fyne.KeyR: // Regenerate All - if a.regenerateAllBtn.Disabled() { - return - } - a.onRegenerateAll() - - case fyne.KeyD: // Delete - if a.deleteButton.Disabled() { - return - } - a.onDelete() - - case fyne.KeyLeft: // Previous word - if a.prevWordBtn.Disabled() { - return - } - a.onPrevWord() - - case fyne.KeyRight: // Next word - if a.nextWordBtn.Disabled() { - return - } - a.onNextWord() - - case fyne.KeyX: // Export to APKG - a.onExportToAnki() - - case fyne.KeyV: // Archive all cards - a.onArchive() - - case fyne.KeyQ: // Quit application - a.onQuitConfirm() - } + a.ensureHandlers() + a.keys.setupKeyboardShortcuts() } // handleWordChange is called when the Bulgarian word is changed diff --git a/internal/gui/export_handler.go b/internal/gui/export_handler.go new file mode 100644 index 0000000..d0c46bb --- /dev/null +++ b/internal/gui/export_handler.go @@ -0,0 +1,212 @@ +package gui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/storage" + "fyne.io/fyne/v2/widget" + + "codeberg.org/snonux/totalrecall/internal" + "codeberg.org/snonux/totalrecall/internal/anki" + appconfig "codeberg.org/snonux/totalrecall/internal/config" +) + +// ExportHandler owns the Export to Anki dialog and APKG/CSV export paths (SRP). +type ExportHandler struct { + app *Application +} + +// onExportToAnki opens the Export to Anki dialog where the user selects a format, +// deck name, and output directory. No-op when no exportable cards exist. +func (e *ExportHandler) onExportToAnki() { + a := e.app + if !e.hasExportableCards() { + dialog.ShowInformation("No Cards", "No cards found in anki_cards folder. Generate some cards first!", a.window) + return + } + + formatOptions := []string{"APKG (Recommended)", "CSV (Legacy)"} + formatSelect := widget.NewSelect(formatOptions, nil) + formatSelect.SetSelected(formatOptions[0]) + deckNameEntry := widget.NewEntry() + deckNameEntry.SetPlaceHolder("Bulgarian Vocabulary") + + selectedDir := e.defaultExportDir() + dirLabel := widget.NewLabel(selectedDir) + dirButton := widget.NewButton("Browse...", func() { + e.browseExportDir(&selectedDir, dirLabel) + }) + + content := e.buildExportDialogContent(formatSelect, deckNameEntry, dirLabel, dirButton) + e.showExportDialog(content, formatOptions, formatSelect, deckNameEntry, &selectedDir) +} + +func (e *ExportHandler) buildExportDialogContent(formatSelect *widget.Select, deckNameEntry *widget.Entry, dirLabel *widget.Label, dirButton *widget.Button) fyne.CanvasObject { + return container.NewVBox( + widget.NewLabel("Export Format:"), + formatSelect, + widget.NewSeparator(), + widget.NewLabel("Deck Name:"), + deckNameEntry, + widget.NewSeparator(), + widget.NewLabel("Export Directory:"), + container.NewBorder(nil, nil, nil, dirButton, dirLabel), + widget.NewLabel(""), + widget.NewRichTextFromMarkdown("**APKG**: Complete package with media files included\n**CSV**: Text only, requires manual media copy"), + ) +} + +func (e *ExportHandler) showExportDialog(content fyne.CanvasObject, formatOptions []string, formatSelect *widget.Select, deckNameEntry *widget.Entry, selectedDir *string) { + a := e.app + exportDialogOpen := true + + customDialog := dialog.NewCustomConfirm("Export to Anki", "Export (e)", "Cancel (c/Esc)", content, func(export bool) { + exportDialogOpen = false + if !export { + return + } + deckName := deckNameEntry.Text + if deckName == "" { + deckName = "Bulgarian Vocabulary" + } + e.performExport(formatSelect.Selected == formatOptions[0], deckName, *selectedDir) + }, a.window) + + e.wireExportDialogKeys(customDialog, &exportDialogOpen) + customDialog.Resize(fyne.NewSize(400, 300)) + customDialog.Show() +} + +// wireExportDialogKeys attaches keyboard shortcuts to the export dialog. +// e/е triggers confirm; c/ц/Esc cancels. Original handlers are restored on close. +func (e *ExportHandler) wireExportDialogKeys(customDialog *dialog.ConfirmDialog, exportDialogOpen *bool) { + a := e.app + origRune := a.window.Canvas().OnTypedRune() + origKey := a.window.Canvas().OnTypedKey() + + a.window.Canvas().SetOnTypedRune(func(r rune) { + if *exportDialogOpen { + switch r { + case 'e', 'E', 'е', 'Е': + customDialog.Hide() + *exportDialogOpen = false + customDialog.Confirm() + case 'c', 'C', 'ц', 'Ц': + customDialog.Hide() + *exportDialogOpen = false + } + return + } + if origRune != nil { + origRune(r) + } + }) + a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { + if *exportDialogOpen && ev.Name == fyne.KeyEscape { + customDialog.Hide() + *exportDialogOpen = false + return + } + if origKey != nil { + origKey(ev) + } + }) + customDialog.SetOnClosed(func() { + *exportDialogOpen = false + a.window.Canvas().SetOnTypedRune(origRune) + a.window.Canvas().SetOnTypedKey(origKey) + }) +} + +func (e *ExportHandler) hasExportableCards() bool { + entries, err := os.ReadDir(e.app.config.OutputDir) + if err != nil || len(entries) == 0 { + return false + } + for _, entry := range entries { + if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") { + return true + } + } + return false +} + +func (e *ExportHandler) defaultExportDir() string { + homeDir, err := appconfig.HomeDir() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + } + return homeDir +} + +func (e *ExportHandler) browseExportDir(dir *string, label *widget.Label) { + a := e.app + folderDialog := dialog.NewFolderOpen(func(selected fyne.ListableURI, err error) { + if err != nil || selected == nil { + return + } + *dir = selected.Path() + label.SetText(*dir) + }, a.window) + + if uri, err := storage.ParseURI("file://" + *dir); err == nil { + if listableURI, ok := uri.(fyne.ListableURI); ok { + folderDialog.SetLocation(listableURI) + } + } + folderDialog.Show() +} + +func (e *ExportHandler) performExport(isAPKG bool, deckName, outputDir string) { + if isAPKG { + e.exportAPKG(deckName, outputDir) + } else { + e.exportCSV(outputDir) + } +} + +func (e *ExportHandler) exportAPKG(deckName, outputDir string) { + a := e.app + filename := fmt.Sprintf("%s.apkg", internal.SanitizeFilename(deckName)) + outputPath := filepath.Join(outputDir, filename) + + gen := anki.NewGenerator(nil) + if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil { + dialog.ShowError(fmt.Errorf("failed to load cards: %w", err), a.window) + return + } + if err := gen.GenerateAPKG(outputPath, deckName); err != nil { + dialog.ShowError(fmt.Errorf("failed to generate APKG: %w", err), a.window) + return + } + total, withAudio, withImages := gen.Stats() + a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)", total, outputDir, withAudio, withImages)) +} + +func (e *ExportHandler) exportCSV(outputDir string) { + a := e.app + outputPath := filepath.Join(outputDir, "anki_import.csv") + + gen := anki.NewGenerator(&anki.GeneratorOptions{ + OutputPath: outputPath, + MediaFolder: a.config.OutputDir, + IncludeHeaders: true, + AudioFormat: a.config.AudioFormat, + }) + if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil { + dialog.ShowError(fmt.Errorf("failed to load cards: %w", err), a.window) + return + } + if err := gen.GenerateCSV(); err != nil { + dialog.ShowError(fmt.Errorf("failed to generate CSV: %w", err), a.window) + return + } + total, withAudio, withImages := gen.Stats() + a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)", total, outputDir, withAudio, withImages)) +} diff --git a/internal/gui/keyboard_shortcuts.go b/internal/gui/keyboard_shortcuts.go new file mode 100644 index 0000000..28ffada --- /dev/null +++ b/internal/gui/keyboard_shortcuts.go @@ -0,0 +1,322 @@ +package gui + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/widget" +) + +// KeyboardShortcuts wires global canvas shortcuts and the hotkey help dialog (SRP). +type KeyboardShortcuts struct { + app *Application +} + +// hotkeysMarkdown is the markdown reference text shown in the hotkeys dialog. +const hotkeysMarkdown = `[Project Page: https://codeberg.org/snonux/totalrecall](https://codeberg.org/snonux/totalrecall) + +--- + +## Navigation +**← / h/х** Previous word (vim-style) +**→ / l/л** Next word (vim-style) +**Tab** Navigate fields +**Esc** Unfocus field + +## Focus Fields +**b/б** Focus Bulgarian input +**e/е** Focus English input +**o/о** Focus image prompt + +## Word Processing +**g/г** Generate word +**n/н** New word +**d/д** Delete word + +## Regeneration +**i/и** Regenerate image +**m/м** Random image +**a/а** Regenerate audio (front for bg-bg) +**A/А** Regenerate back audio (bg-bg only) +**r/р** Regenerate all + +## Playback +**p/п** Play front audio (or audio for en-bg) +**P/П** Play back audio (bg-bg only) +**u/у** Toggle auto-play + +## Export & Archive +**x/ж** Export to Anki +**v/в** Archive all cards + +## Help +**?** Show hotkeys +**c/ц** Close dialog +**q/ч** Quit application + +## Dialogs +**y/ъ** Confirm action +**n/н** Cancel action +**c/ц** Cancel action +**Esc** Cancel action + +--- +*All hotkeys work with both Latin and Cyrillic keyboards* + +Press **c/ц** or **Esc** to close this dialog` + +// onShowHotkeys builds the keyboard-shortcut reference dialog and wires temporary +// c/ц and Esc handlers to close it. Original handlers are restored via setupKeyboardShortcuts +// when the dialog is dismissed. +func (ks *KeyboardShortcuts) onShowHotkeys() { + a := ks.app + content := widget.NewRichTextFromMarkdown(hotkeysMarkdown) + content.Wrapping = fyne.TextWrapWord + + scroll := container.NewScroll(container.NewPadded(content)) + scroll.SetMinSize(fyne.NewSize(700, 480)) + + d := dialog.NewCustom("Keyboard Shortcuts", "Close", scroll, a.window) + ks.wireHotkeysDialog(d) +} + +// wireHotkeysDialog attaches temporary c/ц and Esc key handlers that close the +// dialog, then restores normal shortcuts via setupKeyboardShortcuts on close. +func (ks *KeyboardShortcuts) wireHotkeysDialog(d *dialog.CustomDialog) { + a := ks.app + dialogOpen := true + originalRuneHandler := a.window.Canvas().OnTypedRune() + originalKeyHandler := a.window.Canvas().OnTypedKey() + + a.window.Canvas().SetOnTypedRune(func(r rune) { + if dialogOpen && (r == 'c' || r == 'C' || r == 'ц' || r == 'Ц') { + d.Hide() + return + } + if originalRuneHandler != nil { + originalRuneHandler(r) + } + }) + + a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { + if dialogOpen && ev.Name == fyne.KeyEscape { + d.Hide() + return + } + if originalKeyHandler != nil { + originalKeyHandler(ev) + } + }) + + d.SetOnClosed(func() { + dialogOpen = false + ks.setupKeyboardShortcuts() + }) + + d.Show() +} + +// setupKeyboardShortcuts registers rune and key handlers on the window canvas. +// Rune events handle focus shortcuts and Cyrillic action keys; key events handle +// Latin/function keys, Escape, and Tab navigation. +func (ks *KeyboardShortcuts) setupKeyboardShortcuts() { + a := ks.app + a.window.Canvas().SetOnTypedRune(ks.handleTypedRune) + a.window.Canvas().SetOnTypedKey(ks.handleTypedKey) +} + +// handleTypedRune processes character-based shortcuts, supporting both Latin and +// Cyrillic keyboard layouts. No-op when an input field is focused or a confirmation +// dialog is active. +func (ks *KeyboardShortcuts) handleTypedRune(r rune) { + a := ks.app + focused := a.window.Canvas().Focused() + isInputFocused := focused == a.wordInput || focused == a.imagePromptEntry || focused == a.translationEntry + if isInputFocused || a.deleteConfirming || a.quitConfirming { + return + } + + switch r { + case 'b', 'B', 'б', 'Б': + a.window.Canvas().Focus(a.wordInput) + case 'e', 'E', 'е', 'Е': + a.window.Canvas().Focus(a.translationEntry) + case 'o', 'O', 'о', 'О': + a.window.Canvas().Focus(a.imagePromptEntry) + case 'г', 'Г': + if !a.submitButton.Disabled() { + a.onSubmit() + } + case 'н', 'Н': + if !a.keepButton.Disabled() { + a.onKeepAndContinue() + } + case 'и', 'И': + if !a.regenerateImageBtn.Disabled() { + a.onRegenerateImage() + } + case 'м', 'М': + if !a.regenerateRandomImageBtn.Disabled() { + a.onRegenerateRandomImage() + } + case 'a', 'а': + if !a.regenerateAudioBtn.Disabled() { + a.onRegenerateAudio() + } + case 'A', 'А': + if a.currentCardType == "bg-bg" { + a.onRegenerateBackAudio() + } + case 'р', 'Р': + if !a.regenerateAllBtn.Disabled() { + a.onRegenerateAll() + } + case 'д', 'Д': + if !a.deleteButton.Disabled() { + a.onDelete() + } + case 'p', 'п': + if a.currentAudioFile != "" { + a.audioPlayer.Play() + } + case 'P', 'П': + if a.currentAudioFileBack != "" { + a.audioPlayer.PlayBack() + } + case 'ж', 'Ж': + a.export.onExportToAnki() + case 'в', 'В': + a.onArchive() + case '?': + ks.onShowHotkeys() + case 'h', 'H', 'х', 'Х': + if !a.prevWordBtn.Disabled() { + a.onPrevWord() + } + case 'l', 'L', 'л', 'Л': + if !a.nextWordBtn.Disabled() { + a.onNextWord() + } + case 'ч', 'Ч': + a.onQuitConfirm() + case 'u', 'U', 'у', 'У': + a.toggleAutoPlay() + } +} + +// handleTypedKey processes key-event shortcuts (Latin letters, arrows, Escape, Tab). +// Escape always unfocuses; Tab cycles focus. All others are ignored when an input +// field is focused or a confirmation dialog is active. +func (ks *KeyboardShortcuts) handleTypedKey(ev *fyne.KeyEvent) { + a := ks.app + focused := a.window.Canvas().Focused() + isInputFocused := focused == a.wordInput || focused == a.imagePromptEntry || focused == a.translationEntry + + if ev.Name == fyne.KeyEscape { + a.window.Canvas().Unfocus() + a.deleteConfirming = false + a.quitConfirming = false + return + } + + if ev.Name == fyne.KeyTab { + ks.handleTabNavigation() + return + } + + if isInputFocused || a.deleteConfirming || a.quitConfirming { + return + } + + if ev.Name == fyne.KeyB || ev.Name == fyne.KeyE || ev.Name == fyne.KeyO { + return + } + + ks.handleShortcutKey(ev.Name) +} + +// handleTabNavigation manages custom Tab navigation order. +func (ks *KeyboardShortcuts) handleTabNavigation() { + a := ks.app + focused := a.window.Canvas().Focused() + + switch focused { + case a.wordInput: + a.window.Canvas().Focus(a.translationEntry) + case a.translationEntry: + a.window.Canvas().Focus(a.imagePromptEntry) + case a.imagePromptEntry: + a.window.Canvas().Focus(a.wordInput) + default: + a.window.Canvas().Focus(a.wordInput) + } +} + +// handleShortcutKey handles the actual shortcut action. +func (ks *KeyboardShortcuts) handleShortcutKey(key fyne.KeyName) { + a := ks.app + if a.deleteConfirming || a.quitConfirming { + return + } + + switch key { + case fyne.KeyG: + if a.submitButton.Disabled() { + return + } + a.onSubmit() + + case fyne.KeyN: + if a.keepButton.Disabled() { + return + } + a.onKeepAndContinue() + + case fyne.KeyI: + if a.regenerateImageBtn.Disabled() { + return + } + a.onRegenerateImage() + + case fyne.KeyM: + if a.regenerateRandomImageBtn.Disabled() { + return + } + a.onRegenerateRandomImage() + + case fyne.KeyA: + + case fyne.KeyR: + if a.regenerateAllBtn.Disabled() { + return + } + a.onRegenerateAll() + + case fyne.KeyD: + if a.deleteButton.Disabled() { + return + } + a.onDelete() + + case fyne.KeyLeft: + if a.prevWordBtn.Disabled() { + return + } + a.onPrevWord() + + case fyne.KeyRight: + if a.nextWordBtn.Disabled() { + return + } + a.onNextWord() + + case fyne.KeyX: + a.export.onExportToAnki() + + case fyne.KeyV: + a.onArchive() + + case fyne.KeyQ: + a.onQuitConfirm() + } +} diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go deleted file mode 100644 index 08f87c5..0000000 --- a/internal/gui/navigation.go +++ /dev/null @@ -1,593 +0,0 @@ -package gui - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/dialog" - - "codeberg.org/snonux/totalrecall/internal" -) - -// findCardDirectory finds the directory for a given Bulgarian word. -// Delegates to CardService which wraps the shared internal.FindCardDirectory. -func (a *Application) findCardDirectory(word string) string { - return a.getCardService().FindCardDirectory(word) -} - -// scanExistingWords scans the output directory for existing words and updates -// the existingWords slice. Delegates file discovery to CardService. -func (a *Application) scanExistingWords() { - a.existingWords = a.getCardService().ScanExistingWords() - - // Update navigation buttons - a.updateNavigation() - - // Load first word if available and nothing is loaded yet - if len(a.existingWords) > 0 && a.currentWord == "" { - a.loadWordByIndex(0) - } -} - -// updateNavigation updates the navigation button states based on the current -// combined word list (existing words on disk + completed queue jobs). -func (a *Application) updateNavigation() { - // Get all available words (existing + completed from queue) - allWords := a.getAllAvailableWords() - - if len(allWords) > 1 { - // Enable both buttons when there's more than one word (allows circular navigation) - a.prevWordBtn.Enable() - a.nextWordBtn.Enable() - - // Find current word index - a.currentWordIndex = -1 - for i, word := range allWords { - if word == a.currentWord { - a.currentWordIndex = i - break - } - } - } else if len(allWords) == 1 { - // With only one word, disable navigation - a.prevWordBtn.Disable() - a.nextWordBtn.Disable() - } else { - // No words at all - a.prevWordBtn.Disable() - a.nextWordBtn.Disable() - } -} - -// getAllAvailableWords returns all words from disk and completed queue jobs, -// merged and sorted. -func (a *Application) getAllAvailableWords() []string { - // Start with existing words from disk - words := make([]string, len(a.existingWords)) - copy(words, a.existingWords) - - // Add completed jobs from queue that are not already in the list - completedJobs := a.queue.GetCompletedJobs() - for _, job := range completedJobs { - found := false - for _, w := range words { - if w == job.Word { - found = true - break - } - } - if !found { - words = append(words, job.Word) - } - } - - sort.Strings(words) - return words -} - -// onPrevWord loads the previous word (with wrap-around). -func (a *Application) onPrevWord() { - currentWord := a.currentWord - - // Rescan to pick up any new cards added externally - a.scanExistingWords() - - allWords := a.getAllAvailableWords() - if len(allWords) == 0 { - return - } - - currentIndex := a.findWordIndex(allWords, currentWord) - newIndex := currentIndex - 1 - if newIndex < 0 { - newIndex = len(allWords) - 1 - } - - a.loadWordByIndex(newIndex) -} - -// onNextWord loads the next word (with wrap-around). -func (a *Application) onNextWord() { - currentWord := a.currentWord - - // Rescan to pick up any new cards added externally - a.scanExistingWords() - - allWords := a.getAllAvailableWords() - if len(allWords) == 0 { - return - } - - currentIndex := a.findWordIndex(allWords, currentWord) - newIndex := currentIndex + 1 - if newIndex >= len(allWords) { - newIndex = 0 - } - - a.loadWordByIndex(newIndex) -} - -// findWordIndex returns the index of word in allWords, falling back to -// a.currentWordIndex when the word is not found (e.g. after a rescan). -func (a *Application) findWordIndex(allWords []string, word string) int { - for i, w := range allWords { - if w == word { - return i - } - } - return a.currentWordIndex -} - -// loadWordByIndex loads a word by its index in the combined word list. -func (a *Application) loadWordByIndex(index int) { - // Stop any existing file check ticker before switching words. - if a.fileCheckTicker != nil { - a.fileCheckTicker.Stop() - a.fileCheckTicker = nil - } - - allWords := a.getAllAvailableWords() - if index < 0 || index >= len(allWords) { - return - } - - word := allWords[index] - a.currentWord = word - a.currentWordIndex = index - - // Update input field - a.wordInput.SetText(word) - - // Clear UI state before loading new word - a.clearUI() - - // Check if this word is from a completed queue job - var fromQueue bool - completedJobs := a.queue.GetCompletedJobs() - for _, job := range completedJobs { - if job.Word == word && job.Status == StatusCompleted { - fromQueue = true - a.applyQueueJobToState(job) - break - } - } - - // If not from queue, load existing files from disk - if !fromQueue { - a.loadExistingFiles(word) - } - - // Update navigation - a.updateNavigation() - - // Enable action buttons if we have content - hasContent := a.currentAudioFile != "" || a.currentImage != "" || a.currentTranslation != "" - if hasContent { - a.setActionButtonsEnabled(true) - } - - // Start ticker to check for missing files - a.startFileCheckTicker() -} - -// applyQueueJobToState loads state and UI from a completed WordJob. -// Must only be called from the main goroutine (or inside fyne.Do). -func (a *Application) applyQueueJobToState(job *WordJob) { - a.currentTranslation = job.Translation - a.currentAudioFile = job.AudioFile - a.currentAudioFileBack = job.AudioFileBack - a.currentImage = job.ImageFile - a.currentCardType = job.CardType - a.syncCardTypeSelection(internal.CardType(job.CardType)) - - fyne.Do(func() { - if job.Translation != "" { - a.translationEntry.SetText(job.Translation) - } - if job.AudioFile != "" { - a.audioPlayer.SetAudioFile(job.AudioFile) - } - if job.AudioFileBack != "" { - a.audioPlayer.SetBackAudioFile(job.AudioFileBack) - } - if job.ImageFile != "" { - a.imageDisplay.SetImages([]string{job.ImageFile}) - } - - // Load phonetic info from disk if it exists - a.loadPhoneticInfo(job.Word) - - // Load image prompt from disk if it exists - if prompt := a.getCardService().LoadImagePromptForWord(job.Word); prompt != "" { - a.imagePromptEntry.SetText(prompt) - } - - a.updateStatus(fmt.Sprintf("Loaded from queue: %s", job.Word)) - }) -} - -// loadExistingFiles loads existing card files for word from disk and updates -// UI state. Delegates file I/O to CardService. -func (a *Application) loadExistingFiles(word string) { - cs := a.getCardService() - cf := cs.LoadCardFiles(word) - if cf == nil { - return - } - - // CRITICAL: Set the translation state BEFORE SetText so it's available - // whenever another method reads it during the same tick. - if cf.Translation != "" { - a.currentTranslation = cf.Translation - } - - a.currentCardType = string(cf.CardType) - a.syncCardTypeSelection(cf.CardType) - - if cf.AudioFile != "" { - a.currentAudioFile = cf.AudioFile - if a.window == nil { - a.audioPlayer.SetAudioFile(cf.AudioFile) - } else { - fyne.Do(func() { - a.audioPlayer.SetAudioFile(cf.AudioFile) - }) - } - } - - if cf.AudioBack != "" { - a.currentAudioFileBack = cf.AudioBack - if a.window == nil { - a.audioPlayer.SetBackAudioFile(cf.AudioBack) - } else { - fyne.Do(func() { - a.audioPlayer.SetBackAudioFile(cf.AudioBack) - }) - } - } else if !cf.CardType.IsBgBg() { - // For en-bg cards clear the back audio button explicitly. - a.currentAudioFileBack = "" - if a.window == nil { - a.audioPlayer.SetBackAudioFile("") - } else { - fyne.Do(func() { - a.audioPlayer.SetBackAudioFile("") - }) - } - } - - if cf.ImageFile != "" { - a.currentImage = cf.ImageFile - fyne.Do(func() { - a.imageDisplay.SetImages([]string{cf.ImageFile}) - }) - } - - fyne.Do(func() { - if cf.Translation != "" { - a.translationEntry.SetText(cf.Translation) - } - if cf.ImagePrompt != "" { - a.imagePromptEntry.SetText(cf.ImagePrompt) - } - if cf.PhoneticInfo != "" { - a.audioPlayer.SetPhonetic(cf.PhoneticInfo) - } - a.updateStatus(fmt.Sprintf("Loaded: %s", word)) - }) -} - -// startFileCheckTicker starts a ticker that periodically checks for missing -// files (e.g. audio/image that is still being generated) and updates the UI. -func (a *Application) startFileCheckTicker() { - // Stop any existing ticker first - if a.fileCheckTicker != nil { - a.fileCheckTicker.Stop() - } - - ticker := time.NewTicker(2 * time.Second) - a.fileCheckTicker = ticker - - // Track this goroutine in wg so the shutdown handler waits for it. - // The ctx.Done() case ensures it exits promptly on application close. - a.wg.Add(1) - go func() { - defer a.wg.Done() - for { - select { - case <-ticker.C: - a.mu.Lock() - currentWord := a.currentWord - a.mu.Unlock() - - if currentWord != "" { - a.checkForMissingFiles(currentWord) - } - case <-a.ctx.Done(): - return - } - } - }() -} - -// checkForMissingFiles polls for files that may have appeared since the last -// load (e.g. background generation) and updates the UI when found. -func (a *Application) checkForMissingFiles(word string) { - cs := a.getCardService() - - missing := cs.CheckMissingFiles( - word, - a.currentAudioFile, - a.currentAudioFileBack, - a.currentImage, - a.currentTranslation, - a.imagePromptEntry.Text, - a.currentPhonetic, - a.currentCardType, - ) - if missing == nil { - return - } - - a.applyMissingFiles(word, missing) -} - -// applyMissingFiles applies newly discovered files to the application state -// and updates the UI. Each field is applied only when non-empty. -func (a *Application) applyMissingFiles(word string, missing *CardFiles) { - if missing.AudioFile != "" { - a.currentAudioFile = missing.AudioFile - fyne.Do(func() { - a.audioPlayer.SetAudioFile(missing.AudioFile) - a.updateStatus(fmt.Sprintf("Found audio file for %s", word)) - }) - } - - if missing.AudioBack != "" { - a.currentAudioFileBack = missing.AudioBack - fyne.Do(func() { - a.audioPlayer.SetBackAudioFile(missing.AudioBack) - a.updateStatus(fmt.Sprintf("Found back audio file for %s", word)) - }) - } - - if missing.ImageFile != "" { - a.currentImage = missing.ImageFile - fyne.Do(func() { - a.imageDisplay.SetImages([]string{missing.ImageFile}) - a.updateStatus(fmt.Sprintf("Found image file for %s", word)) - }) - } - - if missing.Translation != "" { - a.currentTranslation = missing.Translation - fyne.Do(func() { - a.translationEntry.SetText(missing.Translation) - a.updateStatus(fmt.Sprintf("Found translation for %s", word)) - }) - } - - if missing.ImagePrompt != "" { - fyne.Do(func() { - a.imagePromptEntry.SetText(missing.ImagePrompt) - a.updateStatus(fmt.Sprintf("Found prompt for %s", word)) - }) - } - - if missing.PhoneticInfo != "" { - a.currentPhonetic = missing.PhoneticInfo - fyne.Do(func() { - a.audioPlayer.SetPhonetic(missing.PhoneticInfo) - a.updateStatus(fmt.Sprintf("Found phonetic info for %s", word)) - }) - } - - // Enable action buttons when any content is now available. - hasContent := a.currentAudioFile != "" || a.currentImage != "" || a.currentTranslation != "" - if hasContent { - fyne.Do(func() { - a.setActionButtonsEnabled(true) - }) - } -} - -// onDelete shows a confirmation dialog before moving the current word's files to -// the trash bin. Keyboard shortcuts y/Y and n/N also control the dialog. -func (a *Application) onDelete() { - if a.currentWord == "" { - return - } - - // Check if this word has active operations - if a.hasActiveOperations(a.currentWord) { - dialog.ShowError(fmt.Errorf("cannot delete %q while content is being generated; please wait for generation to complete", a.currentWord), a.window) - return - } - - // Also check if word is in the processing queue - if a.queue.IsWordProcessing(a.currentWord) { - dialog.ShowError(fmt.Errorf("cannot delete %q while it is in the processing queue; please wait for processing to complete", a.currentWord), a.window) - return - } - - message := fmt.Sprintf("Move all files for '%s' to trash?\n\nPress y to confirm or n to cancel", a.currentWord) - confirmDialog := dialog.NewConfirm("Move to Trash", message, func(confirm bool) { - a.deleteConfirming = false - if confirm { - a.deleteCurrentWord() - } - }, a.window) - - a.deleteConfirming = true - oldKeyHandler := a.window.Canvas().OnTypedKey() - oldRuneHandler := a.window.Canvas().OnTypedRune() - - a.window.Canvas().SetOnTypedRune(func(r rune) { - if a.deleteConfirming { - switch r { - case 'y', 'Y', 'ъ', 'Ъ': - confirmDialog.Hide() - a.deleteConfirming = false - a.deleteCurrentWord() - a.window.Canvas().SetOnTypedKey(oldKeyHandler) - a.window.Canvas().SetOnTypedRune(oldRuneHandler) - case 'n', 'N', 'н', 'Н': - confirmDialog.Hide() - a.deleteConfirming = false - a.window.Canvas().SetOnTypedKey(oldKeyHandler) - a.window.Canvas().SetOnTypedRune(oldRuneHandler) - } - } else if oldRuneHandler != nil { - oldRuneHandler(r) - } - }) - - a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { - if a.deleteConfirming { - switch ev.Name { - case fyne.KeyY: - confirmDialog.Hide() - a.deleteConfirming = false - a.deleteCurrentWord() - a.window.Canvas().SetOnTypedKey(oldKeyHandler) - a.window.Canvas().SetOnTypedRune(oldRuneHandler) - case fyne.KeyN, fyne.KeyEscape: - confirmDialog.Hide() - a.deleteConfirming = false - a.window.Canvas().SetOnTypedKey(oldKeyHandler) - a.window.Canvas().SetOnTypedRune(oldRuneHandler) - } - } else if oldKeyHandler != nil { - oldKeyHandler(ev) - } - }) - - confirmDialog.Show() -} - -// deleteCurrentWord delegates the file removal to CardService and then -// updates Application state and UI accordingly. -func (a *Application) deleteCurrentWord() { - // Cancel any ongoing operations for this card - a.cancelCardOperations(a.currentWord) - - // Delegate the filesystem work and state list updates to CardService. - newWords, newCards, err := a.getCardService().DeleteWord(a.currentWord, a.existingWords, a.savedCards) - if err != nil { - fyne.Do(func() { - a.updateStatus(err.Error()) - }) - return - } - - // Capture the trash dir for the deferred cleanup goroutine. - trashDir := filepath.Join(a.config.OutputDir, ".trashbin") - - a.mu.Lock() - a.savedCards = newCards - a.mu.Unlock() - a.existingWords = newWords - - // Remove from completed queue jobs. - a.queue.RemoveCompletedJobByWord(a.currentWord) - - // Clear UI - a.clearUI() - - fyne.Do(func() { - a.updateStatus(fmt.Sprintf("Moved '%s' to trash", a.currentWord)) - a.updateQueueStatus() - }) - - deletedWord := a.currentWord - a.currentWord = "" - a.wordInput.SetText("") - - // Navigate to another word or update button states when no words remain. - if a.currentWordIndex > 0 && a.currentWordIndex <= len(a.existingWords) { - a.loadWordByIndex(a.currentWordIndex - 1) - } else if len(a.existingWords) > 0 { - a.loadWordByIndex(0) - } else { - a.updateNavigation() - a.setActionButtonsEnabled(false) - a.deleteButton.Enable() - } - - // S