summaryrefslogtreecommitdiff
path: root/internal/gui/navigation.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-16 13:13:38 +0300
committerPaul Buetow <paul@buetow.org>2025-07-16 13:13:38 +0300
commit7187e7464f16a9d2991ba2da3c672fdb3cf5de72 (patch)
tree208d8e301dc55512a078f836f4f0c9ad2a927427 /internal/gui/navigation.go
parentb105333c061ea165b3b79317415cbb8b9cfb7c75 (diff)
feat: add Fyne GUI mode with interactive flashcard management
- Add --gui flag to launch interactive GUI mode - Implement word navigation with prev/next buttons through existing cards - Add delete functionality to remove unwanted flashcards - Add fine-grained regeneration (image-only, audio-only, or both) - Implement audio playback using mpg123 on Linux - Auto-load first word on startup if cards exist - Save translation files for navigation persistence - Use DALL-E 2 with 512x512 images (half size) - Update audio speed to 0.9 (from 0.8) - Add comprehensive GUI documentation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal/gui/navigation.go')
-rw-r--r--internal/gui/navigation.go268
1 files changed, 268 insertions, 0 deletions
diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go
new file mode 100644
index 0000000..f8931ba
--- /dev/null
+++ b/internal/gui/navigation.go
@@ -0,0 +1,268 @@
+package gui
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/dialog"
+)
+
+// scanExistingWords scans the output directory for existing words
+func (a *Application) scanExistingWords() {
+ a.existingWords = []string{}
+
+ // Read directory
+ entries, err := os.ReadDir(a.config.OutputDir)
+ if err != nil {
+ // Directory doesn't exist yet, that's OK
+ return
+ }
+
+ // Collect unique words
+ wordMap := make(map[string]bool)
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ name := entry.Name()
+ // Skip attribution and translation files
+ if strings.Contains(name, "_attribution") || strings.Contains(name, "_translation") {
+ continue
+ }
+
+ // Extract word from filename (before first underscore or dot)
+ base := strings.TrimSuffix(name, filepath.Ext(name))
+ parts := strings.Split(base, "_")
+ if len(parts) > 0 {
+ word := parts[0]
+ wordMap[word] = true
+ }
+ }
+
+ // Convert map to sorted slice
+ for word := range wordMap {
+ a.existingWords = append(a.existingWords, word)
+ }
+ sort.Strings(a.existingWords)
+
+ // 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
+func (a *Application) updateNavigation() {
+ if len(a.existingWords) > 0 {
+ a.prevWordBtn.Enable()
+ a.nextWordBtn.Enable()
+
+ // Find current word index
+ a.currentWordIndex = -1
+ for i, word := range a.existingWords {
+ if word == a.currentWord {
+ a.currentWordIndex = i
+ break
+ }
+ }
+
+ // Disable at boundaries
+ if a.currentWordIndex <= 0 {
+ a.prevWordBtn.Disable()
+ }
+ if a.currentWordIndex >= len(a.existingWords)-1 || a.currentWordIndex == -1 {
+ a.nextWordBtn.Disable()
+ }
+ } else {
+ a.prevWordBtn.Disable()
+ a.nextWordBtn.Disable()
+ }
+}
+
+// onPrevWord loads the previous word
+func (a *Application) onPrevWord() {
+ if a.currentWordIndex > 0 {
+ a.loadWordByIndex(a.currentWordIndex - 1)
+ }
+}
+
+// onNextWord loads the next word
+func (a *Application) onNextWord() {
+ if a.currentWordIndex < len(a.existingWords)-1 && a.currentWordIndex >= 0 {
+ a.loadWordByIndex(a.currentWordIndex + 1)
+ }
+}
+
+// loadWordByIndex loads a word by its index in existingWords
+func (a *Application) loadWordByIndex(index int) {
+ if index < 0 || index >= len(a.existingWords) {
+ return
+ }
+
+ word := a.existingWords[index]
+ a.currentWord = word
+ a.currentWordIndex = index
+
+ // Update input field
+ a.wordInput.SetText(word)
+
+ // Clear UI
+ a.clearUI()
+
+ // Load existing files
+ a.loadExistingFiles(word)
+
+ // Update navigation
+ a.updateNavigation()
+
+ // Enable action buttons since we have loaded content
+ a.setActionButtonsEnabled(true)
+}
+
+// loadExistingFiles loads existing files for a word
+func (a *Application) loadExistingFiles(word string) {
+ sanitized := sanitizeFilename(word)
+
+ // Load translation
+ translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", sanitized))
+ if data, err := os.ReadFile(translationFile); err == nil {
+ // Parse translation from "word = translation" format
+ content := string(data)
+ parts := strings.Split(content, "=")
+ if len(parts) >= 2 {
+ a.currentTranslation = strings.TrimSpace(parts[1])
+ fyne.Do(func() {
+ a.translationText.SetText(fmt.Sprintf("%s = %s", word, a.currentTranslation))
+ })
+ }
+ }
+
+ // Load audio file
+ audioFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s.%s", sanitized, a.config.AudioFormat))
+ if _, err := os.Stat(audioFile); err == nil {
+ a.currentAudioFile = audioFile
+ fyne.Do(func() {
+ a.audioPlayer.SetAudioFile(audioFile)
+ })
+ }
+
+ // Load image files
+ a.currentImages = []string{}
+ // Try to find images with different patterns
+ patterns := []string{
+ fmt.Sprintf("%s.jpg", sanitized),
+ fmt.Sprintf("%s.png", sanitized),
+ fmt.Sprintf("%s_0.jpg", sanitized),
+ fmt.Sprintf("%s_0.png", sanitized),
+ fmt.Sprintf("%s_1.jpg", sanitized),
+ fmt.Sprintf("%s_1.png", sanitized),
+ }
+
+ for _, pattern := range patterns {
+ imagePath := filepath.Join(a.config.OutputDir, pattern)
+ if _, err := os.Stat(imagePath); err == nil {
+ a.currentImages = append(a.currentImages, imagePath)
+ break // Just load the first image found
+ }
+ }
+
+ if len(a.currentImages) > 0 {
+ fyne.Do(func() {
+ a.imageDisplay.SetImages(a.currentImages)
+ })
+ }
+
+ fyne.Do(func() {
+ a.updateStatus(fmt.Sprintf("Loaded: %s", word))
+ })
+}
+
+// onDelete deletes the current word's files
+func (a *Application) onDelete() {
+ if a.currentWord == "" {
+ return
+ }
+
+ // Confirm deletion
+ dialog.ShowConfirm("Delete Word",
+ fmt.Sprintf("Delete all files for '%s'?", a.currentWord),
+ func(confirm bool) {
+ if confirm {
+ a.deleteCurrentWord()
+ }
+ }, a.window)
+}
+
+// deleteCurrentWord deletes all files for the current word
+func (a *Application) deleteCurrentWord() {
+ sanitized := sanitizeFilename(a.currentWord)
+ deletedCount := 0
+
+ // List of possible files to delete
+ patterns := []string{
+ fmt.Sprintf("%s.mp3", sanitized),
+ fmt.Sprintf("%s.wav", sanitized),
+ fmt.Sprintf("%s.jpg", sanitized),
+ fmt.Sprintf("%s.png", sanitized),
+ fmt.Sprintf("%s.gif", sanitized),
+ fmt.Sprintf("%s_*.jpg", sanitized),
+ fmt.Sprintf("%s_*.png", sanitized),
+ fmt.Sprintf("%s_translation.txt", sanitized),
+ fmt.Sprintf("%s_attribution.txt", sanitized),
+ fmt.Sprintf("%s_*_attribution.txt", sanitized),
+ }
+
+ // Delete files matching patterns
+ for _, pattern := range patterns {
+ matches, err := filepath.Glob(filepath.Join(a.config.OutputDir, pattern))
+ if err != nil {
+ continue
+ }
+ for _, match := range matches {
+ if err := os.Remove(match); err == nil {
+ deletedCount++
+ }
+ }
+ }
+
+ // Remove from existingWords
+ newWords := []string{}
+ for _, w := range a.existingWords {
+ if w != a.currentWord {
+ newWords = append(newWords, w)
+ }
+ }
+ a.existingWords = newWords
+
+ // Clear UI
+ a.clearUI()
+
+ // Update status
+ fyne.Do(func() {
+ a.updateStatus(fmt.Sprintf("Deleted %d files for '%s'", deletedCount, a.currentWord))
+ })
+
+ // Clear current word
+ a.currentWord = ""
+ a.wordInput.SetText("")
+
+ // Try to load previous or next word
+ if a.currentWordIndex > 0 && a.currentWordIndex <= len(a.existingWords) {
+ a.loadWordByIndex(a.currentWordIndex - 1)
+ } else if len(a.existingWords) > 0 {
+ a.loadWordByIndex(0)
+ } else {
+ // No more words
+ a.updateNavigation()
+ a.setActionButtonsEnabled(false)
+ }
+} \ No newline at end of file