diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-19 15:58:30 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-19 16:08:02 +0300 |
| commit | 14357aee5c0db8665bea2da817d67f12d0499451 (patch) | |
| tree | e7cf4d3b45d7cb4a202490e5d5d83039889babb1 | |
| parent | d8c65cd0511b558fe62aac7022c267f51d76f3f9 (diff) | |
feat: improve GUI layout and add Cyrillic keyboard supportv0.5.0
- Move navigation buttons to top toolbar
- Increase window height (600→700px) and phonetic info area (100→180px)
- Fix file check ticker race condition
- Remove File menu, add export/help icons to toolbar
- Add comprehensive Cyrillic keyboard support for all hotkeys
- Support Cyrillic keys in all dialogs (help, export, delete confirmation)
- Add project page link to help dialog
- Bump version to 0.5.0
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | internal/gui/app.go | 247 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 61 | ||||
| -rw-r--r-- | internal/version.go | 2 |
3 files changed, 255 insertions, 55 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index 6949a8f..31d2460 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -152,7 +152,7 @@ func New(config *Config) *Application { // setupUI creates the main user interface func (a *Application) setupUI() { a.window = a.app.NewWindow(fmt.Sprintf("TotalRecall v%s - Bulgarian Flashcard Generator", internal.Version)) - a.window.Resize(fyne.NewSize(800, 600)) + a.window.Resize(fyne.NewSize(800, 700)) // Create input section with navigation a.wordInput = widget.NewEntry() @@ -227,8 +227,8 @@ func (a *Application) setupUI() { inputSection := container.NewBorder( nil, nil, - a.prevWordBtn, - container.NewHBox(a.submitButton, a.nextWordBtn), + nil, + a.submitButton, inputGrid, ) @@ -265,10 +265,10 @@ func (a *Application) setupUI() { a.phoneticDisplay = widget.NewLabel("Phonetic information will appear here...") a.phoneticDisplay.Wrapping = fyne.TextWrapWord - // Set minimum size for phonetic display (reduced to ~5 lines of text) + // Set minimum size for phonetic display (~8-10 lines of text) // Assuming ~20 pixels per line with standard font phoneticScroll := container.NewScroll(a.phoneticDisplay) - phoneticScroll.SetMinSize(fyne.NewSize(0, 100)) + phoneticScroll.SetMinSize(fyne.NewSize(0, 180)) phoneticContainer := container.NewBorder( widget.NewLabel("Phonetic Information:"), @@ -283,7 +283,7 @@ func (a *Application) setupUI() { phoneticContainer, a.audioPlayer, ) - audioPhoneticSection.SetOffset(0.5) // Equal split between phonetic and audio + audioPhoneticSection.SetOffset(0.7) // Give more space to phonetic info (70/30 split) displaySection := container.NewBorder( nil, @@ -309,8 +309,15 @@ func (a *Application) setupUI() { // Initially disable action buttons a.setActionButtonsEnabled(false) - // Create toolbar with all action buttons aligned to the left + // Create export and help buttons for toolbar + exportButton := ttwidget.NewButtonWithIcon("", theme.UploadIcon(), a.onExportToAnki) + 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(), @@ -318,6 +325,9 @@ func (a *Application) setupUI() { a.regenerateRandomImageBtn, a.regenerateAudioBtn, a.regenerateAllBtn, + widget.NewSeparator(), + exportButton, + helpButton, ) // Create status section @@ -334,17 +344,7 @@ func (a *Application) setupUI() { ), ) - // Create menu - fileMenu := fyne.NewMenu("File", - fyne.NewMenuItem("Export to Anki... (E)", a.onExportToAnki), - fyne.NewMenuItemSeparator(), - fyne.NewMenuItem("Hotkeys... (H)", a.onShowHotkeys), - fyne.NewMenuItemSeparator(), - fyne.NewMenuItem("Quit", a.app.Quit), - ) - - mainMenu := fyne.NewMainMenu(fileMenu) - a.window.SetMainMenu(mainMenu) + // No menu needed - all functions are in the toolbar // Combine all sections with toolbar at the top content := container.NewBorder( @@ -364,6 +364,10 @@ func (a *Application) setupUI() { // Now that tooltip layer is created, set all tooltips a.setupTooltips() + // Set tooltips for export and help buttons + exportButton.SetToolTip("Export to Anki (x)") + helpButton.SetToolTip("Show hotkeys (h)") + a.window.SetOnClosed(func() { // Stop file check ticker if a.fileCheckTicker != nil { @@ -380,8 +384,7 @@ func (a *Application) setupUI() { // Run starts the GUI application func (a *Application) Run() { - // Focus the Bulgarian word input on startup - a.window.Canvas().Focus(a.wordInput) + // Don't focus any input field on startup - let user choose a.window.ShowAndRun() } @@ -677,7 +680,7 @@ func (a *Application) onKeepAndContinue() { a.currentImage = "" a.mu.Unlock() - a.wordInput.FocusGained() // Focus input for next word + // Don't focus any input field - let user choose what to focus // Hide progress bar if it was showing a.hideProgress() @@ -896,7 +899,11 @@ func (a *Application) onExportToAnki() { widget.NewRichTextFromMarkdown("**APKG**: Complete package with media files included\n**CSV**: Text only, requires manual media copy"), ) - customDialog := dialog.NewCustomConfirm("Export to Anki", "Export", "Cancel", content, func(export bool) { + // Store export dialog state + exportDialogOpen := true + + customDialog := dialog.NewCustomConfirm("Export to Anki", "Export (e)", "Cancel (c)", content, func(export bool) { + exportDialogOpen = false if !export { return } @@ -967,35 +974,83 @@ func (a *Application) onExportToAnki() { } }, a.window) + // Store original keyboard handler + originalRuneHandler := a.window.Canvas().OnTypedRune() + + // Add keyboard shortcuts for the export dialog (both Latin and Cyrillic) + a.window.Canvas().SetOnTypedRune(func(r rune) { + if exportDialogOpen { + switch r { + case 'e', 'E', 'е', 'Е': + // Trigger export + customDialog.Hide() + exportDialogOpen = false + customDialog.Confirm() + case 'c', 'C', 'ц', 'Ц': + // Cancel dialog + customDialog.Hide() + exportDialogOpen = false + } + return + } + // Call original handler if it exists + if originalRuneHandler != nil { + originalRuneHandler(r) + } + }) + + // Restore original handler when dialog closes + customDialog.SetOnClosed(func() { + exportDialogOpen = false + // Restore original keyboard handler + a.window.Canvas().SetOnTypedRune(originalRuneHandler) + }) + customDialog.Resize(fyne.NewSize(400, 300)) customDialog.Show() } // onShowHotkeys displays a dialog with all available keyboard shortcuts func (a *Application) onShowHotkeys() { - hotkeys := `## Navigation + hotkeys := `[Project Page: https://codeberg.org/snonux/totalrecall](https://codeberg.org/snonux/totalrecall) + +--- + +## Navigation **←** Previous word **→** Next word **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 +**g/г** Generate word +**n/н** New word +**d/д** Delete word ## Regeneration -**I** Regenerate image -**M** Random image -**A** Regenerate audio -**R** Regenerate all -**P** Play audio +**i/и** Regenerate image +**m/м** Random image +**a/а** Regenerate audio +**r/р** Regenerate all +**p/п** Play audio ## Export -**E** Export to Anki +**x/ж** Export to Anki ## Help -**H** Show hotkeys` +**h/х** Show hotkeys +**c/ц** Close dialog +**q/ч** Quit application + +--- +*All hotkeys work with both Latin and Cyrillic keyboards* + +Press **c/ц** to close this dialog` content := widget.NewRichTextFromMarkdown(hotkeys) content.Wrapping = fyne.TextWrapWord @@ -1005,9 +1060,38 @@ func (a *Application) onShowHotkeys() { // Create a scrollable container for the content scroll := container.NewScroll(paddedContent) - scroll.SetMinSize(fyne.NewSize(350, 450)) + scroll.SetMinSize(fyne.NewSize(700, 480)) // Doubled width from 350 to 700 - dialog.NewCustom("Keyboard Shortcuts", "Close", scroll, a.window).Show() + // Create the dialog + d := dialog.NewCustom("Keyboard Shortcuts", "Close", scroll, a.window) + + // Store dialog state + dialogOpen := true + + // Store original rune handler + originalRuneHandler := a.window.Canvas().OnTypedRune() + + // Add temporary handler for 'c' to close dialog (both Latin and Cyrillic) + a.window.Canvas().SetOnTypedRune(func(r rune) { + if dialogOpen && (r == 'c' || r == 'C' || r == 'ц' || r == 'Ц') { + d.Hide() + return + } + // Call original handler if it exists + if originalRuneHandler != nil { + originalRuneHandler(r) + } + }) + + // Show the dialog + d.Show() + + // Restore original handlers when dialog closes + d.SetOnClosed(func() { + dialogOpen = false + // Restore original keyboard shortcuts + a.setupKeyboardShortcuts() + }) } // Helper methods @@ -1096,17 +1180,20 @@ func (a *Application) clearUI() { // setupTooltips sets up all tooltips after the tooltip layer has been created func (a *Application) setupTooltips() { // Navigation button tooltips - a.submitButton.SetToolTip("Generate word (G)") + a.submitButton.SetToolTip("Generate word (g)") a.prevWordBtn.SetToolTip("Previous word (←)") a.nextWordBtn.SetToolTip("Next word (→)") // Action button tooltips - a.keepButton.SetToolTip("Keep card and new word (N)") - a.regenerateImageBtn.SetToolTip("Regenerate image (I)") - a.regenerateRandomImageBtn.SetToolTip("Random image (M)") - a.regenerateAudioBtn.SetToolTip("Regenerate audio (A)") - a.regenerateAllBtn.SetToolTip("Regenerate all (R)") - a.deleteButton.SetToolTip("Delete word (D)") + a.keepButton.SetToolTip("Keep card and new word (n)") + a.regenerateImageBtn.SetToolTip("Regenerate image (i)") + a.regenerateRandomImageBtn.SetToolTip("Random image (m)") + a.regenerateAudioBtn.SetToolTip("Regenerate audio (a)") + a.regenerateAllBtn.SetToolTip("Regenerate all (r)") + a.deleteButton.SetToolTip("Delete word (d)") + + // Export and help button tooltips need to be set after creation + // We'll handle this in setupUI where they are created } // processNextInQueue processes the next word in the queue @@ -1439,6 +1526,73 @@ func (a *Application) decrementProcessing() { // setupKeyboardShortcuts sets up keyboard shortcuts for the application func (a *Application) setupKeyboardShortcuts() { + // Handle character input (for focus shortcuts that shouldn't type the character) + a.window.Canvas().SetOnTypedRune(func(r rune) { + // Check if input field is focused + focused := a.window.Canvas().Focused() + isInputFocused := focused == a.wordInput || focused == a.imagePromptEntry || focused == a.translationEntry + + // If input is focused, let the character be typed normally + if isInputFocused { + return + } + + // Don't process if we're in delete confirmation mode + if a.deleteConfirming { + return + } + + // Handle focus shortcuts that shouldn't type the character + // Support both Latin and Cyrillic keyboard layouts + 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) + // Handle Cyrillic shortcuts for actions + case 'г', 'Г': // г = g + if !a.submitButton.Disabled() { + a.onSubmit() + } + case 'н', 'Н': // н = n + if !a.keepButton.Disabled() { + a.onKeepAndContinue() + } + case 'и', 'И': // и = i + if !a.regenerateImageBtn.Disabled() { + a.onRegenerateImage() + } + case 'м', 'М': // м = m + if !a.regenerateRandomImageBtn.Disabled() { + a.onRegenerateRandomImage() + } + case 'а', 'А': // а = a + if !a.regenerateAudioBtn.Disabled() { + a.onRegenerateAudio() + } + case 'р', 'Р': // р = r + if !a.regenerateAllBtn.Disabled() { + a.onRegenerateAll() + } + case 'д', 'Д': // д = d + if !a.deleteButton.Disabled() { + a.onDelete() + } + case 'п', 'П': // п = p (play audio) + if a.currentAudioFile != "" { + a.audioPlayer.Play() + } + case 'ж', 'Ж': // ж = x + a.onExportToAnki() + case 'х', 'Х': // х = h + a.onShowHotkeys() + case 'ч', 'Ч': // ч = q + a.window.Close() + } + }) + // Create a custom shortcut handler for regular keys (when input fields are not focused) a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { // Handle Escape key to unfocus any field @@ -1468,6 +1622,11 @@ func (a *Application) setupKeyboardShortcuts() { return } + // Skip focus keys in SetOnTypedKey since they're handled in SetOnTypedRune + if ev.Name == fyne.KeyB || ev.Name == fyne.KeyE || ev.Name == fyne.KeyO { + return + } + a.handleShortcutKey(ev.Name) }) } @@ -1559,11 +1718,13 @@ func (a *Application) handleShortcutKey(key fyne.KeyName) { a.audioPlayer.Play() } - case fyne.KeyE: // Export to APKG + case fyne.KeyX: // Export to APKG a.onExportToAnki() case fyne.KeyH: // Show hotkeys a.onShowHotkeys() + case fyne.KeyQ: // Quit application + a.window.Close() } } diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index e166283..8cba6c3 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -458,18 +458,30 @@ func (a *Application) loadExistingFiles(word string) { // startFileCheckTicker starts a ticker to check for missing files func (a *Application) startFileCheckTicker() { + // Stop any existing ticker first + if a.fileCheckTicker != nil { + a.fileCheckTicker.Stop() + } + // Create ticker that checks every 2 seconds - a.fileCheckTicker = time.NewTicker(2 * time.Second) + ticker := time.NewTicker(2 * time.Second) + a.fileCheckTicker = ticker go func() { - for range a.fileCheckTicker.C { - // Only check files for the current word - a.mu.Lock() - currentWord := a.currentWord - a.mu.Unlock() - - if currentWord != "" { - a.checkForMissingFiles(currentWord) + for { + select { + case <-ticker.C: + // Only check files for the current word + a.mu.Lock() + currentWord := a.currentWord + a.mu.Unlock() + + if currentWord != "" { + a.checkForMissingFiles(currentWord) + } + case <-a.ctx.Done(): + // Application is shutting down + return } } }() @@ -582,6 +594,31 @@ func (a *Application) onDelete() { // Create a custom key handler for the dialog window oldKeyHandler := a.window.Canvas().OnTypedKey() + oldRuneHandler := a.window.Canvas().OnTypedRune() + + // Handle both Latin and Cyrillic keys + a.window.Canvas().SetOnTypedRune(func(r rune) { + if a.deleteConfirming { + switch r { + case 'y', 'Y', 'ъ', 'Ъ': + confirmDialog.Hide() + a.deleteConfirming = false + a.deleteCurrentWord() + // Restore original handlers + a.window.Canvas().SetOnTypedKey(oldKeyHandler) + a.window.Canvas().SetOnTypedRune(oldRuneHandler) + case 'n', 'N', 'н', 'Н': + confirmDialog.Hide() + a.deleteConfirming = false + // Restore original handlers + 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 { @@ -589,13 +626,15 @@ func (a *Application) onDelete() { confirmDialog.Hide() a.deleteConfirming = false a.deleteCurrentWord() - // Restore original key handler + // Restore original handlers a.window.Canvas().SetOnTypedKey(oldKeyHandler) + a.window.Canvas().SetOnTypedRune(oldRuneHandler) case fyne.KeyN, fyne.KeyEscape: confirmDialog.Hide() a.deleteConfirming = false - // Restore original key handler + // Restore original handlers a.window.Canvas().SetOnTypedKey(oldKeyHandler) + a.window.Canvas().SetOnTypedRune(oldRuneHandler) } } else if oldKeyHandler != nil { oldKeyHandler(ev) diff --git a/internal/version.go b/internal/version.go index 40069bf..c658e16 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,3 +1,3 @@ package internal -const Version = "0.4.2" +const Version = "0.5.0" |
