diff --git a/CHANGELOG.md b/CHANGELOG.md index 41442729..0f2e199a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ because it turns other people's test suites red. ### Added +- **The window in Polish, and a Preferences tab.** The window now speaks the + language your system is set to when it has that language, and English + otherwise - so on a system set to Polish it opens in Polish. Preferences + lets you choose another language. The choice takes effect the next time + the window opens, and Restart now does that at once. It is off while files + are being made, and anything typed on the other tabs is cleared. The + command line, recipes and manifests do not change language. The same tab + says what the window keeps between runs (the output directory, the window + size and the language), shows the folder they are kept in, and Forget + clears them. `tfg-gui --pseudo-language` opens the window with every + sentence longer and accented, to see how a translation will fit before + one exists. It is never saved as a choice. - **A Windows installer, `tfg-setup__windows_amd64.msi`, beside the zip archives.** It installs the window and the command line for every account on the machine, in `Program Files\Testing Files Generator`, puts diff --git a/README.md b/README.md index 82141bfb..07de9174 100644 --- a/README.md +++ b/README.md @@ -666,9 +666,12 @@ not a cut down version: a test compares the two interfaces capability by capability, and anything only one of them can do has to be declared and justified rather than quietly drifting apart. -Four screens - one batch, presets, several batches at once, and about. It shows -what a run would cost before writing anything, reports progress while it runs, -and can be cancelled part way without leaving a half written file behind. +Five screens - one batch, presets, several batches at once, preferences, and +about. It shows what a run would cost before writing anything, reports progress +while it runs, and can be cancelled part way without leaving a half written file +behind. The window speaks your system's language when it has it (English and +Polish today) and English otherwise. Preferences lets you choose another, and +says what the window keeps between runs.

The Presets screen of the window, with the list of presets open diff --git a/internal/guard/embeddedassets_test.go b/internal/guard/embeddedassets_test.go index b1effc9f..fc2a8b6d 100644 --- a/internal/guard/embeddedassets_test.go +++ b/internal/guard/embeddedassets_test.go @@ -139,6 +139,9 @@ var ownWork = map[string]bool{ "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts heart.svg": true, // The window's own words. "github.com/donislawdev/TestingFilesGenerator/internal/gui/text locale/en.json": true, + // The same words in Polish, translated for this project on 2026-09-29 - + // the first language the Preferences screen offers beside English. + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text locale/pl.json": true, } // accountFor requires exactly one registry entry to claim a file. None means diff --git a/internal/guard/guitext_test.go b/internal/guard/guitext_test.go index 81c0646e..a7aa8474 100644 --- a/internal/guard/guitext_test.go +++ b/internal/guard/guitext_test.go @@ -93,8 +93,19 @@ var notWords = map[string]string{ "Translating a storage key would lose what was kept the day somebody changed language", `"windowWidth"`: "the name the window files its width under, never shown", `"windowHeight"`: "the name the window files its height under, never shown", - `"file"`: "the scheme of a local address, which is a protocol name rather than prose", - `"."`: "the working directory, when the system will not say which one it is", + `"language"`: "the name the window files the chosen language under, never shown. " + + "Translating it would lose the choice the day somebody changed language", + `"--pseudo-language"`: "a flag on the launch line for whoever builds the window, English by D9 like every flag, never translated or shown", + // What each screen IS, as opposed to the word on its tab - see parts.Tab. + // They carry the output directory and the keyboard between screens, so a + // translation that renamed one would split a screen from its own state. + `"generate"`: "what the single batch screen is, the key it is known by between screens, never shown", + `"presets"`: "what the presets screen is, the key it is known by between screens, never shown", + `"recipe"`: "what the several batches screen is, the key it is known by between screens, never shown", + `"preferences"`: "what the preferences screen is, the key it is known by between screens, never shown", + `"about"`: "what the about screen is, the key it is known by between screens, never shown", + `"file"`: "the scheme of a local address, which is a protocol name rather than prose", + `"."`: "the working directory, when the system will not say which one it is", `"tfg-out"`: "the folder the window offers to write into. A directory name rather than " + "prose, and translating it would move where somebody's files land", `". "`: "what joins two sentences the declaration already carries", diff --git a/internal/guard/menushape_test.go b/internal/guard/menushape_test.go index e22fa543..f7f432e3 100644 --- a/internal/guard/menushape_test.go +++ b/internal/guard/menushape_test.go @@ -45,7 +45,7 @@ func TestAMenuIsNotDrawnAsWideAsABoxToTypeIn(t *testing.T) { for _, tab := range allTabs() { screen := selectTab(t, content, tab) menus, boxes := menusOn(screen), typingBoxesOn(screen) - if len(menus) == 0 { + if len(menus) == 0 || holdsNoBoxByDesign(t, tab, boxes) { continue } widest := float32(0) @@ -173,6 +173,25 @@ func menusOn(screen fyne.CanvasObject) []*parts.Chooser { return found } +// holdsNoBoxByDesign says whether a screen is the one whose menus stand beside +// no box to type in, so that a guard comparing a menu with the boxes beside it +// has nothing to compare on it. Preferences holds one menu and no box. +// +// Named rather than skipped whenever a screen comes back with no box: a screen +// that lost its boxes to a renamed part would otherwise pass these guards by +// not looking. Held both ways - the day a box stands on the named screen, the +// name stops being true out loud. +func holdsNoBoxByDesign(t *testing.T, tab string, boxes []*parts.Entry) bool { + t.Helper() + if tab != text.TabPreferences() { + return false + } + if len(boxes) != 0 { + t.Errorf("the %s screen holds %d box(es) to type in and is still named as holding none", tab, len(boxes)) + } + return true +} + func typingBoxesOn(screen fyne.CanvasObject) []*parts.Entry { var found []*parts.Entry walk(screen, func(o fyne.CanvasObject) { @@ -263,12 +282,12 @@ func TestNoMenuIsNarrowerThanTheBoxesItStandsBeside(t *testing.T) { checked := 0 for _, tab := range allTabs() { screen := selectTab(t, content, tab) - menus := menusWithAnArchiveOpened(t, screen, tab, canvas) - if len(menus) == 0 { + menus, boxes := menusWithAnArchiveOpened(t, screen, tab, canvas), typingBoxesOn(screen) + if len(menus) == 0 || holdsNoBoxByDesign(t, tab, boxes) { continue } narrowest := float32(0) - for _, box := range typingBoxesOn(screen) { + for _, box := range boxes { w := box.Size().Width if w <= 0 { continue diff --git a/internal/guard/navigation_test.go b/internal/guard/navigation_test.go index 079a36ca..0b73fdc7 100644 --- a/internal/guard/navigation_test.go +++ b/internal/guard/navigation_test.go @@ -110,9 +110,10 @@ func TestMovingBetweenScreensIsTabsAndNotButtons(t *testing.T) { window.Open(host) // The order is the order of the list, and About stays last. The two work - // screens that produce files come first, the recipe screen joins them, and + // screens that produce files come first, the recipe screen joins them, + // Preferences follows the work because it changes nothing a run makes, and // the notice is the one somebody goes looking for rather than passes through. - want := []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), text.TabAbout()} + want := []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), text.TabPreferences(), text.TabAbout()} got := tabNames(host.content) if len(got) != len(want) { t.Fatalf("the window has tabs %v and %v was expected", got, want) diff --git a/internal/guard/notelemetry_test.go b/internal/guard/notelemetry_test.go index cbd646fb..64874492 100644 --- a/internal/guard/notelemetry_test.go +++ b/internal/guard/notelemetry_test.go @@ -315,7 +315,7 @@ var librariesLoadedByPath = map[string]approvedOperation{ // of the compiler. var spawnsAllowed = map[string]approvedOperation{ "internal/gui/again.go": {call: "exec.Command", from: "os.Executable", - why: "starts this program again, by the path os.Executable answers, for the software renderer - see startAgain there"}, + why: "starts this program again, by the path os.Executable answers - for the software renderer, and for Restart now on the Preferences screen - see ourselves there"}, } // withoutRegisteredPathLoads drops the findings a registered file is allowed diff --git a/internal/guard/preferences_test.go b/internal/guard/preferences_test.go new file mode 100644 index 00000000..b8c80308 --- /dev/null +++ b/internal/guard/preferences_test.go @@ -0,0 +1,181 @@ +package guard + +import ( + "strings" + "testing" + + "fyne.io/fyne/v2" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" +) + +// The Preferences screen, docs/USTAWIENIA-2026-09-29.md: which language the +// window speaks, and what it keeps between runs. + +// polishName is what the list calls Polish, read from the build rather than +// typed here - a guard that wrote "Polski" would pass against a list that +// shows something else. +func polishName(t *testing.T) string { + t.Helper() + for _, l := range text.Languages() { + if l.Tag == "pl" { + return l.Name + } + } + t.Fatal("this build carries no Polish, so there is no second language to choose") + return "" +} + +// Choosing a language saves it at once, and Restart now is offered exactly +// while a restart would change what the window speaks. +func TestChoosingALanguageSavesItAndOffersARestart(t *testing.T) { + host := newFakeHost(t) + window.Open(host) + prefs := selectTab(t, host.content, text.TabPreferences()) + restart := buttonNamed(prefs, text.ButtonRestart()) + if restart == nil { + t.Fatalf("there is no %q button. The screen has: %v", text.ButtonRestart(), buttonNames(prefs)) + } + // In the state this is about: English on screen, nothing chosen. + if text.Speaking() != text.English || host.Remembered().Language() != "" { + t.Fatalf("the window speaks %q with %q chosen, not English with nothing chosen", + text.Speaking(), host.Remembered().Language()) + } + if !restart.Disabled() { + t.Error("Restart now is offered with nothing chosen that a restart would change") + } + + menu := chooserUnder(t, prefs, text.FieldLanguage()) + menu.SetSelected(polishName(t)) + if got := host.Remembered().Language(); got != "pl" { + t.Errorf("Polish was chosen and %q was saved", got) + } + if restart.Disabled() { + t.Error("Polish was chosen and Restart now stayed off, so the choice can only arrive by closing the window by hand") + } + + // Back to the system, which here is English: nothing saved, and nothing a + // restart would change. + menu.SetSelected(text.ChoiceSameAsSystem(text.LanguageName())) + if got := host.Remembered().Language(); got != "" { + t.Errorf("the system was chosen and %q stayed saved", got) + } + if !restart.Disabled() { + t.Error("the language on screen was chosen again and Restart now stayed on") + } +} + +// Opening the screen writes nothing - above all when the saved language is +// one this build does not carry. The list shows the system then, and saving +// what it shows would erase the person's choice without a press (untouchable +// rule 7) and the sentence saying it was missing along with it. +func TestOpeningPreferencesChangesNothingSaved(t *testing.T) { + host := newFakeHost(t) + // A real language this build does not carry, so it is parsed and matched + // rather than rejected as a malformed tag. + host.Remembered().RememberLanguage("de") + window.Open(host) + prefs := selectTab(t, host.content, text.TabPreferences()) + + if got := host.Remembered().Language(); got != "de" { + t.Errorf("a saved choice of de became %q by opening the window", got) + } + if said := textIn(prefs); !strings.Contains(said, text.PreferencesMissing("de")) { + t.Errorf("the screen does not say the saved language is missing from this version:\n%s", said) + } + if got, want := chooserUnder(t, prefs, text.FieldLanguage()).Selected, text.ChoiceSameAsSystem(text.LanguageName()); got != want { + t.Errorf("with the saved language missing the list shows %q, and what the window speaks for it is %q", got, want) + } +} + +// Restart now stands down while files are being made, stands up when they are +// done, and closes the window the way the close button does - the run +// stopped, the folder written down - before asking for the new one. +func TestRestartWaitsForTheRunAndClosesTheWayAPersonDoes(t *testing.T) { + host, content, hold := heldScreen(t) + prefs := tabNamed(t, host.content, text.TabPreferences()) + chooserUnder(t, prefs, text.FieldLanguage()).SetSelected(polishName(t)) + restart := buttonNamed(prefs, text.ButtonRestart()) + if restart == nil || restart.Disabled() { + t.Fatal("Polish is chosen and Restart now is not on, so this guard cannot see it stand down") + } + + dir := t.TempDir() + fill(t, content, text.FieldOutputDir(), dir) + fill(t, content, text.FieldCount(), "20000") + press(t, content, text.ButtonPreview()) + hold.look(func() { + if !restart.Disabled() { + t.Error("Restart now stayed on while the screen was busy - pressing it would close the window and stop the work") + } + if said := textIn(prefs); !strings.Contains(said, text.PreferencesRestartBusy()) { + t.Errorf("the screen does not say why Restart now is off:\n%s", said) + } + }) + join(host) + if restart.Disabled() { + t.Fatal("the work ended and Restart now stayed off") + } + + press(t, prefs, text.ButtonRestart()) + if host.restarts != 1 { + t.Errorf("Restart now asked for %d new windows", host.restarts) + } + if host.closed != 1 { + t.Errorf("Restart now closed the window %d times", host.closed) + } + if got := host.Remembered().Directory(); got != dir { + t.Errorf("the output folder written down at the restart is %q, not %q - the window did not close the way the close button closes it", got, dir) + } +} + +// Forget removes what the window keeps, says so, and the close that follows +// writes none of it back. +func TestForgetClearsWhatTheWindowKeepsAndTheCloseWritesNothingBack(t *testing.T) { + host := newFakeHost(t) + kept := host.Remembered() + kept.RememberDirectory(`C:\before`) + kept.RememberSize(fyne.NewSize(900, 700)) + kept.RememberLanguage("pl") + window.Open(host) + prefs := selectTab(t, host.content, text.TabPreferences()) + if host.kept.dir == "" || host.kept.language == "" { + t.Fatal("nothing was kept before Forget, so this guard would pass on a button that does nothing") + } + + press(t, prefs, text.ButtonForget()) + if host.kept.forgets != 1 { + t.Errorf("Forget reached the store %d times", host.kept.forgets) + } + if host.kept.dir != "" || host.kept.size != (fyne.Size{}) || host.kept.language != "" { + t.Errorf("after Forget the store still holds %q, %v and %q", host.kept.dir, host.kept.size, host.kept.language) + } + if forget := buttonNamed(prefs, text.ButtonForget()); forget == nil || !forget.Disabled() { + t.Error("Forget can still be pressed after it was, with nothing left to forget") + } + if said := textIn(prefs); !strings.Contains(said, text.PreferencesForgotten()) { + t.Errorf("the screen does not say it forgot:\n%s", said) + } + if got, want := chooserUnder(t, prefs, text.FieldLanguage()).Selected, text.ChoiceSameAsSystem(text.LanguageName()); got != want { + t.Errorf("after Forget the list shows %q, and with nothing chosen the window speaks %q", got, want) + } + + // The close button, as a person presses it. + host.intercept() + if host.kept.dir != "" { + t.Errorf("closing the window after Forget wrote the folder %q back", host.kept.dir) + } + // And the size, which the real window writes after the close rather than + // in it. A fresh store writes one - the control, without which a store + // that writes nothing at all would pass - and the window's own store, + // told to forget, does not. + window.Forgetting(host.kept).RememberSize(fyne.NewSize(1, 1)) + if host.kept.size != fyne.NewSize(1, 1) { + t.Fatalf("a store that was never told to forget did not write a size, so the next check proves nothing") + } + host.Remembered().RememberSize(fyne.NewSize(800, 600)) + if host.kept.size != fyne.NewSize(1, 1) { + t.Errorf("after Forget the window's store wrote a size of %v", host.kept.size) + } +} diff --git a/internal/guard/reachability_test.go b/internal/guard/reachability_test.go index 2cc56323..cb9b1ae8 100644 --- a/internal/guard/reachability_test.go +++ b/internal/guard/reachability_test.go @@ -57,7 +57,7 @@ func laidOutWindow(t *testing.T) (fyne.CanvasObject, fyne.Canvas) { // looking at three of the four. Passing by not looking is worse than failing. // TestEveryTabInTheWindowIsOnTheListGuardsWalk keeps it honest. func allTabs() []string { - return []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), text.TabAbout()} + return []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), text.TabPreferences(), text.TabAbout()} } // What this defends. A button a person can see is a button a person can press. @@ -289,7 +289,16 @@ func TestTabbingReachesTheControlsAndSaysInWhatOrder(t *testing.T) { reachedWords[word.Text()] = true } } - if !reachedChrome { + // Every screen but Preferences, by the owner's decision of + // 2026-09-29: that screen holds how the window speaks and what it + // keeps, and asks for nothing. Held both ways, so the day a Donate + // button stands on it this exemption stops being true out loud. + withoutDonate := tab == text.TabPreferences() + if withoutDonate && buttonNamed(screen, text.ButtonDonate()) != nil { + t.Errorf("the %q screen holds a %q button and is still named as the screen without one", + tab, text.ButtonDonate()) + } + if !reachedChrome && !withoutDonate { t.Errorf("the %q button cannot be reached with Tab from the %q screen (UX9)", text.ButtonDonate(), tab) } diff --git a/internal/guard/screenpixels_test.go b/internal/guard/screenpixels_test.go index 635214b3..e84a586b 100644 --- a/internal/guard/screenpixels_test.go +++ b/internal/guard/screenpixels_test.go @@ -312,6 +312,13 @@ func screenScenes() []screenScene { // every later step - and why it is the first in the list. {name: "catalogue", page: catalogue.Page}, {name: "about", tab: text.TabAbout()}, + // The Preferences screen at rest, and with another language chosen - + // the one state in which its main action is on and the line under it + // says what a restart does. + {name: "preferences", tab: text.TabPreferences()}, + {name: "preferences-chosen", tab: text.TabPreferences(), set: func(t *testing.T, s scene) { + menuUnder(t, s.tab, text.FieldLanguage()).SetSelected(polishName(t)) + }}, {name: "generate", tab: text.TabOneTarget()}, {name: "generate-empty", tab: text.TabOneTarget(), set: func(t *testing.T, s scene) { fillField(t, s.tab, text.FieldCount(), "0") diff --git a/internal/guard/tabidentity_test.go b/internal/guard/tabidentity_test.go new file mode 100644 index 00000000..4f8d6660 --- /dev/null +++ b/internal/guard/tabidentity_test.go @@ -0,0 +1,95 @@ +package guard + +import ( + "os" + "os/exec" + "strings" + "testing" + "testing/fstest" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" +) + +// sameWordChild puts the second half of this guard in its own process, for the +// reason translation_test.go gives: loading a catalogue changes the words for +// everything that runs after it. +const sameWordChild = "TFG_SAME_WORD_CHILD" + +// TestTwoTabsCalledTheSameAreStillTwoScreens holds the window to knowing its +// screens by what they are rather than by what they are called. +// +// The window carried the output directory and the keyboard between screens +// through maps keyed by the word on each tab until 2026-09-29. In English every +// word is different and nothing could tell. A translation is free to call two +// screens by one word, and then the two became one entry: moving between them +// carried the directory from a screen to itself, and the box on the other +// screen stayed where it was without a word. +// +// So this gives two tabs the same word and moves between them the way a person +// does, by the tab rather than by its name - a lookup by name cannot tell them +// apart, which is the whole point. +func TestTwoTabsCalledTheSameAreStillTwoScreens(t *testing.T) { + if os.Getenv(sameWordChild) == "1" { + twoTabsCalledTheSame(t) + return + } + + run := exec.Command(os.Args[0], + "-test.run=^TestTwoTabsCalledTheSameAreStillTwoScreens$", "-test.v") + run.Env = append(os.Environ(), sameWordChild+"=1") + out, err := run.CombinedOutput() + if err != nil { + t.Fatalf("two tabs called the same were not two screens:\n%s", out) + } + if !strings.Contains(string(out), "PASS") { + t.Fatalf("the child said nothing about passing, which means it never ran:\n%s", out) + } +} + +// sameWordCatalogue calls the first two screens by one word. +const sameWordCatalogue = `{ + "TabOneTarget": { "other": "EKRAN" }, + "TabPresets": { "other": "EKRAN" } +}` + +func twoTabsCalledTheSame(t *testing.T) { + t.Helper() + + made := fstest.MapFS{ + "locale/pl.json": &fstest.MapFile{Data: []byte(sameWordCatalogue)}, + } + if err := text.Load(made, "locale", "pl"); err != nil { + t.Fatalf("the catalogue would not load: %v", err) + } + + host := newFakeHost(t) + window.Open(host) + tabs := tabsIn(host.content) + if tabs == nil || len(tabs.Items()) < 2 { + t.Fatal("the window has fewer than two tabs") + } + generate, preset := tabs.Items()[0], tabs.Items()[1] + // In the state this guard is about, or it proves nothing: two tabs whose + // words are the same, and the ones the catalogue named. + if generate.Text != "EKRAN" || preset.Text != "EKRAN" { + t.Fatalf("the first two tabs are called %q and %q, not the one word the catalogue gave both", + generate.Text, preset.Text) + } + + const chosen = "C:\\somewhere\\else" + fill(t, generate.Content, text.FieldOutputDir(), chosen) + tabs.Select(preset) + if got := entryUnder(t, preset.Content, text.FieldOutputDir()).Text; got != chosen { + t.Errorf("the first screen was pointed at %q and the second, called by the same word, says %q", + chosen, got) + } + + const second = "C:\\third\\place" + fill(t, preset.Content, text.FieldOutputDir(), second) + tabs.Select(generate) + if got := entryUnder(t, generate.Content, text.FieldOutputDir()).Text; got != second { + t.Errorf("the second screen was pointed at %q and the first, called by the same word, says %q", + second, got) + } +} diff --git a/internal/guard/testdata/screens/about.png b/internal/guard/testdata/screens/about.png index e269768c..94100cf1 100644 Binary files a/internal/guard/testdata/screens/about.png and b/internal/guard/testdata/screens/about.png differ diff --git a/internal/guard/testdata/screens/about.xml b/internal/guard/testdata/screens/about.xml index 66cc6ec8..da8df10f 100644 --- a/internal/guard/testdata/screens/about.xml +++ b/internal/guard/testdata/screens/about.xml @@ -20,12 +20,17 @@ Several batches - + + + + Preferences + + About - + diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index 5d65aeae..02e5835d 100644 Binary files a/internal/guard/testdata/screens/catalogue.png and b/internal/guard/testdata/screens/catalogue.png differ diff --git a/internal/guard/testdata/screens/catalogue.xml b/internal/guard/testdata/screens/catalogue.xml index 532e3b89..091b154e 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -1733,7 +1733,12 @@ Several batches - + + + + Preferences + + About @@ -1765,7 +1770,12 @@ Several batches - + + + + Preferences + + About @@ -1797,7 +1807,12 @@ Several batches - + + + + Preferences + + About @@ -1833,7 +1848,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png index 747b28da..7c698f60 100644 Binary files a/internal/guard/testdata/screens/generate-chosen-by-key.png and b/internal/guard/testdata/screens/generate-chosen-by-key.png differ diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.xml b/internal/guard/testdata/screens/generate-chosen-by-key.xml index a992ad94..5803c33f 100644 --- a/internal/guard/testdata/screens/generate-chosen-by-key.xml +++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-chosen.png b/internal/guard/testdata/screens/generate-chosen.png index 2616cfaf..302b01a3 100644 Binary files a/internal/guard/testdata/screens/generate-chosen.png and b/internal/guard/testdata/screens/generate-chosen.png differ diff --git a/internal/guard/testdata/screens/generate-chosen.xml b/internal/guard/testdata/screens/generate-chosen.xml index 11eeead6..c2df00d8 100644 --- a/internal/guard/testdata/screens/generate-chosen.xml +++ b/internal/guard/testdata/screens/generate-chosen.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-empty.png b/internal/guard/testdata/screens/generate-empty.png index a04a2332..5fc52e68 100644 Binary files a/internal/guard/testdata/screens/generate-empty.png and b/internal/guard/testdata/screens/generate-empty.png differ diff --git a/internal/guard/testdata/screens/generate-empty.xml b/internal/guard/testdata/screens/generate-empty.xml index e61f4dea..840e63e7 100644 --- a/internal/guard/testdata/screens/generate-empty.xml +++ b/internal/guard/testdata/screens/generate-empty.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png index 2395d12e..d38f627c 100644 Binary files a/internal/guard/testdata/screens/generate-focused.png and b/internal/guard/testdata/screens/generate-focused.png differ diff --git a/internal/guard/testdata/screens/generate-focused.xml b/internal/guard/testdata/screens/generate-focused.xml index fc1de54b..ec42ecff 100644 --- a/internal/guard/testdata/screens/generate-focused.xml +++ b/internal/guard/testdata/screens/generate-focused.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-hovered.png b/internal/guard/testdata/screens/generate-hovered.png index f1e8a534..1ac3ae18 100644 Binary files a/internal/guard/testdata/screens/generate-hovered.png and b/internal/guard/testdata/screens/generate-hovered.png differ diff --git a/internal/guard/testdata/screens/generate-hovered.xml b/internal/guard/testdata/screens/generate-hovered.xml index c4ae8fb7..82a1af08 100644 --- a/internal/guard/testdata/screens/generate-hovered.xml +++ b/internal/guard/testdata/screens/generate-hovered.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index b57bb848..f98b80e6 100644 Binary files a/internal/guard/testdata/screens/generate-menu-hovered.png and b/internal/guard/testdata/screens/generate-menu-hovered.png differ diff --git a/internal/guard/testdata/screens/generate-menu-hovered.xml b/internal/guard/testdata/screens/generate-menu-hovered.xml index dbcc2562..a4f92426 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index d7423353..76a90698 100644 Binary files a/internal/guard/testdata/screens/generate-menu-keyed.png and b/internal/guard/testdata/screens/generate-menu-keyed.png differ diff --git a/internal/guard/testdata/screens/generate-menu-keyed.xml b/internal/guard/testdata/screens/generate-menu-keyed.xml index 0d1c3dea..2eab3580 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index eb264844..215641bd 100644 Binary files a/internal/guard/testdata/screens/generate-menu.png and b/internal/guard/testdata/screens/generate-menu.png differ diff --git a/internal/guard/testdata/screens/generate-menu.xml b/internal/guard/testdata/screens/generate-menu.xml index 3455ff18..070021ec 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-pdf-settings.png b/internal/guard/testdata/screens/generate-pdf-settings.png index fafbac7e..3e3a537b 100644 Binary files a/internal/guard/testdata/screens/generate-pdf-settings.png and b/internal/guard/testdata/screens/generate-pdf-settings.png differ diff --git a/internal/guard/testdata/screens/generate-pdf-settings.xml b/internal/guard/testdata/screens/generate-pdf-settings.xml index 3bc07653..464140fa 100644 --- a/internal/guard/testdata/screens/generate-pdf-settings.xml +++ b/internal/guard/testdata/screens/generate-pdf-settings.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png index c7339582..d565c944 100644 Binary files a/internal/guard/testdata/screens/generate-refused-both.png and b/internal/guard/testdata/screens/generate-refused-both.png differ diff --git a/internal/guard/testdata/screens/generate-refused-both.xml b/internal/guard/testdata/screens/generate-refused-both.xml index 92e51b3f..84902247 100644 --- a/internal/guard/testdata/screens/generate-refused-both.xml +++ b/internal/guard/testdata/screens/generate-refused-both.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png index 2d90a20d..e3ff14e2 100644 Binary files a/internal/guard/testdata/screens/generate-refused-setting.png and b/internal/guard/testdata/screens/generate-refused-setting.png differ diff --git a/internal/guard/testdata/screens/generate-refused-setting.xml b/internal/guard/testdata/screens/generate-refused-setting.xml index 204b1b8f..406109cc 100644 --- a/internal/guard/testdata/screens/generate-refused-setting.xml +++ b/internal/guard/testdata/screens/generate-refused-setting.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png index 1e7be301..bafaac1f 100644 Binary files a/internal/guard/testdata/screens/generate-refused.png and b/internal/guard/testdata/screens/generate-refused.png differ diff --git a/internal/guard/testdata/screens/generate-refused.xml b/internal/guard/testdata/screens/generate-refused.xml index 470072f5..565810be 100644 --- a/internal/guard/testdata/screens/generate-refused.xml +++ b/internal/guard/testdata/screens/generate-refused.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png index edde73ca..16eed34b 100644 Binary files a/internal/guard/testdata/screens/generate-switch-by-key.png and b/internal/guard/testdata/screens/generate-switch-by-key.png differ diff --git a/internal/guard/testdata/screens/generate-switch-by-key.xml b/internal/guard/testdata/screens/generate-switch-by-key.xml index 56842a99..7a73a883 100644 --- a/internal/guard/testdata/screens/generate-switch-by-key.xml +++ b/internal/guard/testdata/screens/generate-switch-by-key.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-typed.png b/internal/guard/testdata/screens/generate-typed.png index 42c70beb..aa409394 100644 Binary files a/internal/guard/testdata/screens/generate-typed.png and b/internal/guard/testdata/screens/generate-typed.png differ diff --git a/internal/guard/testdata/screens/generate-typed.xml b/internal/guard/testdata/screens/generate-typed.xml index 94e405dd..4ba8b473 100644 --- a/internal/guard/testdata/screens/generate-typed.xml +++ b/internal/guard/testdata/screens/generate-typed.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate-unchecked.png b/internal/guard/testdata/screens/generate-unchecked.png index 8fbb17e8..28313db8 100644 Binary files a/internal/guard/testdata/screens/generate-unchecked.png and b/internal/guard/testdata/screens/generate-unchecked.png differ diff --git a/internal/guard/testdata/screens/generate-unchecked.xml b/internal/guard/testdata/screens/generate-unchecked.xml index ba0e56f0..40468838 100644 --- a/internal/guard/testdata/screens/generate-unchecked.xml +++ b/internal/guard/testdata/screens/generate-unchecked.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png index 57c0292a..a69ba375 100644 Binary files a/internal/guard/testdata/screens/generate.png and b/internal/guard/testdata/screens/generate.png differ diff --git a/internal/guard/testdata/screens/generate.xml b/internal/guard/testdata/screens/generate.xml index 1661e87c..833545e6 100644 --- a/internal/guard/testdata/screens/generate.xml +++ b/internal/guard/testdata/screens/generate.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/preferences-chosen.png b/internal/guard/testdata/screens/preferences-chosen.png new file mode 100644 index 00000000..878afb64 Binary files /dev/null and b/internal/guard/testdata/screens/preferences-chosen.png differ diff --git a/internal/guard/testdata/screens/preferences-chosen.xml b/internal/guard/testdata/screens/preferences-chosen.xml new file mode 100644 index 00000000..45f19a2a --- /dev/null +++ b/internal/guard/testdata/screens/preferences-chosen.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + Preferences + + + + + About + + + + + + + + + + + + + + Preferences + + + + + + + How this window speaks, and what it keeps between runs. Nothing here changes the files it makes. + + + + + + + + + + Language + + + + + + Window language + + + + + + + Polski + + + + + + + + + + + + + Only the window changes language. The files it makes, recipes, manifests and the command line do not. + + + + + + + + Restart now + + + + + + Restarting closes this window and opens it again. Anything typed on the other tabs is cleared. + + + + + + + + + + + + What this window remembers + + + + + Between runs this window keeps three things: the output directory, the size of the window, and the language chosen + above. The folder picker also keeps the last folder it showed. Nothing leaves this computer. + + + + + + + They are kept in this folder: + + + + + + + C:\settings\of\the\window + + + + + + + + + Open folder + + + + + Forget + + + + + + + Forget clears the three things above. This window then keeps nothing when it closes, and the next one opens the way + the first one did. + + + + + + + + + + + + + diff --git a/internal/guard/testdata/screens/preferences.png b/internal/guard/testdata/screens/preferences.png new file mode 100644 index 00000000..71a15be1 Binary files /dev/null and b/internal/guard/testdata/screens/preferences.png differ diff --git a/internal/guard/testdata/screens/preferences.xml b/internal/guard/testdata/screens/preferences.xml new file mode 100644 index 00000000..b09ca547 --- /dev/null +++ b/internal/guard/testdata/screens/preferences.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + Preferences + + + + + About + + + + + + + + + + + + + + Preferences + + + + + + + How this window speaks, and what it keeps between runs. Nothing here changes the files it makes. + + + + + + + + + + Language + + + + + + Window language + + + + + + + Same as the system (English) + + + + + + + + + + + + + Only the window changes language. The files it makes, recipes, manifests and the command line do not. + + + + + + + + Restart now + + + + + + The window speaks the chosen language from the next time it opens. + + + + + + + + + + + + What this window remembers + + + + + Between runs this window keeps three things: the output directory, the size of the window, and the language chosen + above. The folder picker also keeps the last folder it showed. Nothing leaves this computer. + + + + + + + They are kept in this folder: + + + + + + + C:\settings\of\the\window + + + + + + + + + Open folder + + + + + Forget + + + + + + + Forget clears the three things above. This window then keeps nothing when it closes, and the next one opens the way + the first one did. + + + + + + + + + + + + + diff --git a/internal/guard/testdata/screens/preset-many-settings.png b/internal/guard/testdata/screens/preset-many-settings.png index a572512b..322227c7 100644 Binary files a/internal/guard/testdata/screens/preset-many-settings.png and b/internal/guard/testdata/screens/preset-many-settings.png differ diff --git a/internal/guard/testdata/screens/preset-many-settings.xml b/internal/guard/testdata/screens/preset-many-settings.xml index f83bef8e..18a1f5b9 100644 --- a/internal/guard/testdata/screens/preset-many-settings.xml +++ b/internal/guard/testdata/screens/preset-many-settings.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png index bcd89ca4..a1313903 100644 Binary files a/internal/guard/testdata/screens/preset-menu-setting.png and b/internal/guard/testdata/screens/preset-menu-setting.png differ diff --git a/internal/guard/testdata/screens/preset-menu-setting.xml b/internal/guard/testdata/screens/preset-menu-setting.xml index 660cfcfc..241f2fda 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png index 68ad1acc..626e5e5e 100644 Binary files a/internal/guard/testdata/screens/preset-menu.png and b/internal/guard/testdata/screens/preset-menu.png differ diff --git a/internal/guard/testdata/screens/preset-menu.xml b/internal/guard/testdata/screens/preset-menu.xml index 682b3b60..1a9f5649 100644 --- a/internal/guard/testdata/screens/preset-menu.xml +++ b/internal/guard/testdata/screens/preset-menu.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/preset-refused.png b/internal/guard/testdata/screens/preset-refused.png index 07d43e1d..1afc76f3 100644 Binary files a/internal/guard/testdata/screens/preset-refused.png and b/internal/guard/testdata/screens/preset-refused.png differ diff --git a/internal/guard/testdata/screens/preset-refused.xml b/internal/guard/testdata/screens/preset-refused.xml index 31ec0787..f28e48cf 100644 --- a/internal/guard/testdata/screens/preset-refused.xml +++ b/internal/guard/testdata/screens/preset-refused.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/preset.png b/internal/guard/testdata/screens/preset.png index 945b44d7..983311cf 100644 Binary files a/internal/guard/testdata/screens/preset.png and b/internal/guard/testdata/screens/preset.png differ diff --git a/internal/guard/testdata/screens/preset.xml b/internal/guard/testdata/screens/preset.xml index 3116e0f4..a18a6e27 100644 --- a/internal/guard/testdata/screens/preset.xml +++ b/internal/guard/testdata/screens/preset.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/recipe-contents.png b/internal/guard/testdata/screens/recipe-contents.png index 7861de54..e84d0129 100644 Binary files a/internal/guard/testdata/screens/recipe-contents.png and b/internal/guard/testdata/screens/recipe-contents.png differ diff --git a/internal/guard/testdata/screens/recipe-contents.xml b/internal/guard/testdata/screens/recipe-contents.xml index af3764dc..3546ddd8 100644 --- a/internal/guard/testdata/screens/recipe-contents.xml +++ b/internal/guard/testdata/screens/recipe-contents.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.png b/internal/guard/testdata/screens/recipe-on-a-preset.png index b1c55e55..a7fa5de7 100644 Binary files a/internal/guard/testdata/screens/recipe-on-a-preset.png and b/internal/guard/testdata/screens/recipe-on-a-preset.png differ diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.xml b/internal/guard/testdata/screens/recipe-on-a-preset.xml index 7c9893a7..0056217b 100644 --- a/internal/guard/testdata/screens/recipe-on-a-preset.xml +++ b/internal/guard/testdata/screens/recipe-on-a-preset.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.png b/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.png index d419798c..55d053fa 100644 Binary files a/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.png and b/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.png differ diff --git a/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.xml b/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.xml index 3a3ca063..5d12c8ac 100644 --- a/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.xml +++ b/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/recipe-refused.png b/internal/guard/testdata/screens/recipe-refused.png index dea2f00f..766c4e60 100644 Binary files a/internal/guard/testdata/screens/recipe-refused.png and b/internal/guard/testdata/screens/recipe-refused.png differ diff --git a/internal/guard/testdata/screens/recipe-refused.xml b/internal/guard/testdata/screens/recipe-refused.xml index b2e55909..7254e8d1 100644 --- a/internal/guard/testdata/screens/recipe-refused.xml +++ b/internal/guard/testdata/screens/recipe-refused.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/recipe-two-batches.png b/internal/guard/testdata/screens/recipe-two-batches.png index 613c9e9e..2af13213 100644 Binary files a/internal/guard/testdata/screens/recipe-two-batches.png and b/internal/guard/testdata/screens/recipe-two-batches.png differ diff --git a/internal/guard/testdata/screens/recipe-two-batches.xml b/internal/guard/testdata/screens/recipe-two-batches.xml index 6fc25efe..bf39469c 100644 --- a/internal/guard/testdata/screens/recipe-two-batches.xml +++ b/internal/guard/testdata/screens/recipe-two-batches.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/testdata/screens/recipe.png b/internal/guard/testdata/screens/recipe.png index 8ae9a8b8..be994474 100644 Binary files a/internal/guard/testdata/screens/recipe.png and b/internal/guard/testdata/screens/recipe.png differ diff --git a/internal/guard/testdata/screens/recipe.xml b/internal/guard/testdata/screens/recipe.xml index 096b1d1c..7dd6fce5 100644 --- a/internal/guard/testdata/screens/recipe.xml +++ b/internal/guard/testdata/screens/recipe.xml @@ -20,7 +20,12 @@ Several batches - + + + + Preferences + + About diff --git a/internal/guard/window_test.go b/internal/guard/window_test.go index b483f11f..7275b12d 100644 --- a/internal/guard/window_test.go +++ b/internal/guard/window_test.go @@ -57,6 +57,10 @@ import ( type fakeHost struct { content fyne.CanvasObject intercept func() + // system is what SystemLanguage answers, and restarts how many times + // Restart now asked for a new window. + system string + restarts int // waitForWork waits for a preview or a run to finish, without stopping it. // // Separate from intercept, which is the CLOSE intercept and now cancels. @@ -85,7 +89,8 @@ type fakeHost struct { file string fileCount int - kept *keptInMemory + kept *keptInMemory + remembered window.Remembered // hold parks the worker just before it reports, so that a guard can read // the screen while a run is going. Nil unless a guard asked for one. @@ -401,13 +406,21 @@ func (h *fakeHost) Remembered() window.Remembered { if h.kept == nil { h.kept = &keptInMemory{} } - return h.kept + // Wrapped once and kept, like the real window's store: the promise + // Forget makes lives in the wrapper, and a new one each call would + // forget that it was ever asked to forget. + if h.remembered == nil { + h.remembered = window.Forgetting(h.kept) + } + return h.remembered } type keptInMemory struct { dir string size fyne.Size dirWrite int + language string + forgets int } func (k *keptInMemory) Directory() string { return k.dir } @@ -415,8 +428,34 @@ func (k *keptInMemory) RememberDirectory(d string) { k.dir = d k.dirWrite++ } -func (k *keptInMemory) Size() fyne.Size { return k.size } -func (k *keptInMemory) RememberSize(s fyne.Size) { k.size = s } +func (k *keptInMemory) Size() fyne.Size { return k.size } +func (k *keptInMemory) RememberSize(s fyne.Size) { k.size = s } +func (k *keptInMemory) Language() string { return k.language } +func (k *keptInMemory) RememberLanguage(tag string) { k.language = tag } + +// Forget removes the values, as the real store does. What it promises for +// the rest of the run is window.Forgetting's, which Remembered wraps this in. +func (k *keptInMemory) Forget() { + k.dir, k.size, k.language = "", fyne.Size{}, "" + k.forgets++ +} + +// SettingsFolder is a made up folder, so a guard can find it on the screen. +func (h *fakeHost) SettingsFolder() string { return fakeSettingsFolder } + +// SystemLanguage is what a guard set, English when it set nothing - the +// answer the toolkit gives a system that says nothing usable. +func (h *fakeHost) SystemLanguage() string { + if h.system == "" { + return "en" + } + return h.system +} + +// RestartWhenClosed counts the asks, for the guard that presses Restart now. +func (h *fakeHost) RestartWhenClosed() { h.restarts++ } + +const fakeSettingsFolder = `C:\settings\of\the\window` func (h *fakeHost) ChooseDirectory(chosen func(string)) { h.asked++ @@ -838,9 +877,15 @@ func unringed(o fyne.CanvasObject) fyne.CanvasObject { // it - the render probe said there was no Format menu to open, which was // untrue and read exactly like a defect. if len(box.Objects) == 1 { - if nested, is := box.Objects[0].(*fyne.Container); is { - box = nested + nested, is := box.Objects[0].(*fyne.Container) + if !is { + // The width wrapper round a control with no edge in it: a menu + // on a row that nothing can refuse - Named, which the language + // list on the Preferences screen is since 2026-09-29. The + // control is the one thing inside. + return box.Objects[0] } + box = nested } if len(box.Objects) != 2 { return o diff --git a/internal/guard/windowlanguage_test.go b/internal/guard/windowlanguage_test.go new file mode 100644 index 00000000..9f77519f --- /dev/null +++ b/internal/guard/windowlanguage_test.go @@ -0,0 +1,321 @@ +package guard + +import ( + "encoding/binary" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + "unicode" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/font" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" +) + +// The languages of the window - docs/USTAWIENIA-2026-09-29.md, and the +// owner's decision of that day that the window speaks the system's language +// when nobody chose one (docs/PRODUCT.md D9). + +// The language the window speaks is decided the way the owner decided it: a +// choice first, then the system, then English - and matched rather than +// compared, because the system says pl-PL where the catalogue says pl. +func TestTheWindowSpeaksTheLanguageItWasToldTo(t *testing.T) { + carried := []text.Language{{Tag: "en"}, {Tag: "pl"}, {Tag: "pt"}, {Tag: "zh"}} + for _, c := range []struct { + why string + saved, system string + speaks, missing string + }{ + {"nothing chosen, a Polish system", "", "pl-PL", "pl", ""}, + {"nothing chosen, a British system", "", "en-GB", "en", ""}, + {"nothing chosen, a Brazilian system", "", "pt-BR", "pt", ""}, + {"nothing chosen, a system in a language the window lacks", "", "de-DE", "en", ""}, + {"nothing chosen, a system that says nothing", "", "", "en", ""}, + {"nothing chosen, the C locale", "", "C", "en", ""}, + // A person who reads traditional Chinese is not served by a catalogue + // in the simplified script, and English is the better answer. + {"nothing chosen, a Taiwanese system and simplified Chinese carried", "", "zh-TW", "en", ""}, + {"Polish chosen on a German system", "pl", "de-DE", "pl", ""}, + {"English chosen on a Polish system", "en", "pl-PL", "en", ""}, + {"a choice this build lacks, on a Polish system", "de", "pl-PL", "pl", "de"}, + {"a choice that is not a language at all", "not a tag!", "", "en", "not a tag!"}, + } { + got := text.Resolve(c.saved, c.system, carried) + if got.Tag != c.speaks || got.Missing != c.missing { + t.Errorf("%s: the window speaks %q and says %q is missing, and it should speak %q and say %q", + c.why, got.Tag, got.Missing, c.speaks, c.missing) + } + } +} + +// catalogueFiles is every catalogue compiled into the window, by tag. +func catalogueFiles(t *testing.T) map[string]map[string]map[string]string { + t.Helper() + paths, err := filepath.Glob(filepath.Join(localeDir, "*.json")) + if err != nil || len(paths) == 0 { + t.Fatalf("no catalogue in %s: %v", localeDir, err) + } + out := map[string]map[string]map[string]string{} + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + var entries map[string]map[string]string + if err := json.Unmarshal(raw, &entries); err != nil { + t.Fatalf("%s is not a catalogue: %v", path, err) + } + out[strings.TrimSuffix(filepath.Base(path), ".json")] = entries + } + return out +} + +var catalogueField = regexp.MustCompile(`\{\{\s*\.([A-Za-z0-9]+)\s*\}\}`) + +// Every language says everything the window says, with the values it is +// handed, in every form its numbers need. +// +// A missing entry is not a crash - the English answers for it - and that is +// exactly why it needs a guard: a Polish window with one English button in it +// is a defect nobody sees in English. A missing plural form is the same shape +// one level down: go-i18n refuses the number, and the window falls back to the +// English sentence for "2 files" in the middle of a Polish screen. +func TestEveryLanguageSaysEverythingTheWindowSays(t *testing.T) { + files := catalogueFiles(t) + english, ok := files[text.English] + if !ok { + t.Fatal("there is no English catalogue to hold the others to") + } + if len(files) < 2 { + t.Fatal("the build carries one language, so the list on the Preferences screen offers nothing to choose") + } + names := map[string]string{} + for tag, entries := range files { + name := entries["LanguageName"]["other"] + if name == "" || (tag != text.English && name == english["LanguageName"]["other"]) { + t.Errorf("%s.json does not name its own language, so the list would show it as %q", tag, name) + } + if other, taken := names[name]; taken { + t.Errorf("%s.json and %s.json both call themselves %q, and the list could not tell them apart", tag, other, name) + } + names[name] = tag + if tag == text.English { + continue + } + for id, want := range english { + entry, has := entries[id] + if !has || entry["other"] == "" { + t.Errorf("%s.json has no %s, so the window says it in English", tag, id) + continue + } + given := map[string]bool{} + for _, f := range catalogueField.FindAllStringSubmatch(want["other"]+want["one"], -1) { + given[f[1]] = true + } + for form, sentence := range entry { + for _, f := range catalogueField.FindAllStringSubmatch(sentence, -1) { + if !given[f[1]] { + t.Errorf("%s.json %s (%s) asks for {{.%s}}, which the window never hands it - it would show ", + tag, id, form, f[1]) + } + } + } + if _, plural := want["one"]; plural { + everyNumberHasItsForm(t, tag, id, entry) + } + } + for id := range entries { + if _, known := english[id]; !known { + t.Errorf("%s.json carries %s, which the window no longer says", tag, id) + } + } + } +} + +// everyNumberHasItsForm asks go-i18n for the entry at every count up to 125, +// which covers every rule CLDR has for the languages this window may speak. +func everyNumberHasItsForm(t *testing.T, tag, id string, entry map[string]string) { + t.Helper() + raw, err := json.Marshal(map[string]map[string]string{id: entry}) + if err != nil { + t.Fatal(err) + } + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + if _, err := bundle.ParseMessageFileBytes(raw, tag+".json"); err != nil { + t.Errorf("%s.json %s does not load: %v", tag, id, err) + return + } + localizer := i18n.NewLocalizer(bundle, tag) + for n := 0; n <= 125; n++ { + _, err := localizer.Localize(&i18n.LocalizeConfig{ + MessageID: id, PluralCount: n, TemplateData: map[string]any{"Count": n}, + }) + if err != nil { + t.Errorf("%s.json %s cannot say %d: %v", tag, id, n, err) + return + } + } +} + +// Every letter every language says is in the typeface the window ships. +// +// A letter the face lacks is drawn from whatever the system has, or as an +// empty box - so a language whose script the window's own face does not carry +// is a decision about the typeface, and it should be taken as one rather than +// discovered on somebody's screen. Measured on 2026-09-29: Inter carries the +// Latin, Cyrillic and Greek scripts and none of Chinese, Japanese, Korean, +// Arabic, Hebrew or Devanagari (docs/USTAWIENIA-2026-09-29.md section 1.1). +func TestEveryLetterEveryLanguageSaysIsInTheTypeface(t *testing.T) { + faces := map[string]map[rune]bool{ + "Inter-Regular": charactersIn(t, "Inter-Regular", font.Regular), + "Inter-Bold": charactersIn(t, "Inter-Bold", font.Bold), + } + for tag, entries := range catalogueFiles(t) { + missing := map[string][]string{} + for id, entry := range entries { + for form, sentence := range entry { + if form == "description" { + continue + } + for _, r := range sentence { + if unicode.IsSpace(r) { + continue + } + for face, carries := range faces { + if !carries[r] { + missing[face] = append(missing[face], string(r)+" in "+id) + } + } + } + } + } + for face, where := range missing { + sort.Strings(where) + t.Errorf("%s has no glyph for what %s.json says: %s", face, tag, strings.Join(where, ", ")) + } + } +} + +// charactersIn reads the characters a TrueType face maps, from its format 12 +// cmap subtable - the one covering the whole of Unicode, which both Inter +// files carry (fontTools 4.66, 2026-09-29: platform 3, encoding 10). Read here +// rather than through a font library, because the two in the module graph are +// there for the toolkit, and importing one from a test would change the graph +// the release gate asks about. +func charactersIn(t *testing.T, name string, face []byte) map[rune]bool { + t.Helper() + be := binary.BigEndian + if len(face) < 12 { + t.Fatalf("%s is too short to be a font", name) + } + tables := int(be.Uint16(face[4:])) + cmap := -1 + for i := 0; i < tables; i++ { + at := 12 + 16*i + if at+16 > len(face) { + break + } + if string(face[at:at+4]) == "cmap" { + cmap = int(be.Uint32(face[at+8:])) + } + } + if cmap < 0 || cmap+4 > len(face) { + t.Fatalf("%s has no cmap table", name) + } + subtables := int(be.Uint16(face[cmap+2:])) + for i := 0; i < subtables; i++ { + at := cmap + 4 + 8*i + platform, encoding := be.Uint16(face[at:]), be.Uint16(face[at+2:]) + if platform != 3 || encoding != 10 { + continue + } + sub := cmap + int(be.Uint32(face[at+4:])) + if be.Uint16(face[sub:]) != 12 { + t.Fatalf("%s maps the whole of Unicode in format %d, not 12", name, be.Uint16(face[sub:])) + } + groups := int(be.Uint32(face[sub+12:])) + out := map[rune]bool{} + for g := 0; g < groups; g++ { + at := sub + 16 + 12*g + for r := be.Uint32(face[at:]); r <= be.Uint32(face[at+4:]); r++ { + out[rune(r)] = true + } + } + // In the state this guard is about: a face that maps its own Latin. + if !out['A'] || !out['z'] || len(out) < 1000 { + t.Fatalf("%s maps %d characters and not the Latin alphabet, so this reader is wrong", name, len(out)) + } + return out + } + t.Fatalf("%s has no cmap subtable for the whole of Unicode", name) + return nil +} + +// pseudoChild puts the second half of the pseudo guard in its own process, for +// the reason translation_test.go gives. +const pseudoChild = "TFG_PSEUDO_CHILD" + +// The pseudo language disguises every word the window says and keeps every +// value it is handed. It is a tool for seeing how a translation will sit on +// each screen before one exists, and a tool that changed a path it was shown +// would make the screens it draws lie about exactly the part a person reads +// most carefully. +func TestThePseudoLanguageDisguisesTheWordsAndKeepsTheValues(t *testing.T) { + if os.Getenv(pseudoChild) == "1" { + pseudoWindow(t) + return + } + run := exec.Command(os.Args[0], + "-test.run=^TestThePseudoLanguageDisguisesTheWordsAndKeepsTheValues$", "-test.v") + run.Env = append(os.Environ(), pseudoChild+"=1") + out, err := run.CombinedOutput() + if err != nil { + t.Fatalf("the pseudo language did not hold:\n%s", out) + } + if !strings.Contains(string(out), "PASS") { + t.Fatalf("the child said nothing about passing, which means it never ran:\n%s", out) + } +} + +func pseudoWindow(t *testing.T) { + t.Helper() + if err := text.LoadBuiltIn(text.Pseudo); err != nil { + t.Fatalf("the pseudo language would not load: %v", err) + } + const where = `C:\some where\{{.Not}}` + if got := text.WillGoTo(where); !strings.Contains(got, where) { + t.Errorf("the pseudo language changed a value it was handed: %q became %q", where, got) + } + if got := text.ButtonGenerate(); got == "Generate" || !strings.HasPrefix(got, "[") { + t.Errorf("the pseudo language left a word as it was: %q", got) + } + + host := newFakeHost(t) + window.Open(host) + faces := charactersIn(t, "Inter-Regular", font.Regular) + tabs := tabsIn(host.content) + if tabs == nil { + t.Fatal("the window has no tabs") + } + for _, item := range tabs.Items() { + said := textIn(item.Content) + if strings.Contains(said, "") || strings.Contains(said, "{{") { + t.Errorf("the %s tab shows a value the pseudo language broke:\n%s", item.ID, said) + } + for _, r := range said { + if !unicode.IsSpace(r) && !faces[r] { + t.Errorf("the %s tab says %q in the pseudo language, which Inter has no glyph for", item.ID, r) + break + } + } + } +} diff --git a/internal/gui/again.go b/internal/gui/again.go index 012719d5..f40c9913 100644 --- a/internal/gui/again.go +++ b/internal/gui/again.go @@ -9,8 +9,8 @@ import ( // startAgain starts this executable again with args, waits for it, and // answers its exit code. // -// The one place in the window binary that starts a process, and the process -// it starts is itself: the path comes from os.Executable and nowhere else, +// This file is the one place in the window binary that starts a process, and +// the process it starts is itself: the path comes from os.Executable and nowhere else, // so nothing on the search path and nothing beside the program can be what // runs. The guard over shipped code that refuses every other spawn names // this file for that reason - notelemetry_test.go. @@ -27,18 +27,10 @@ import ( // process sets it for itself too, and this is the half that does not depend // on when the C runtime takes its copy. func startAgain(args []string) (int, error) { - exe, err := os.Executable() + cmd, err := ourselves(args) if err != nil { return 0, err } - // Settled rather than suppressed, for both scanners that flag a command - // built from variables: the program is this one, by the path the system - // answers for the running process, and the arguments are the ones this - // process was started with plus one flag of ours. Nothing a person typed - // chooses what runs, and nothing on the search path can be what runs. - //nolint:gosec // G204: the command is os.Executable and the arguments are our own - cmd := exec.Command(exe, args...) // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr cmd.Env = append(os.Environ(), softwareDriver+"="+softwareLLVMPipe) err = cmd.Run() var exit *exec.ExitError @@ -53,3 +45,44 @@ func startAgain(args []string) (int, error) { } return 0, nil } + +// startInstead starts this executable again with args and does not wait: the +// window that asked has already closed, and the new one takes its place. It is +// what Restart now on the Preferences screen comes to, once the window has +// gone the way it goes when a person closes it - see run. +// +// The streams are handed over as startAgain hands them, and the environment is +// this process's own, so a window drawn by the software renderer starts again +// drawn by it: the flag is among args and the driver variable is inherited. +func startInstead(args []string) error { + cmd, err := ourselves(args) + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + // Nothing here waits for it, so the handle is given back rather than + // held until this process ends. + return cmd.Process.Release() +} + +// ourselves is this program, with args. The one command this binary ever +// builds, and the one spawn the guard over shipped code forgives - both ways of +// starting again go through it, so there is one place to read what can run. +func ourselves(args []string) (*exec.Cmd, error) { + exe, err := os.Executable() + if err != nil { + return nil, err + } + // Settled rather than suppressed, for both scanners that flag a command + // built from variables: the program is this one, by the path the system + // answers for the running process, and the arguments are the ones this + // process was started with plus, at most, one flag of ours. Nothing a + // person typed chooses what runs, and nothing on the search path can be + // what runs. + //nolint:gosec // G204: the command is os.Executable and the arguments are our own + cmd := exec.Command(exe, args...) // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd, nil +} diff --git a/internal/gui/catalogue/lists.go b/internal/gui/catalogue/lists.go index 5519d542..199e915f 100644 --- a/internal/gui/catalogue/lists.go +++ b/internal/gui/catalogue/lists.go @@ -101,28 +101,31 @@ func asWideAsItsBox(values []string, list *parts.OpenList) fyne.CanvasObject { } func tabs() Entry { - four := func() *parts.Tabs { + // The strip as the window has it, five screens since the Preferences + // tab of 2026-09-29. + five := func() *parts.Tabs { return parts.NewTabs( &parts.Tab{Text: "Single batch", Content: parts.Prose("the first screen")}, &parts.Tab{Text: "Presets", Content: parts.Prose("the second screen")}, &parts.Tab{Text: "Several batches", Content: parts.Prose("the third screen")}, - &parts.Tab{Text: "About", Content: parts.Prose("the fourth screen")}, + &parts.Tab{Text: "Preferences", Content: parts.Prose("the fourth screen")}, + &parts.Tab{Text: "About", Content: parts.Prose("the fifth screen")}, ) } return Entry{Name: "Tabs", Covers: []string{"TabWord", "Tabbed"}, States: []State{ - {"the strip, first word chosen", func() fyne.CanvasObject { return four() }}, + {"the strip, first word chosen", func() fyne.CanvasObject { return five() }}, {"a word under the pointer", func() fyne.CanvasObject { - t := four() + t := five() t.Words()[1].MouseIn(&desktop.MouseEvent{}) return t }}, {"a word holding the keyboard", func() fyne.CanvasObject { - t := four() + t := five() t.Words()[2].FocusGained() return t }}, {"the strip with its screens under it", func() fyne.CanvasObject { - return parts.Tabbed(four()) + return parts.Tabbed(five()) }}, {"long words", func() fyne.CanvasObject { return parts.NewTabs( diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 26dadfa0..54f691d8 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -45,7 +45,12 @@ func Run(args []string, errOut io.Writer) int { type Launch struct { Catalogue bool SoftwareGL bool - Args []string + // Pseudo opens the window in text.Pseudo - every sentence accented and + // longer, to see how a translation will sit on each screen before one + // exists. For whoever builds the window, like the catalogue, and never + // saved as a choice. + Pseudo bool + Args []string } // SoftwareFlag asks for the software renderer. Public, because it is the way @@ -53,7 +58,10 @@ type Launch struct { // window asks for itself when it starts again. const SoftwareFlag = "--software-gl" -// ReadLaunch reads the two flags the window knows out of the launch line. +// PseudoFlag asks for the pseudo language - see Launch.Pseudo. +const PseudoFlag = "--pseudo-language" + +// ReadLaunch reads the flags the window knows out of the launch line. // // The catalogue has two spellings, because the tree writes the word one way // and the flag was named the other on the day it was decided, and a flag @@ -66,6 +74,8 @@ func ReadLaunch(args []string) Launch { launch.Catalogue = true case SoftwareFlag: launch.SoftwareGL = true + case PseudoFlag: + launch.Pseudo = true } } return launch diff --git a/internal/gui/parts/tabs.go b/internal/gui/parts/tabs.go index a2e23c51..7221f3ee 100644 --- a/internal/gui/parts/tabs.go +++ b/internal/gui/parts/tabs.go @@ -12,7 +12,14 @@ import ( ) // Tab is one screen of the window and the word on the strip that leads to it. +// +// ID is what the screen IS and Text is what it is called, and the two are +// kept apart because the second is a translation. The window used to know its +// screens by their words, so two tabs translated to the same word would have +// become one screen to everything that carries the output directory and the +// keyboard between them. type Tab struct { + ID string Text string Content fyne.CanvasObject } diff --git a/internal/gui/run_cgo.go b/internal/gui/run_cgo.go index 1857f02d..424004b4 100644 --- a/internal/gui/run_cgo.go +++ b/internal/gui/run_cgo.go @@ -14,6 +14,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/lang" "github.com/donislawdev/TestingFilesGenerator/internal/gui/catalogue" "github.com/donislawdev/TestingFilesGenerator/internal/gui/icon" @@ -78,6 +79,16 @@ type desktop struct { // because a window drawn in software is slower and the person should // be able to read why. software bool + // args are the arguments this process was started with, handed back + // unchanged when Restart now starts the program again - see run. + args []string + // restart is set by Restart now and read once the window has closed. + // A pointer because the host is copied into every screen, and the + // screen that sets it and run that reads it hold different copies. + restart *bool + // kept is the one store for the whole run, made once, because the + // promise Forget makes lives in it - see window.Forgetting. + kept window.Remembered } // SoftwareRendering reports whether this window draws through the software @@ -107,9 +118,9 @@ func (d desktop) Later(after time.Duration, then func()) func() { // dies mid write leaves a short file. That is survivable here and the toolkit // already decides it: a file that will not parse is logged and the defaults are // used, which for these two values means the window opens where it always did. -func (d desktop) Remembered() window.Remembered { return stored{fyne.CurrentApp().Preferences()} } +func (d desktop) Remembered() window.Remembered { return d.kept } -// stored puts names on the two things kept, so that no screen and no guard ever +// stored puts names on the things kept, so that no screen and no guard ever // handles a preference key. The keys are here and nowhere else. type stored struct{ prefs fyne.Preferences } @@ -117,11 +128,31 @@ const ( keyDirectory = "outputDirectory" keyWidth = "windowWidth" keyHeight = "windowHeight" + keyLanguage = "language" ) +// ours is every key this program writes, which is what Forget removes and all +// it removes. The folder picker's own keys stay: they are the toolkit's. +var ours = []string{keyDirectory, keyWidth, keyHeight, keyLanguage} + func (s stored) Directory() string { return s.prefs.String(keyDirectory) } func (s stored) RememberDirectory(d string) { s.prefs.SetString(keyDirectory, d) } +func (s stored) Language() string { return s.prefs.String(keyLanguage) } +func (s stored) RememberLanguage(tag string) { + if tag == "" { + s.prefs.RemoveValue(keyLanguage) + return + } + s.prefs.SetString(keyLanguage, tag) +} + +func (s stored) Forget() { + for _, key := range ours { + s.prefs.RemoveValue(key) + } +} + // Size is two numbers rather than one, because the toolkit's store holds // scalars. Read back as a size so that everything above this line handles a // size and not a pair. @@ -134,6 +165,19 @@ func (s stored) RememberSize(size fyne.Size) { s.prefs.SetFloat(keyHeight, float64(size.Height)) } +// SettingsFolder is the toolkit's storage root for this application, which is +// where its preferences.json lives - asked of the toolkit rather than put +// together here, so it is the folder the file is really in. +func (d desktop) SettingsFolder() string { + return filepath.FromSlash(fyne.CurrentApp().Storage().RootURI().Path()) +} + +// SystemLanguage asks the toolkit, which asks the system. The toolkit answers +// "en" when the system says nothing usable, so an answer always comes back. +func (d desktop) SystemLanguage() string { return lang.SystemLocale().LanguageString() } + +func (d desktop) RestartWhenClosed() { *d.restart = true } + // rememberThisSize writes down how big the window is now. // // It reads the CANVAS, which is the content area, and that is the same thing @@ -282,16 +326,6 @@ func run(launch Launch, errOut io.Writer) int { // the menus. See darkmenus_windows.go for what it is and what it costs. PreferDarkMenus() - // The catalogue, before the first word is asked for. A failure here is not - // a reason to refuse to start: every message states its English on the spot - // and answers with it when no catalogue is loaded, so the window opens in - // English rather than not at all. It is said out loud rather than swallowed, - // because a language silently not arriving is the shape of defect somebody - // reports as "it ignores my system settings" a year later. - if err := text.LoadBuiltIn(); err != nil { - fmt.Fprintln(errOut, text.CatalogueNotLoaded(err)) - } - // Where the toolkit says what went wrong when it cannot open its window. // It writes through the standard logger, whose stream a binary built for // the windows subsystem does not have, so the stream is kept as it was @@ -303,6 +337,14 @@ func run(launch Launch, errOut io.Writer) int { log.SetOutput(io.MultiWriter(&said, log.Writer())) a := app.NewWithID(appID) + + // The language, decided before the first word is asked for, and after the + // application exists - the choice is kept in its preferences, so the order + // of these two was turned round on 2026-09-29. + restart := false + kept := window.Forgetting(stored{prefs: a.Preferences()}) + speakTheChosenLanguage(kept, launch.Pseudo, errOut) + // The picture the desktop shows for this program, in the taskbar, in the // switcher and on the window itself - the toolkit says an application icon // is also the default icon for every window it opens, so this one line @@ -315,7 +357,7 @@ func run(launch Launch, errOut io.Writer) int { // answers dark whatever the desktop is set to, by the owner's decision. a.Settings().SetTheme(parts.Theme()) w := a.NewWindow(text.WindowTitle(version.Version)) - host := desktop{Window: w, software: software} + host := desktop{Window: w, software: software, args: launch.Args, restart: &restart, kept: kept} // The window coming to the front is not the keyboard arriving, and only // the window can tell the two apart - see WindowReturning for the // measurement. The foreground hook runs just before the driver's call, so @@ -371,7 +413,7 @@ func run(launch Launch, errOut io.Writer) int { // the person, because the window that would have carried it is what // failed - and it says what became of the renderer, when there is // something to say. - return OpenOrRefuse(w, a.Run, SecondAttemptFor(launch, errOut).Try, func(why error) { + code := OpenOrRefuse(w, a.Run, SecondAttemptFor(launch, errOut).Try, func(why error) { sentence := text.WindowRefused(CauseFrom(said.String())) fmt.Fprintln(errOut, sentence) body := sentence @@ -381,4 +423,53 @@ func run(launch Launch, errOut io.Writer) int { } sayInADialog(text.WindowRefusedTitle(), body) }) + + // Restart now, once everything the window writes at its close is written: + // the folder by the close intercept, the size by the close callback, and + // the toolkit's last save of its preferences as the loop ends. A new + // window started any earlier could read the file before them. + // + // The toolkit's own last save is inside a.Run and not after it, read in + // fyne v2.8.1: the loop queues the stopped hook that saves (app/app.go, + // forceImmediateSave), and Run waits for that queue to drain before it + // returns (internal/driver/glfw/driver.go, WaitForEvents). + if restart { + restartInstead(launch.Args, errOut) + } + return code +} + +// speakTheChosenLanguage loads the words this window speaks: a choice made on +// the Preferences screen, or the system's language when there is none, or +// English (docs/PRODUCT.md D9) - see text.Resolve. The pseudo language, when +// asked for, is never a choice and never saved. +// +// A failure to load is not a reason to refuse to start: every message states +// its English on the spot and answers with it when no catalogue is loaded, so +// the window opens in English rather than not at all. It is said out loud +// rather than swallowed, because a language silently not arriving is the shape +// of defect somebody reports as "it ignores my system settings" a year later. +func speakTheChosenLanguage(kept window.Remembered, pseudo bool, errOut io.Writer) { + speak := text.Resolve(kept.Language(), lang.SystemLocale().LanguageString(), text.Languages()).Tag + if pseudo { + speak = text.Pseudo + } + if err := text.LoadBuiltIn(speak); err != nil { + fmt.Fprintln(errOut, text.CatalogueNotLoaded(err)) + } +} + +// restartInstead starts the program again once this window has gone. The +// window is gone by now, so a start that fails is said where a window that +// could not open says so - on standard error and in a system dialog - with the +// system's reason, and the way round it: the choice is saved, and opening the +// program again gives it. +func restartInstead(args []string, errOut io.Writer) { + err := startInstead(args) + if err == nil { + return + } + sentence := text.PreferencesRestartFailed(err.Error()) + fmt.Fprintln(errOut, sentence) + sayInADialog(text.WindowTitle(version.Version), sentence) } diff --git a/internal/gui/text/catalogue.go b/internal/gui/text/catalogue.go index 4bfe34ca..651ba8e2 100644 --- a/internal/gui/text/catalogue.go +++ b/internal/gui/text/catalogue.go @@ -90,6 +90,9 @@ var localiser *i18n.Localizer // returning it raw, because a sentence with {{.Directory}} still in it is not a // fallback, it is a different defect. func sayf(id, english string, data map[string]any) string { + if pseudo { + return fill(pseudoOf(english), data) + } if localiser == nil { return fill(english, data) } @@ -120,6 +123,12 @@ func sayN(id, one, other string, count int, data map[string]any) string { for k, v := range data { values[k] = v } + if pseudo { + if count == 1 { + return fill(pseudoOf(one), values) + } + return fill(pseudoOf(other), values) + } if localiser == nil { if count == 1 { return fill(one, values) @@ -165,6 +174,9 @@ func fill(layout string, data map[string]any) string { // own, so there is one place a sentence is written and no way for a catalogue // to disagree with the code about what it says in English. func say(id, english string) string { + if pseudo { + return pseudoOf(english) + } if localiser == nil { return english } @@ -204,10 +216,30 @@ func Load(fsys fs.FS, dir string, prefer ...string) error { } } - localiser = i18n.NewLocalizer(bundle, append(prefer, "en")...) + localiser = i18n.NewLocalizer(bundle, append(prefer, English)...) + speaking = English + for _, asked := range prefer { + if carries(bundle, asked) { + speaking = asked + break + } + } return nil } +// carries says whether a catalogue was loaded for a language, so that Speaking +// names the language the window answers in rather than the one it was asked +// for - a file missing from the build is the difference. +func carries(bundle *i18n.Bundle, asked string) bool { + want := language.Make(asked) + for _, tag := range bundle.LanguageTags() { + if tag == want { + return true + } + } + return false +} + // LoadBuiltIn is Load over the catalogue compiled into this program. // // It is called once, where the window is built. Changing language after that @@ -216,10 +248,22 @@ func Load(fsys fs.FS, dir string, prefer ...string) error { // docs/GUI.md section 6 names that as its own piece of work rather than as // something a text package can do. // -// No language is preferred yet. Every message answers in English, which is the -// only language this build carries, and asking the machine which language it -// wants is the next step rather than this one - a preference nothing can honour -// is a setting that does nothing. -func LoadBuiltIn() error { - return Load(builtIn, "locale") +// The language is decided before this is called - see Resolve - and handed in +// as a tag this build carries. Empty, or English, answers in English. Pseudo +// is the language of whoever builds the window, and answers in no catalogue at +// all: every sentence is its English, disguised. +// +// Since 2026-09-29 the language is chosen on the Preferences screen and takes +// effect from the next start, which is what keeps this the one call it has +// always been. The switch without a restart remains its own piece of work, for +// the reasons in the paragraph above. +func LoadBuiltIn(lang string) error { + if lang == Pseudo { + pseudo = true + lang = "" + } + if lang == "" { + return Load(builtIn, "locale") + } + return Load(builtIn, "locale", lang) } diff --git a/internal/gui/text/languages.go b/internal/gui/text/languages.go new file mode 100644 index 00000000..42c082bd --- /dev/null +++ b/internal/gui/text/languages.go @@ -0,0 +1,245 @@ +package text + +import ( + "encoding/json" + "io/fs" + "path" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/language" +) + +// Language is one language the window can speak: the tag its catalogue file is +// named by, and its name written in itself - "Polski" rather than "Polish" - +// because that is the name a person looks for in a list they may not be able to +// read yet. +type Language struct { + Tag string + Name string +} + +// English is the language every build carries. The English is written in the +// code beside each entry, so it answers even with no catalogue at all. +const English = "en" + +// languageNameID is the catalogue entry that names the language a file is +// written in - see LanguageName in screens.go. +const languageNameID = "LanguageName" + +// Languages is every language compiled into this build, English first and the +// rest by their tags. +// +// Read from the files rather than listed here, so a language arrives by adding +// its file and nothing else. A list typed beside the files is a list that one +// day names a language that is not there, or leaves out one that is. +func Languages() []Language { + return languagesIn(builtIn, "locale") +} + +// languagesIn is Languages over any catalogue, so a guard can hand it one. +// +// A file whose name entry is missing or unreadable is listed by its tag rather +// than left out: a language somebody sees under an odd name is a defect +// somebody reports, and one that is silently missing is not. A guard holds +// every file to carrying the entry, so this is the fallback and not the plan. +func languagesIn(fsys fs.FS, dir string) []Language { + entries, err := fs.ReadDir(fsys, dir) + if err != nil { + return []Language{{Tag: English, Name: LanguageName()}} + } + var out []Language + for _, entry := range entries { + if entry.IsDir() || path.Ext(entry.Name()) != ".json" { + continue + } + tag := strings.TrimSuffix(entry.Name(), ".json") + out = append(out, Language{Tag: tag, Name: nameIn(fsys, path.Join(dir, entry.Name()), tag)}) + } + sort.SliceStable(out, func(i, j int) bool { + if (out[i].Tag == English) != (out[j].Tag == English) { + return out[i].Tag == English + } + return out[i].Tag < out[j].Tag + }) + return out +} + +// nameIn is what one catalogue file calls its own language, or the tag. +func nameIn(fsys fs.FS, file, tag string) string { + raw, err := fs.ReadFile(fsys, file) + if err != nil { + return tag + } + var entries map[string]json.RawMessage + if json.Unmarshal(raw, &entries) != nil { + return tag + } + var entry struct { + Other string `json:"other"` + } + if json.Unmarshal(entries[languageNameID], &entry) == nil && entry.Other != "" { + return entry.Other + } + return tag +} + +// Choice is the language the window speaks and how it came to it. +type Choice struct { + // Tag is one of the carried languages, English when nothing else fits. + Tag string + // Missing is a saved choice this build does not carry, and empty when + // there was none or it was found. The window says so rather than quietly + // speaking something else - untouchable rule 6. + Missing string +} + +// Resolve decides which language the window speaks. +// +// A language somebody chose comes first. Nothing chosen - the saved value is +// empty - means the language of the system, which the owner decided on +// 2026-09-29 (docs/PRODUCT.md D9). English when the system's language is not +// carried, or cannot be read at all. +// +// A choice this build does not carry is not an error: a later version may +// have dropped a language, or the file may have been edited by hand. It falls +// through to the system as if nothing were chosen, and says what was missing. +// +// Matched rather than compared, because the system says pl-PL where the +// catalogue says pl - see closest. +func Resolve(saved, system string, carried []Language) Choice { + if saved != "" { + if tag, ok := closest(saved, carried); ok { + return Choice{Tag: tag} + } + choice := fromSystem(system, carried) + choice.Missing = saved + return choice + } + return fromSystem(system, carried) +} + +func fromSystem(system string, carried []Language) Choice { + if tag, ok := closest(system, carried); ok { + return Choice{Tag: tag} + } + return Choice{Tag: English} +} + +// closest is the carried language nearest to what was asked for, when it is +// near enough. +// +// High confidence and above, and the line is drawn there on purpose: pl-PL +// finds pl and en-GB finds en, while zh-TW does not find a catalogue written +// in simplified Chinese - a person who reads one script is not served by the +// other, and English is the better answer for them. +func closest(asked string, carried []Language) (string, bool) { + want, err := language.Parse(asked) + if err != nil || len(carried) == 0 { + return "", false + } + tags := make([]language.Tag, len(carried)) + for i, l := range carried { + tags[i] = language.Make(l.Tag) + } + _, index, confidence := language.NewMatcher(tags).Match(want) + if confidence < language.High { + return "", false + } + return carried[index].Tag, true +} + +// Speaking is the language this window was opened in. +// +// Set by Load, once, before any screen is built - the same constraint as the +// localiser, and for the same reason. The Preferences screen compares it with +// the saved choice to know whether a restart would change anything. +func Speaking() string { + if speaking == "" { + return English + } + return speaking +} + +var speaking string + +// Pseudo is a language nobody speaks, for whoever builds the window. +// +// Every sentence comes out with its letters accented and about two fifths +// longer, between brackets - so every screen can be seen the way a translation +// will make it before any translation exists: a label that runs out of its +// column, a heading that pushes a button off the row, a sentence cut short +// (the closing bracket is missing), a word that was never asked for from the +// catalogue (it has no accents). Asked for with --pseudo-language and never +// offered in the list, and never saved. +const Pseudo = "pseudo" + +// pseudo is whether this window speaks Pseudo. Written once, like speaking. +var pseudo bool + +// pseudoOf is one sentence in Pseudo. +// +// The values a sentence carries - {{.Name}} - are left as they are, because +// they are filled in afterwards and a field renamed on the way is a field that +// comes out as "". An empty sentence stays empty: several entries +// are empty on purpose, and a pair of brackets where nothing should stand would +// be a defect this language made up. +func pseudoOf(sentence string) string { + if sentence == "" { + return "" + } + var b strings.Builder + b.WriteString("[") + letters := 0 + for i := 0; i < len(sentence); { + if n := valueAt(sentence[i:]); n > 0 { + b.WriteString(sentence[i : i+n]) + i += n + continue + } + r, size := utf8.DecodeRuneInString(sentence[i:]) + if accented, ok := pseudoLetters[r]; ok { + r = accented + } + if unicode.IsLetter(r) { + letters++ + } + b.WriteRune(r) + i += size + } + b.WriteString(" ") + b.WriteString(strings.Repeat(string(rune(0x00B7)), (letters*2+4)/5)) + b.WriteString("]") + return b.String() +} + +// valueAt is how long the value a sentence carries is when s starts with one, +// or 0 when it does not. One left open runs to the end of the sentence, so +// nothing after it is taken for words and accented. +func valueAt(s string) int { + if !strings.HasPrefix(s, "{{") { + return 0 + } + if end := strings.Index(s, "}}"); end >= 0 { + return end + 2 + } + return len(s) +} + +// pseudoLetters are the letters Pseudo accents, each one a letter of a real +// language the window may be translated into - so a glyph missing from the +// window's typeface shows up here first. Written as numbers because the tools +// that write this tree have turned escaped letters into other characters +// before (CLAUDE.md, trap 14). +var pseudoLetters = map[rune]rune{ + 'a': 0x00E5, 'c': 0x00E7, 'd': 0x010F, 'e': 0x00E9, 'g': 0x011F, + 'i': 0x00EE, 'k': 0x0137, 'l': 0x0142, 'n': 0x00F1, 'o': 0x00F6, + 'r': 0x0159, 's': 0x0161, 't': 0x0165, 'u': 0x00FC, 'y': 0x00FD, + 'z': 0x017E, + 'A': 0x00C5, 'C': 0x00C7, 'D': 0x010E, 'E': 0x00C9, 'G': 0x011E, + 'I': 0x00CE, 'K': 0x0136, 'L': 0x0141, 'N': 0x00D1, 'O': 0x00D6, + 'R': 0x0158, 'S': 0x0160, 'T': 0x0164, 'U': 0x00DC, 'Y': 0x00DD, + 'Z': 0x017D, +} diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index 35f95300..7dac2269 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -31,6 +31,10 @@ "description": "The words on a button.", "other": "Duplicate" }, + "ButtonForget": { + "description": "The words on a button.", + "other": "Forget" + }, "ButtonGenerate": { "description": "The words on a button.", "other": "Generate" @@ -59,6 +63,14 @@ "description": "The words on a button.", "other": "Remove" }, + "ButtonRestart": { + "description": "The words on a button.", + "other": "Restart now" + }, + "ChoiceSameAsSystem": { + "description": "An entry in a list somebody chooses from. Carries one value, {{.Language}}, which has to stay spelled exactly that way.", + "other": "Same as the system ({{.Language}})" + }, "ContentsHeading": { "description": "Shown where an archive says what it holds.", "other": "Files inside each archive" @@ -187,6 +199,10 @@ "description": "The name above a box somebody fills in.", "other": "Label in each file" }, + "FieldLanguage": { + "description": "The name above a box somebody fills in.", + "other": "Window language" + }, "FieldManifest": { "description": "The name above a box somebody fills in.", "other": "Manifest file name" @@ -325,6 +341,10 @@ "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Path}}, {{.Cause}}.", "other": "The instructions could not be saved to {{.Path}} ({{.Cause}}). The manifest was saved and holds the same facts." }, + "LanguageName": { + "description": "The name of the language this file is written in, written in that language: Polski, not Polish. It is how the language is listed on the Preferences screen.", + "other": "English" + }, "ListHeadingCount": { "description": "Shown inside an open list somebody chooses from. Carries these values, each of which has to stay spelled exactly that way: {{.Kind}}, {{.Count}}.", "other": "{{.Kind}} · {{.Count}}" @@ -405,6 +425,46 @@ "description": "Stands in an empty box, in a quieter colour than a value.", "other": "worked out from the size" }, + "PreferencesForgetWhat": { + "description": "Shown on the Preferences screen.", + "other": "Forget clears the three things above. This window then keeps nothing when it closes, and the next one opens the way the first one did." + }, + "PreferencesForgotten": { + "description": "Shown on the Preferences screen.", + "other": "Forgotten. The next window opens the way the first one did." + }, + "PreferencesKept": { + "description": "Shown on the Preferences screen.", + "other": "Between runs this window keeps three things: the output directory, the size of the window, and the language chosen above. The folder picker also keeps the last folder it showed. Nothing leaves this computer." + }, + "PreferencesKeptIn": { + "description": "Shown on the Preferences screen.", + "other": "They are kept in this folder:" + }, + "PreferencesLanguageScope": { + "description": "Shown on the Preferences screen.", + "other": "Only the window changes language. The files it makes, recipes, manifests and the command line do not." + }, + "PreferencesMissing": { + "description": "Shown on the Preferences screen. Carries one value, {{.Tag}}, which has to stay spelled exactly that way.", + "other": "The language chosen before, {{.Tag}}, is not in this version. The window follows the system until you choose again." + }, + "PreferencesNextStart": { + "description": "Shown on the Preferences screen.", + "other": "The window speaks the chosen language from the next time it opens." + }, + "PreferencesRestartBusy": { + "description": "Shown on the Preferences screen.", + "other": "Files are being made. Restart once they are done." + }, + "PreferencesRestartClears": { + "description": "Shown on the Preferences screen.", + "other": "Restarting closes this window and opens it again. Anything typed on the other tabs is cleared." + }, + "PreferencesRestartFailed": { + "description": "Shown on the Preferences screen. Carries one value, {{.Reason}}, which has to stay spelled exactly that way.", + "other": "The window could not start again: {{.Reason}}. The language chosen is saved. Open the program again to see it." + }, "PresetCatchesHeading": { "description": "Shown on the preset screen.", "other": "Typically finds:" @@ -477,6 +537,10 @@ "description": "The heading over a group of fields.", "other": "How to use it" }, + "SectionLanguage": { + "description": "The heading over a group of fields.", + "other": "Language" + }, "SectionLicence": { "description": "The heading over a group of fields.", "other": "Licence" @@ -493,6 +557,10 @@ "description": "The heading over a group of fields.", "other": "The question" }, + "SectionRemembered": { + "description": "The heading over a group of fields.", + "other": "What this window remembers" + }, "SectionSettings": { "description": "The heading over a group of fields.", "other": "Settings" @@ -541,6 +609,10 @@ "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", "other": "Files of one format and one size, with a manifest that says how the system under test should react to them." }, + "SubtitlePreferences": { + "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", + "other": "How this window speaks, and what it keeps between runs. Nothing here changes the files it makes." + }, "SubtitlePreset": { "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", "other": "Ready-made sets of files, each built to answer one question about the system under test." @@ -557,6 +629,10 @@ "description": "The name of a tab along the top of the window.", "other": "Single batch" }, + "TabPreferences": { + "description": "The name of a tab along the top of the window.", + "other": "Preferences" + }, "TabPresets": { "description": "The name of a tab along the top of the window.", "other": "Presets" diff --git a/internal/gui/text/locale/pl.json b/internal/gui/text/locale/pl.json new file mode 100644 index 00000000..b837855b --- /dev/null +++ b/internal/gui/text/locale/pl.json @@ -0,0 +1,173 @@ +{ + "AboutTagline": { "other": "Generuj pliki testowe i wiedz, jak system pod testem powinien na nie zareagować." }, + "BatchHeading": { "other": "Partia {{.Number}}" }, + "ButtonAddBatch": { "other": "Dodaj partię" }, + "ButtonAddContents": { "other": "Dodaj pliki do środka" }, + "ButtonCancel": { "other": "Anuluj" }, + "ButtonChoose": { "other": "Wybierz..." }, + "ButtonDonate": { "other": "Wesprzyj" }, + "ButtonDuplicateBatch": { "other": "Powiel" }, + "ButtonForget": { "other": "Zapomnij" }, + "ButtonGenerate": { "other": "Generuj" }, + "ButtonOpenFolder": { "other": "Otwórz folder" }, + "ButtonOpenInstructions": { "other": "Otwórz instrukcje" }, + "ButtonOpenManifest": { "other": "Otwórz manifest" }, + "ButtonPreview": { "other": "Podgląd" }, + "ButtonRemoveBatch": { "other": "Usuń" }, + "ButtonRemoveContents": { "other": "Usuń" }, + "ButtonRestart": { "other": "Uruchom ponownie" }, + "ChoiceSameAsSystem": { "other": "Jak w systemie ({{.Language}})" }, + "ContentsHeading": { "other": "Pliki w każdym archiwum" }, + "DamageNone": { "other": "brak" }, + "DamageSettingsFor": { "other": "Ustawienia dla {{.Damage}}" }, + "DetailBasePreset": { "other": "Ustawienia pod nim należą do presetu. Puste bierze swoją wartość domyślną, a manifest zapisuje, które tak zrobiły." }, + "DetailBoundary": { "other": "Podaj limit, który deklaruje twój system, na przykład 10mb. Jednostki liczą się po 1024, a przebieg wypisuje liczbę, której użył." }, + "DetailBuildOnPreset": { "other": "Bez zaznaczenia partie poniżej są całym przepisem. Z zaznaczeniem najpierw idą pliki wybranego presetu, a partie są dodawane po nich." }, + "DetailDamage": { "other": "Pliki mają rozmiar, o który prosisz, i żaden czytnik ich nie przyjmie - a właśnie to walidator ma odrzucić. Manifest zapisuje, co zostało zepsute, i mówi, że plik powinien zostać odrzucony." }, + "DetailDonate": { "other": "Otwiera stronę wsparcia w przeglądarce. Narzędzie jest darmowe i takie zostanie - to płaci za czas, który w nie idzie." }, + "DetailExpected": { "other": "Trafia do manifestu i nic innego go nie czyta. Zostaw puste tam, gdzie właściwa odpowiedź zależy od polityki samej aplikacji." }, + "DetailGroup": { "other": "Trafia do manifestu, więc test może sprawdzić całą klasę przypadków naraz." }, + "DetailLabel": { "other": "Zapisuje w pliku, czym jest i jaki miał mieć rozmiar. Wyłącz dla pliku, który ma zawierać wyłącznie swoją treść." }, + "DetailManifest": { "other": "Trafia do katalogu wyjściowego obok plików." }, + "DetailNameTemplate": { "other": "Puste - pliki dostają nazwę partii i numer, na przykład invoices_0001.pdf. Wypełnione - nazwa jest użyta dokładnie tak, jak ją napisano, razem z rozszerzeniem. Jedyny znacznik to {index:04}, który staje się 0001, 0002 i tak dalej, a partia z więcej niż jednym plikiem go potrzebuje." }, + "DetailOutputDir": { "other": "Zostanie utworzony, jeśli go nie ma." }, + "DetailPreset": { "other": "Zestaw jest wyliczany z odpowiedzi." }, + "DetailPurpose": { "other": "Trafia do manifestu i do instrukcji obok niego, które mówią, do czego służy każdy plik przebiegu. Nie zmienia żadnego bajtu żadnego pliku." }, + "DetailReason": { "other": "Z zamkniętej listy, żeby raport mógł grupować po powodzie. Nazywa regułę, o którą chodzi, niezależnie od wyniku - plik o bajt mniejszy od limitu powinien zostać przyjęty, a regułą w grze wciąż jest limit rozmiaru. Powód wymaga obok siebie oczekiwanego wyniku." }, + "DetailSeed": { "other": "Na każdej maszynie i w każdej wersji tego narzędzia. 0 to ziarno, którego przebieg używa, gdy nikt nie poprosi o inne. To ziarno jak każde inne, a nie prośba o losowe pliki - takich to narzędzie nigdy nie tworzy." }, + "DetailSize": { "other": "Jednostki liczą się po 1024, więc 10mb to 10485760 bajtów. Sama liczba to liczba bajtów." }, + "DetailSizeRange": { "other": "Dwa rozmiary z łącznikiem, na przykład 1kb-8kb. Każdy plik dostaje własny rozmiar wyprowadzony z ziarna, więc przebieg się powtarza." }, + "DetailSizeWay": { "other": "Jeden rozmiar daje każdemu plikowi ten sam rozmiar. Zakres losuje inny rozmiar dla każdego pliku. Wokół limitu tworzy trzy pliki: bajt poniżej limitu, dokładnie na nim i bajt powyżej." }, + "DetailTargetID": { "other": "Trafia do manifestu i do nazw plików, więc test odróżni te pliki od reszty przebiegu. Ziarna są z niej wyprowadzane, więc jej zmiana zmienia bajty." }, + "DirectoryWithFreeSpace": { "other": "{{.Directory}} (wolne {{.Free}})" }, + "FieldBasePreset": { "other": "Preset bazowy" }, + "FieldBoundary": { "other": "Testowany limit" }, + "FieldBuildOnPreset": { "other": "Zacznij od presetu" }, + "FieldCount": { "other": "Liczba plików" }, + "FieldDamage": { "other": "Uszkodzenie" }, + "FieldExpected": { "other": "Oczekiwany wynik" }, + "FieldFormat": { "other": "Format" }, + "FieldGroup": { "other": "Rodzaj przypadku" }, + "FieldLabel": { "other": "Etykieta w każdym pliku" }, + "FieldLanguage": { "other": "Język okna" }, + "FieldManifest": { "other": "Nazwa pliku manifestu" }, + "FieldNameTemplate": { "other": "Nazwy plików" }, + "FieldOutputDir": { "other": "Katalog wyjściowy" }, + "FieldPreset": { "other": "Preset" }, + "FieldPurpose": { "other": "Cel" }, + "FieldReason": { "other": "Testowana reguła" }, + "FieldSeed": { "other": "Ziarno" }, + "FieldSize": { "other": "Rozmiar" }, + "FieldSizeRange": { "other": "Zakres rozmiarów" }, + "FieldSizeWay": { "other": "Jak podany jest rozmiar" }, + "FieldTargetID": { "other": "Nazwa partii" }, + "Files": { "one": "1 plik", "few": "{{.Count}} pliki", "many": "{{.Count}} plików", "other": "{{.Count}} pliku" }, + "FormatCount": { "one": "1 format", "few": "{{.Count}} formaty", "many": "{{.Count}} formatów", "other": "{{.Count}} formatu" }, + "HintBasePreset": { "other": "Jego pliki idą pierwsze." }, + "HintBoundary": { "other": "Trzy pliki: bajt poniżej limitu, dokładnie na nim i bajt powyżej." }, + "HintDamage": { "other": "Celowo psuje pliki." }, + "HintExpected": { "other": "Co system pod testem powinien zrobić z tymi plikami." }, + "HintFormat": { "other": "Jaki rodzaj pliku wytworzyć." }, + "HintGroup": { "other": "Oznacza kilka partii jako jeden rodzaj przypadku." }, + "HintManifest": { "other": "Zapis tego, co wytworzył przebieg." }, + "HintNameTemplate": { "other": "Jak nazywa się każdy plik tej partii." }, + "HintOutputDir": { "other": "Dokąd trafiają pliki i manifest." }, + "HintPreset": { "other": "Co testujesz." }, + "HintPurpose": { "other": "Czym są te pliki i dlaczego są w zestawie." }, + "HintReason": { "other": "Której reguły to dotyczy." }, + "HintSeed": { "other": "To samo ziarno daje te same bajty." }, + "HintSize": { "other": "Dokładny rozmiar każdego pliku." }, + "HintSizeExact": { "other": "Jeden rozmiar dla każdego pliku." }, + "HintSizeRange": { "other": "Inny rozmiar dla każdego pliku." }, + "HintTargetID": { "other": "Krótka nazwa tej partii." }, + "HowToUseChoose": { "other": "Wybierz preset albo wypełnij jedną partię na pierwszym ekranie." }, + "HowToUsePress": { "other": "Naciśnij Generuj. Pliki i manifest trafią do katalogu wyjściowego." }, + "HowToUseRead": { "other": "Skieruj swój test na manifest. Dla każdego pliku mówi, co system pod testem powinien z nim zrobić - przyjąć, odrzucić albo oczyścić - albo zapisuje wynik jako unspecified, gdy właściwa odpowiedź należy do polityki samej aplikacji." }, + "InstructionsNotSaved": { "other": "Nie udało się zapisać instrukcji do {{.Path}} ({{.Cause}}). Manifest został zapisany i zawiera te same informacje." }, + "LanguageName": { "other": "Polski" }, + "ListHeadingCount": { "other": "{{.Kind}} · {{.Count}}" }, + "ListKindArchives": { "other": "Archiwa" }, + "ListKindDocuments": { "other": "Dokumenty" }, + "ListKindPictures": { "other": "Obrazy" }, + "ListKindSound": { "other": "Dźwięk" }, + "ListKindText": { "other": "Tekst i dane" }, + "ListKindVideo": { "other": "Wideo" }, + "ListNothingMatches": { "other": "Żaden format nie pasuje - wyczyść pole, żeby zobaczyć wszystkie" }, + "ManifestNamed": { "other": "Manifest: {{.Name}}" }, + "ManifestNotSaved": { "other": "pliki zostały zapisane, a manifestu nie udało się zapisać do {{.Path}}" }, + "ManifestTooLargeToRead": { "other": "zapis tego przebiegu ma około {{.Size}}, a ta wersja czyta najwyżej {{.Limit}}, więc polecenia verify i cleanup nie zdołają go odczytać. Podziel przebieg, żeby każdy zapis dał się odczytać." }, + "NotAWholeNumber": { "other": "{{.Field}} ma wartość {{.Value}}, która nie jest liczbą całkowitą. Wpisz same cyfry, na przykład 1 albo 500" }, + "NoteBase": { "other": "Z zaznaczonym polem najpierw idą pliki wybranego presetu, a partie poniżej są dodawane po nich. Bez niego partie są całym przepisem." }, + "NoteManifestOnly": { "other": "Te pola opisują przypadek. Trafiają do manifestu i niczego nie zmieniają w plikach." }, + "NothingProduced": { "other": "Nic nie zostało wytworzone." }, + "PlaceholderFilter": { "other": "wpisz, aby zawęzić" }, + "PlaceholderLeftEmpty": { "other": "domyślnie: {{.Value}}" }, + "PlaceholderNotSet": { "other": "nie ustawiono" }, + "PlaceholderNotStated": { "other": "nie podano" }, + "PlaceholderWorkedOut": { "other": "wyliczane z rozmiaru" }, + "PreferencesForgetWhat": { "other": "Zapomnij czyści trzy rzeczy powyżej. To okno nie zachowa potem niczego przy zamknięciu, a następne otworzy się tak jak pierwsze." }, + "PreferencesForgotten": { "other": "Zapomniane. Następne okno otworzy się tak jak pierwsze." }, + "PreferencesKept": { "other": "Między uruchomieniami to okno zachowuje trzy rzeczy: katalog wyjściowy, rozmiar okna i język wybrany powyżej. Okno wyboru katalogu zachowuje też ostatni pokazany katalog. Nic nie opuszcza tego komputera." }, + "PreferencesKeptIn": { "other": "Są przechowywane w tym folderze:" }, + "PreferencesLanguageScope": { "other": "Język zmienia tylko okno, a nie pliki, które tworzy, przepisy, manifesty ani linię komend." }, + "PreferencesMissing": { "other": "Język wybrany wcześniej, {{.Tag}}, nie jest dostępny w tej wersji. Okno używa języka systemu, dopóki nie wybierzesz ponownie." }, + "PreferencesNextStart": { "other": "Okno mówi wybranym językiem od następnego otwarcia." }, + "PreferencesRestartBusy": { "other": "Trwa tworzenie plików. Uruchom ponownie, gdy się skończy." }, + "PreferencesRestartClears": { "other": "Ponowne uruchomienie zamyka to okno i otwiera je od nowa. Wszystko, co wpisano na innych zakładkach, zostanie wyczyszczone." }, + "PreferencesRestartFailed": { "other": "Nie udało się ponownie uruchomić okna: {{.Reason}}. Wybrany język jest zapisany. Otwórz program ponownie, żeby go zobaczyć." }, + "PresetCatchesHeading": { "other": "Zwykle znajduje:" }, + "PreviewNothingWritten": { "other": "jeszcze nic nie zapisano" }, + "Progress": { "other": "pliki {{.Done}}/{{.Total}} {{.BytesDone}} z {{.BytesTotal}} {{.Percent}}%" }, + "RefusedBeforeWriting": { "other": "Nic nie zostało zapisane. Sprawdź ustawienia zaznaczone powyżej." }, + "RendererDidNotHelp": { "other": "Programowy renderer dostarczony obok programu został załadowany, a biblioteka graficzna i tak nie zdołała otworzyć z nim okna." }, + "RendererDrawing": { "other": "To okno rysuje programowy renderer dostarczony obok programu (Mesa llvmpipe), a nie sterownik grafiki. Wszystko działa. Rysowanie jest wolniejsze niż ze sterownikiem obsługującym OpenGL 2.1." }, + "RendererNotBeside": { "other": "Obok programu nie ma programowego renderera z archiwum dla Windows - brakuje {{.Path}}. Przywróć folder opengl z archiwum obok tfg-gui.exe albo użyj sterownika grafiki obsługującego OpenGL 2.1." }, + "RendererNotLoaded": { "other": "Nie udało się załadować programowego renderera dostarczonego obok programu: {{.Error}}." }, + "RendererNotReadable": { "other": "Nie udało się odczytać pliku programowego renderera obok programu: {{.Error}}. Sprawdź, kto może czytać folder opengl obok tfg-gui.exe, albo użyj sterownika grafiki obsługującego OpenGL 2.1." }, + "RendererNotShipped": { "other": "Dla tego systemu nie jest dostarczany programowy renderer, więc ta flaga niczego tu nie zmienia." }, + "RendererStartFailed": { "other": "Ponowne uruchomienie programu z programowym rendererem nie powiodło się: {{.Error}}." }, + "RendererStartingAgain": { "other": "Pierwsza próba otwarcia okna nie powiodła się - zwykle to sterownik grafiki bez OpenGL 2.1. Uruchamiam ponownie z programowym rendererem dostarczonym obok programu." }, + "SectionBase": { "other": "Budowanie na presecie" }, + "SectionCarriedBeside": { "other": "Dostarczane obok programu w Windows" }, + "SectionCarriedCode": { "other": "Wkompilowany kod innych autorów" }, + "SectionCarriedFiles": { "other": "Wkompilowane pliki, które nie są kodem" }, + "SectionConfiguration": { "other": "Konfiguracja pliku" }, + "SectionHowToUse": { "other": "Jak używać" }, + "SectionLanguage": { "other": "Język" }, + "SectionLicence": { "other": "Licencja" }, + "SectionManifestNotes": { "other": "Uwagi do manifestu" }, + "SectionOutput": { "other": "Wyjście" }, + "SectionPreset": { "other": "Pytanie" }, + "SectionRemembered": { "other": "Co pamięta to okno" }, + "SectionSettings": { "other": "Ustawienia" }, + "SectionSupport": { "other": "Wsparcie" }, + "SettingKey": { "other": "W przepisie zapisywane jako {{.Key}}." }, + "SettingsFor": { "other": "Ustawienia dla {{.Format}}" }, + "SizeAndBytes": { "other": "{{.Human}} ({{.Exact}})" }, + "SizeBetween": { "other": "od {{.Least}} do {{.Most}}" }, + "SizeFromContents": { "other": "zależy od plików w środku, znany po Podglądzie" }, + "SizeWayBoundary": { "other": "Wokół limitu" }, + "SizeWayExact": { "other": "Jeden rozmiar" }, + "SizeWayRange": { "other": "Zakres" }, + "StoppedAfter": { "other": "Zatrzymano. Zapisano: {{.Files}}. Manifest opisuje dokładnie te pliki." }, + "SubtitleGenerate": { "other": "Pliki jednego formatu i jednego rozmiaru, z manifestem, który mówi, jak system pod testem powinien na nie zareagować." }, + "SubtitlePreferences": { "other": "Jak mówi to okno i co zachowuje między uruchomieniami. Nic tutaj nie zmienia plików, które tworzy." }, + "SubtitlePreset": { "other": "Gotowe zestawy plików, każdy zbudowany, by odpowiedzieć na jedno pytanie o system pod testem." }, + "SubtitleRecipe": { "other": "Partie różnych formatów i rozmiarów, generowane razem w jednym przebiegu." }, + "TabAbout": { "other": "O programie" }, + "TabOneTarget": { "other": "Jedna partia" }, + "TabPreferences": { "other": "Preferencje" }, + "TabPresets": { "other": "Presety" }, + "TabRecipe": { "other": "Kilka partii" }, + "TimeLeft": { "other": "zostało {{.Roughly}}" }, + "TooManyFiles": { "other": "liczba plików w tym przebiegu to {{.Count}} - {{.Reason}}" }, + "UseSmallestSize": { "other": "Użyj najmniejszego rozmiaru, {{.Size}}" }, + "WillGoTo": { "other": "zapis do {{.Directory}}" }, + "WindowRefused": { "other": "Nie udało się otworzyć okna. Rysuje ono przez OpenGL 2.1, a biblioteka graficzna nie zdołała uzyskać go od sterownika na tym komputerze. Komunikat biblioteki: {{.Cause}}. Wszystko, co robi okno, jest też dostępne w linii komend - uruchom „tfg --help” - i ona nie potrzebuje sterownika grafiki. Żeby mieć okno, użyj sterownika grafiki obsługującego OpenGL 2.1." }, + "WindowRefusedNoCause": { "other": "Nie udało się otworzyć okna. Rysuje ono przez OpenGL 2.1, a biblioteka graficzna nie zdołała uzyskać go od sterownika na tym komputerze. Wszystko, co robi okno, jest też dostępne w linii komend - uruchom „tfg --help” - i ona nie potrzebuje sterownika grafiki. Żeby mieć okno, użyj sterownika grafiki obsługującego OpenGL 2.1." }, + "WindowRefusedTitle": { "other": "Testing Files Generator nie mógł otworzyć okna" }, + "WorkingOutTheCost": { "other": "Liczę, ile to będzie kosztować..." }, + "WritingTo": { "other": "Pliki trafią do {{.Directory}}" }, + "Written": { "other": "Zapisano: {{.Files}}." }, + "WrittenWithFailures": { "other": "Zapisano: {{.Files}}, nie udało się wytworzyć: {{.Failed}}." } +} diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 1c2084c5..3a3b0199 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -731,3 +731,99 @@ func DetailDonate() string { // everything above it. An address is the same in every language, and a // translated one would be a broken one. const SupportURL = "https://donislawdev.com/support/" + +// The Preferences screen: which language the window speaks, and what it keeps +// between runs. Nothing on it changes the files the window makes - a setting +// that did would be an input the recipe does not carry, and the command the +// window offers to show would make different files (docs/USTAWIENIA-2026-09-29.md +// section 2). +// +// "Preferences" and not "Settings" for the tab, because Settings is already the +// section of the form where a format's own settings stand, and two places +// called the same would read as one. +func TabPreferences() string { return say("TabPreferences", "Preferences") } + +func SubtitlePreferences() string { + return say("SubtitlePreferences", "How this window speaks, and what it keeps between runs. Nothing here changes the files it makes.") +} + +func SectionLanguage() string { return say("SectionLanguage", "Language") } +func SectionRemembered() string { return say("SectionRemembered", "What this window remembers") } +func FieldLanguage() string { return say("FieldLanguage", "Window language") } + +// LanguageName is what the language of a catalogue is called in that language +// itself. Every catalogue carries it, and it is how that language is listed on +// the Preferences screen - so a person who cannot read the language the window +// speaks now can still find their own. +func LanguageName() string { return say("LanguageName", "English") } + +// ChoiceSameAsSystem is the first entry of the language list, and what nobody +// having chosen means. It names the language that choice gives on this machine, +// because "the system" alone does not say whether that is a language this +// window carries. +func ChoiceSameAsSystem(lang string) string { + return sayf("ChoiceSameAsSystem", "Same as the system ({{.Language}})", map[string]any{"Language": lang}) +} + +// PreferencesLanguageScope says what the language reaches, because a list of +// languages invites the question whether the files will be in it too. +func PreferencesLanguageScope() string { + return say("PreferencesLanguageScope", "Only the window changes language. The files it makes, recipes, manifests and the command line do not.") +} + +// PreferencesNextStart is said once a language other than the one on screen is +// chosen. A choice that changed nothing visible and said nothing would read as +// a list that does not work. +func PreferencesNextStart() string { + return say("PreferencesNextStart", "The window speaks the chosen language from the next time it opens.") +} + +func ButtonRestart() string { return say("ButtonRestart", "Restart now") } + +// PreferencesRestartClears is beside the button, because the form on the other +// tabs is not kept across a restart and a person who typed a recipe there +// should know before pressing, not after. +func PreferencesRestartClears() string { + return say("PreferencesRestartClears", "Restarting closes this window and opens it again. Anything typed on the other tabs is cleared.") +} + +// PreferencesRestartBusy stands in for the button's use while files are being +// made: closing the window stops the run (docs/GUI.md G7), and a restart must +// not be a way to stop one without saying so. +func PreferencesRestartBusy() string { + return say("PreferencesRestartBusy", "Files are being made. Restart once they are done.") +} + +// PreferencesRestartFailed is the four parts of D6: what failed, why, and what +// still works - the choice is saved, so opening the window by hand gives it. +func PreferencesRestartFailed(reason string) string { + return sayf("PreferencesRestartFailed", "The window could not start again: {{.Reason}}. The language chosen is saved. Open the program again to see it.", map[string]any{"Reason": reason}) +} + +// PreferencesMissing is said when a language chosen before is not in this +// version. Untouchable rule 6: the window does not quietly speak something +// else. +func PreferencesMissing(tag string) string { + return sayf("PreferencesMissing", "The language chosen before, {{.Tag}}, is not in this version. The window follows the system until you choose again.", map[string]any{"Tag": tag}) +} + +// PreferencesKept says what is kept, in words, and that it stays on this +// computer. The folder picker's own memory is named as well: it writes into the +// same place, and a list of what is kept that left it out would be untrue. +func PreferencesKept() string { + return say("PreferencesKept", "Between runs this window keeps three things: the output directory, the size of the window, and the language chosen above. The folder picker also keeps the last folder it showed. Nothing leaves this computer.") +} + +func PreferencesKeptIn() string { return say("PreferencesKeptIn", "They are kept in this folder:") } + +func ButtonForget() string { return say("ButtonForget", "Forget") } + +// PreferencesForgetWhat is beside the button, so it says before the press what +// the press does and what it leaves alone. +func PreferencesForgetWhat() string { + return say("PreferencesForgetWhat", "Forget clears the three things above. This window then keeps nothing when it closes, and the next one opens the way the first one did.") +} + +func PreferencesForgotten() string { + return say("PreferencesForgotten", "Forgotten. The next window opens the way the first one did.") +} diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index f1182aad..61b78c89 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -134,6 +134,25 @@ type Host interface { // window drawn in software is slower and the person should be able to // read why, in the place they go to read what this program is. SoftwareRendering() bool + + // SettingsFolder is the folder the toolkit keeps this window's state in, + // as the system spells it - what the Preferences screen names under "What + // this window remembers", so a person can see the file rather than take a + // sentence's word for it. + SettingsFolder() string + + // SystemLanguage is the language the desktop is set to, as a tag such as + // pl-PL, and empty when the system does not say. The Preferences screen + // names what "Same as the system" comes to on this machine with it. + SystemLanguage() string + + // RestartWhenClosed asks for this program to start again once this window + // has closed - the second half of Restart now, after the screens have + // closed the window the way a person closing it does. Starting the new + // one only after the old one is gone is what makes the new window find + // everything the old one wrote down as it went: the output folder and the + // size are written at the close, not before. + RestartWhenClosed() } // Generate is the screen that produces files from settings somebody chose. diff --git a/internal/gui/window/open.go b/internal/gui/window/open.go index 5601bc99..b81e9923 100644 --- a/internal/gui/window/open.go +++ b/internal/gui/window/open.go @@ -11,6 +11,17 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) +// The screens by what they are rather than by what they are called - see +// parts.Tab. Everything that carries something between screens is keyed by +// these, never by the translated word on the strip. +const ( + tabGenerate = "generate" + tabPresets = "presets" + tabRecipe = "recipe" + tabPrefs = "preferences" + tabAbout = "about" +) + // Open fills the window with the first screen and wires the way between them. // // The window opens on the work rather than on a welcome, decided by the owner @@ -27,6 +38,11 @@ func Open(h Host) fyne.Size { gen := NewGenerate(h) pre := NewPreset(h) rec := NewRecipe(h) + runners := []*runner{gen.runner, pre.runner, rec.runner} + // The way out is wired below, once everything it stops exists. The screen + // is built now, so it is handed a way to reach it rather than the thing. + var leave func() + prefs := NewPreferences(h, func() { leave() }, func() bool { return anyBusy(runners) }) // Tabs across the top rather than buttons at the foot, reported from use on // 2026-08-11. The way between the screens used to sit in the row of actions @@ -44,10 +60,11 @@ func Open(h Host) fyne.Size { // the toolkit's could not do, the first of which is stand on the same edge // as the words under it. tabs := parts.NewTabs( - &parts.Tab{Text: text.TabOneTarget(), Content: gen.Object()}, - &parts.Tab{Text: text.TabPresets(), Content: pre.Object()}, - &parts.Tab{Text: text.TabRecipe(), Content: rec.Object()}, - &parts.Tab{Text: text.TabAbout(), Content: About(h)}, + &parts.Tab{ID: tabGenerate, Text: text.TabOneTarget(), Content: gen.Object()}, + &parts.Tab{ID: tabPresets, Text: text.TabPresets(), Content: pre.Object()}, + &parts.Tab{ID: tabRecipe, Text: text.TabRecipe(), Content: rec.Object()}, + &parts.Tab{ID: tabPrefs, Text: text.TabPreferences(), Content: prefs.Object()}, + &parts.Tab{ID: tabAbout, Text: text.TabAbout(), Content: About(h)}, ) // The output directory follows whoever is looking, and that is a fix for a @@ -68,11 +85,11 @@ func Open(h Host) fyne.Size { OutDir() string SetOutDir(string) }{ - text.TabOneTarget(): gen, - text.TabPresets(): pre, - text.TabRecipe(): rec, + tabGenerate: gen, + tabPresets: pre, + tabRecipe: rec, } - showing := text.TabOneTarget() + showing := tabGenerate offerWhereItLastWrote(h, working) @@ -81,9 +98,9 @@ func Open(h Host) fyne.Size { // shortcut belongs to the window and the answer is whichever screen is // being looked at. keyed := map[string]keyboardScreen{ - text.TabOneTarget(): gen, - text.TabPresets(): pre, - text.TabRecipe(): rec, + tabGenerate: gen, + tabPresets: pre, + tabRecipe: rec, } // The keyboard starts on the first field of the screen somebody is looking @@ -92,7 +109,7 @@ func Open(h Host) fyne.Size { tabs.OnSelected = func(item *parts.Tab, byKeyboard bool) { from, leaving := working[showing] - to, arriving := working[item.Text] + to, arriving := working[item.ID] if leaving && arriving { to.SetOutDir(from.OutDir()) } @@ -100,12 +117,12 @@ func Open(h Host) fyne.Size { // that About in the middle of two work screens does not strand the // value on the screen before it. if arriving { - showing = item.Text + showing = item.ID } // The keyboard follows the person to the screen they moved to. Without // this it stays on a control of the screen they left, which is a Tab // that starts somewhere nobody can see. - focusFirst(item.Text, byKeyboard) + focusFirst(item.ID, byKeyboard) } // Closing the window during a run is a cancellation and not a kill, G7. The @@ -115,8 +132,14 @@ func Open(h Host) fyne.Size { // the middle of a file. // One wait for quiet for the whole window, told by every screen, and // stopped with them when the window closes - see tidy.go. - quiet := tidyWhenLeftAlone(h, gen.runner, pre.runner, rec.runner) - closeCleanly(h, []interface{ Stop() }{gen, pre, rec, quiet}, working, &showing) + quiet := tidyWhenLeftAlone(h, runners...) + leave = closeCleanly(h, []interface{ Stop() }{gen, pre, rec, quiet}, working, &showing) + // Restart now stands down while any screen is making files, and stands up + // again when it stops - told rather than asked, so the button is right + // the moment a run ends while the Preferences tab is on show. + for _, r := range runners { + r.busy.changed = prefs.BusyChanged + } offerSettling(h, []interface{ Settled() }{gen, pre, rec}) offerHolding(h, []interface{ HoldBeforeFinishing(func()) }{gen, pre, rec}) @@ -168,8 +191,8 @@ func Open(h Host) fyne.Size { func closeCleanly(h Host, running []interface{ Stop() }, working map[string]interface { OutDir() string SetOutDir(string) -}, showing *string) { - h.SetCloseIntercept(func() { +}, showing *string) (leave func()) { + leave = func() { for _, screen := range running { screen.Stop() } @@ -177,7 +200,13 @@ func closeCleanly(h Host, running []interface{ Stop() }, working map[string]inte h.Remembered().RememberDirectory(screen.OutDir()) } h.Close() - }) + } + h.SetCloseIntercept(leave) + // Handed back as well, because closing the window from inside it - Restart + // now on the Preferences screen - has to go this same way. The toolkit's + // Close does not pass through the intercept (driver/glfw window.go, read + // on 2026-09-29), so calling it alone would skip the runs and the folder. + return leave } // offerWhereItLastWrote puts the directory of the last run on every screen. @@ -327,7 +356,9 @@ func FirstScreen(h Host) fyne.CanvasObject { // // It stays on every screen either way, which is not decoration - a button asking // for money that appears on some screens and not others is a button people -// conclude they imagined. +// conclude they imagined. Every screen but Preferences, by the owner's decision +// of 2026-09-29: that one holds how the window speaks and what it keeps, and +// asks for nothing. // // It opens the support page in whatever the desktop uses for the web. The // program fetches nothing and sends nothing, which is what keeps untouchable diff --git a/internal/gui/window/preferences.go b/internal/gui/window/preferences.go new file mode 100644 index 00000000..b5a73ae0 --- /dev/null +++ b/internal/gui/window/preferences.go @@ -0,0 +1,219 @@ +package window + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// Preferences is the screen of how the window speaks and what it keeps between +// runs - docs/USTAWIENIA-2026-09-29.md, the owner's decisions of that day. +// +// Nothing on it changes the files the window makes. A setting that did would +// be an input the recipe does not carry, and the command the window offers to +// show would then make different files from the ones on screen - so anything +// that shapes a file belongs in the form, not here. +// +// The language takes effect from the next start rather than at once. The +// words of every screen are asked for once, when the screens are built, and +// the screens are built once so that a run in flight and everything typed +// survive moving between tabs - so a language changed in place would need +// every screen rebuilt around its state. Restart now is the honest version of +// that: it closes the window the way a person closing it does, and opens a new +// one. +type Preferences struct { + host Host + // leave closes the window the way the close button does - runs stopped, + // the folder written down - which Window.Close alone does not do. + leave func() + // busy says whether files are being made on any screen. + busy func() bool + + pick *parts.Chooser + choices languageChoices + // state is the line under the button: what a press would do, or why it + // cannot be pressed now. + state *parts.QuietText + restart *parts.Button + + forget *parts.Button + forgotten *parts.QuietText + + object fyne.CanvasObject +} + +// NewPreferences builds the screen. +func NewPreferences(h Host, leave func(), busy func() bool) *Preferences { + p := &Preferences{host: h, leave: leave, busy: busy} + p.choices = choicesFor(text.Languages(), h.SystemLanguage()) + + p.pick = parts.NewChooser(p.choices.options, nil) + p.show(p.choices.optionFor(h.Remembered().Language())) + p.restart = parts.NewButton(parts.Primary, text.ButtonRestart(), p.restartNow) + p.state = parts.NewQuietText("") + p.state.Wrapping = fyne.TextWrapWord + + p.forget = parts.NewButton(parts.Secondary, text.ButtonForget(), p.forgetNow) + p.forgotten = parts.NewQuietText(text.PreferencesForgetWhat()) + p.forgotten.Wrapping = fyne.TextWrapWord + p.refresh() + + fields := parts.NewFields() + language := parts.Section(text.SectionLanguage(), + parts.Grid(fields.Named(text.FieldLanguage(), parts.NoDetail, parts.Menu(p.pick))), + parts.Prose(text.PreferencesLanguageScope()), + container.NewHBox(p.restart), + p.state, + ) + remembered := parts.Section(text.SectionRemembered(), + parts.Prose(text.PreferencesKept()), + parts.Prose(text.PreferencesKeptIn()), + parts.Prose(h.SettingsFolder()), + // A row of ours, GapButtons apart - a bare box put the toolkit's 4 px + // between the two and they read as one control - held to the left + // edge the way the About screen holds its button. + container.NewHBox(parts.ButtonRow( + parts.NewButton(parts.Secondary, text.ButtonOpenFolder(), func() { h.OpenFolder(h.SettingsFolder()) }), + p.forget)), + p.forgotten, + ) + page := parts.Screen(parts.Titled(text.TabPreferences(), text.SubtitlePreferences()), language, remembered) + p.object = container.NewVScroll(page) + return p +} + +// Object is the screen, for the tab that holds it. +func (p *Preferences) Object() fyne.CanvasObject { return p.object } + +// BusyChanged is told when files start or stop being made on any screen. +func (p *Preferences) BusyChanged() { p.refresh() } + +// show puts an entry in the list without saving it. Opening the screen must +// not write anything, least of all when the saved language is one this build +// does not carry: the list then shows the system, and saving that would erase +// the person's choice without a press - untouchable rule 7 - and the sentence +// saying it was missing with it. +func (p *Preferences) show(option string) { + p.pick.OnChanged = nil + p.pick.SetSelected(option) + p.pick.OnChanged = p.chose +} + +// chose saves what was picked, at once: there is no Save button, because a +// choice that has to be confirmed twice is a choice that gets lost when the +// window is closed in between. +func (p *Preferences) chose(option string) { + p.host.Remembered().RememberLanguage(p.choices.tagOf[option]) + p.refresh() +} + +// restartNow closes this window the way a person does and asks for a new one +// once it has gone - see Host.RestartWhenClosed. Asked again at the press, +// because a run may have started between the last refresh and this. +func (p *Preferences) restartNow() { + if p.busy() || !p.pending() { + p.refresh() + return + } + p.host.RestartWhenClosed() + p.leave() +} + +// forgetNow removes what the window keeps and says it did. The list goes back +// to the system's language with it, because that is what the window will speak +// next time now that nothing is chosen. +func (p *Preferences) forgetNow() { + p.host.Remembered().Forget() + p.show(p.choices.optionFor("")) + p.forgotten.SetText(text.PreferencesForgotten()) + p.forget.Disable() + p.refresh() +} + +// pending says whether a restart would change the language on screen. +func (p *Preferences) pending() bool { + return p.choices.speaks(p.host.Remembered().Language()) != text.Speaking() +} + +// refresh sets the button and the line under it from the state as it is now. +func (p *Preferences) refresh() { + saved := p.host.Remembered().Language() + p.state.SetText(restartSentence(p.pending(), p.busy(), p.choices.missing(saved))) + if p.pending() && !p.busy() { + p.restart.Enable() + } else { + p.restart.Disable() + } +} + +// restartSentence is the line under Restart now. Never empty, so the screen +// does not move when it changes - GUI rule 3. +func restartSentence(pending, busy bool, missing string) string { + switch { + case pending && busy: + return text.PreferencesRestartBusy() + case pending: + return text.PreferencesRestartClears() + case missing != "": + return text.PreferencesMissing(missing) + default: + return text.PreferencesNextStart() + } +} + +// languageChoices is the list of languages as the screen shows it, and what +// each entry of it is. Built from the one list of carried languages, so the +// words on screen and the tags saved cannot come apart - GUI rule 8. +type languageChoices struct { + carried []text.Language + system string + // options are the entries in order: the system first, then every + // carried language by its own name. + options []string + // tagOf is what each entry saves: empty for the system. + tagOf map[string]string +} + +func choicesFor(carried []text.Language, system string) languageChoices { + c := languageChoices{carried: carried, system: system, tagOf: map[string]string{}} + first := text.ChoiceSameAsSystem(nameOf(carried, text.Resolve("", system, carried).Tag)) + c.options = append(c.options, first) + c.tagOf[first] = "" + for _, l := range carried { + c.options = append(c.options, l.Name) + c.tagOf[l.Name] = l.Tag + } + return c +} + +// optionFor is the entry a saved value stands for. A value this build does +// not carry is shown as the system, because that is what the window speaks +// for it - and the line under the button says what was missing. +func (c languageChoices) optionFor(saved string) string { + if saved == "" || c.missing(saved) != "" { + return c.options[0] + } + return nameOf(c.carried, c.speaks(saved)) +} + +// speaks is the language the window opens in for a saved value. +func (c languageChoices) speaks(saved string) string { + return text.Resolve(saved, c.system, c.carried).Tag +} + +// missing is a saved value this build does not carry, or empty. +func (c languageChoices) missing(saved string) string { + return text.Resolve(saved, c.system, c.carried).Missing +} + +// nameOf is what a carried language calls itself. +func nameOf(carried []text.Language, tag string) string { + for _, l := range carried { + if l.Tag == tag { + return l.Name + } + } + return tag +} diff --git a/internal/gui/window/remembered.go b/internal/gui/window/remembered.go index 96308750..c6abff43 100644 --- a/internal/gui/window/remembered.go +++ b/internal/gui/window/remembered.go @@ -4,12 +4,17 @@ import "fyne.io/fyne/v2" // Remembered is the little this window keeps between runs. // -// Two things and no more, by the owner's decision of 2026-08-23: where the +// Two things it notices, by the owner's decision of 2026-08-23: where the // files go, and how big the window is. Deliberately NOT the format, the tab or // the preset - a setting from last week is worse than a start you can predict, // because the person who opens this tool tomorrow is answering a different // question than the one they answered today. // +// And since 2026-09-29 one thing it is told: the language chosen on the +// Preferences screen. That is not last week's state coming back uninvited, it +// is a choice somebody made in order to keep it, and it is the one value here +// that nothing writes but a person. +// // It is an interface on the Host rather than a call to the toolkit's global // preferences, for the reason every other seam here exists: the screens build // and run with no window, no canvas and no C compiler, and a guard has to be @@ -25,8 +30,9 @@ import "fyne.io/fyne/v2" // been writing dev.donislaw.tfg/preferences.json since the Choose button // arrived on 2026-08-05, with its own last folder and view layout in it, and // the note beside appID said no file was written into that directory. This adds -// two keys to a file that was already there. Nothing is ever deleted from it by -// us - rule 7 - and nothing leaves the machine, which is D16. +// our values to a file that was already there. Nothing is ever deleted from it +// by us except on the press of Forget, and then only our values - rule 7 - and +// nothing leaves the machine, which is D16. type Remembered interface { // Directory is where the files went last time, or empty when nobody has // said yet. @@ -37,6 +43,53 @@ type Remembered interface { // nought in it when nobody has said yet. Size() fyne.Size RememberSize(fyne.Size) + + // Language is the language somebody chose on the Preferences screen, as a + // tag, or empty when nobody has - which means the language of the system + // (docs/PRODUCT.md D9, since 2026-09-29). RememberLanguage with an empty + // tag goes back to that by removing the value rather than writing an + // empty one. + Language() string + RememberLanguage(tag string) + + // Forget removes everything above, on the press of the Forget button and + // at no other time - untouchable rule 7. For the rest of this run the + // folder and the size are not written down again either, or closing the + // window would put back what the person just asked to be forgotten. A + // language chosen after it is written: that is a new choice, not the + // old memory. + Forget() +} + +// Forgetting is a store that keeps its word after Forget: for the rest of this +// run, the folder and the size are not written down again, or closing the +// window would put back what the person just asked to be forgotten. The store +// under it only removes and writes values - this is the promise, in the one +// place a guard can reach it, and the real window and the guards' stand in both +// go through it. A language chosen after Forget is written: that is a new +// choice, not the old memory. +func Forgetting(store Remembered) Remembered { return &forgetting{Remembered: store} } + +type forgetting struct { + Remembered + forgot bool +} + +func (f *forgetting) RememberDirectory(d string) { + if !f.forgot { + f.Remembered.RememberDirectory(d) + } +} + +func (f *forgetting) RememberSize(size fyne.Size) { + if !f.forgot { + f.Remembered.RememberSize(size) + } +} + +func (f *forgetting) Forget() { + f.forgot = true + f.Remembered.Forget() } // WorthRemembering says whether a size is a real one. diff --git a/internal/gui/window/runbusy.go b/internal/gui/window/runbusy.go index 7b8df572..24bb3935 100644 --- a/internal/gui/window/runbusy.go +++ b/internal/gui/window/runbusy.go @@ -71,6 +71,11 @@ type busy struct { later later callOff func() + // changed is told whenever work starts or stops owning the screen, and + // only then - the Preferences screen listens, because Restart now closes + // the window and closing it stops a run (G7), so the button stands down + // while there is one. Nil when nobody listens. + changed func() // epoch counts the pieces of work that have owned the screen, so that a // face asked for by one of them can never dress the next. Calling the // clock off is not enough: the real window's clock hands the face to @@ -91,7 +96,11 @@ type busyFace struct{ stoppable, progressing bool } // goes on later - BusyFaceAfter from now, on the interface thread, if the // work is still going - and comes off at once. func (b *busy) set(occupied bool, face busyFace) { + was := b.occupied b.occupied = occupied + if was != occupied && b.changed != nil { + b.changed() + } if b.callOff != nil { b.callOff() b.callOff = nil