diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b7b62c..efeeff88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,23 @@ because it turns other people's test suites red. ### Changed +- **`Generate` means the same thing on every screen.** The `Several batches` + screen used to open with `Batch name` and `Size` empty under a red star + each, so the button that works straight away on `Single batch` turned you + down on the third tab. The first batch now arrives with the same two values + the single batch screen has always had, `files` and `10mb`, and the summary + at the foot of the screen says what the run would come to instead of only + where it would go. Nothing else on that screen is filled in: a count, a kind + of case and an expected outcome left alone still reach the run unstated, + which is what lets the manifest record that you did not state them. A batch + you add yourself still arrives empty, because two batches under one name is + refused. + +- **The first screen says what this tool gives you that a file generator does + not.** The sentence under `Single batch` read "Files of one format and one + size, as many as you need", which describes the mechanism. It now names the + manifest and what it is for. + - **The window is painted from one system rather than from colours chosen one at a time.** Every surface and every piece of text now comes from a single ladder: one hue, an even step between one surface and the next, and one @@ -206,6 +223,29 @@ because it turns other people's test suites red. ### Added +- **The window says where the manifest went, and opens it.** A finished run + used to say `3 files written.` and nothing else, while the same run from the + command line printed the path of the manifest beside the count. The line now + names the file - `3 files written. Manifest: manifest.json` - and a second + button, `Open manifest`, stands beside `Open folder` for as long as there is + a record to open. The manifest is what carries the expected outcome of every + file, its hash and the seed it was made from, so it is the part of a run a + test suite reads. Neither button appears after a preview, because a preview + writes nothing. + +- **The About screen says what to do with the program, in three steps.** It + opened with one sentence about what this tool is and ran straight into the + licence notice. A short section above the licence now says how to get from + an empty window to files and a manifest a test can read. + +- **The switch that chooses how a batch states its size has a name.** On + `Several batches` the row of `One size | A range | Around a limit` stood + between two fields with nothing over it, so what it was about had to be + guessed from its neighbours. It reads `How the size is given` now, with the + same explanation button every other setting has - and that explanation says + what each of the three does, including `Around a limit`, which makes three + files: one byte under the limit, one on it, one over. + - **The palette is something you can open.** `tfg --catalogue`, the hidden screen that shows every control the window is built from, now ends with the palette itself: every colour, what it is for, the value it holds and the diff --git a/internal/guard/aboutuse_test.go b/internal/guard/aboutuse_test.go new file mode 100644 index 00000000..b431ce57 --- /dev/null +++ b/internal/guard/aboutuse_test.go @@ -0,0 +1,98 @@ +package guard + +import ( + "strings" + "testing" + + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" +) + +// The screen that says what this program is also says what to do with it. +// +// Counted off the stored screen on 2026-09-22: the thesis had one sentence and +// everything under it was the licence notice and the list of what the binary +// carries. Four fifths of the one screen somebody opens to find out what they +// have answered a question about redistribution - a real question, and not the +// one anybody has first. What to do with the program was on no screen in the +// window at all. + +// TestTheAboutScreenSaysHowToUseTheProgram. +// +// Three questions, and the third is the one that makes the other two worth +// asking: the steps have to stand ABOVE the licence. A section carrying the +// same words at the bottom of a page of notices is the defect with a heading +// on it, and a guard that only asked whether the words were somewhere on the +// screen would be green for it. +func TestTheAboutScreenSaysHowToUseTheProgram(t *testing.T) { + content, _ := laidOutWindow(t) + about := tabContent(t, content, text.TabAbout()) + + steps := text.HowToUseSteps() + if len(steps) == 0 { + t.Fatal("the window offers no steps at all, so there is nothing to look for") + } + + said := shownText(about) + for _, step := range steps { + if !strings.Contains(said, step) { + t.Errorf("the About screen does not say %q.\nIt says:\n%s", step, said) + } + } + + heading, ok := labelBox(about, text.SectionHowToUse()) + if !ok { + t.Fatalf("the About screen has no section headed %q, so the steps are loose on a page of notices", + text.SectionHowToUse()) + } + licence, ok := labelBox(about, text.SectionLicence()) + if !ok { + t.Fatalf("the About screen has no section headed %q, so this guard cannot ask which comes first", + text.SectionLicence()) + } + if heading.Y >= licence.Y { + t.Errorf("%q starts at y=%.0f and %q at y=%.0f, so what to do with this program is under "+ + "the licence rather than over it", + text.SectionHowToUse(), heading.Y, text.SectionLicence(), licence.Y) + } + + // And the one sentence that was already there is still there. The steps + // are an addition rather than a replacement, and a "fix" that swallowed + // the thesis would be the screen losing the thing it is for. + if !strings.Contains(said, text.AboutTagline()) { + t.Errorf("the About screen no longer says what this program is:\n%s", said) + } +} + +// TestTheStepsNameEveryOutcomeTheManifestCanRecord. +// +// The third step tells somebody what they will find in the manifest, which +// makes it a list copied out of a closed set - and a list copied by hand that +// nothing compares with its source goes stale on green. The set is +// recipe.Outcomes(), four values today, and the first version of that step +// named two of them, invented a third word for one ("turn it away" for +// reject) and left sanitize out altogether. Nothing in the tree would have +// said so. +// +// Read from the REGISTRY rather than from a list here, so a fifth outcome is +// caught by being declared. That is the whole reason this is a guard and not +// a proofread. +func TestTheStepsNameEveryOutcomeTheManifestCanRecord(t *testing.T) { + content, _ := laidOutWindow(t) + about := tabContent(t, content, text.TabAbout()) + said := shownText(about) + + outcomes := recipe.Outcomes() + if len(outcomes) < 2 { + t.Fatalf("the recipe package declares %d outcome(s), which is too few for this to compare anything", len(outcomes)) + } + for _, outcome := range outcomes { + if !strings.Contains(said, outcome) { + t.Errorf("the manifest can record outcome %q and the About screen never uses that word, "+ + "so somebody reading the steps meets it for the first time in the JSON.\nThe screen says:\n%s", + outcome, said) + } + } + t.Logf("%d outcome(s) named on the screen: %v", len(outcomes), outcomes) +} diff --git a/internal/guard/controlnames_test.go b/internal/guard/controlnames_test.go new file mode 100644 index 00000000..b0ea2b19 --- /dev/null +++ b/internal/guard/controlnames_test.go @@ -0,0 +1,86 @@ +package guard + +import ( + "testing" + + "fyne.io/fyne/v2" + + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// Nothing a person can operate stands on the form without a name over it. +// +// The switch between the three ways of stating a size was the one that did, +// from the day it replaced three radio circles until 2026-09-23. Every box on +// that screen carried a name in the column and this stood between two of them +// with nothing, so what it was about came from what happened to sit next to +// it - which is guessing, and which is the one control the rest of the screen +// teaches somebody not to expect. + +// TestEveryControlOnTheFormStandsUnderAName walks the batch screen and asks +// every switch, box and menu on it whether the thing above it is a name. +// +// Read off the FORM rather than from a list of what should be named, which is +// the difference between this and a guard that would have stayed green: a +// control added tomorrow with no name is caught by being on the screen, and no +// list has to be remembered. The shape asked about is the one the window +// builds - a field is a container laid out as one, and its first thing is the +// name (parts.IsField) - so a control that went on some other way is not +// named by definition and is reported by position. +func TestEveryControlOnTheFormStandsUnderAName(t *testing.T) { + batches, _, _ := screenInAWindowWithHost(t, text.TabRecipe()) + + // The one this is really about is on the screen, or the walk below proves + // nothing by finding nothing. + switchOnIt := sizeWaySwitch(t, batches) + + named := map[fyne.CanvasObject]string{} + walk(batches, func(obj fyne.CanvasObject) { + field, ok := obj.(*fyne.Container) + if !ok || !parts.IsField(field) || len(field.Objects) < 2 { + return + } + head, is := headingOf(field.Objects[0]) + if !is || head == "" { + return + } + walk(field.Objects[1], func(inner fyne.CanvasObject) { named[inner] = head }) + }) + + if got, is := named[switchOnIt]; !is { + t.Errorf("the switch that chooses between %q, %q and %q stands on the form with no name over it, "+ + "so the only thing saying what it is about is what happens to be beside it", + text.SizeWayExact(), text.SizeWayRange(), text.SizeWayBoundary()) + } else if got != text.FieldSizeWay() { + t.Errorf("the switch between the three ways of stating a size stands under %q and the window calls it %q", + got, text.FieldSizeWay()) + } + + // And it is the whole class rather than the one case. Anything a person + // can type into, choose from or press on the form is asked the same + // question - see the doc above for why this is walked and not listed. + walk(batches, func(obj fyne.CanvasObject) { + if !operable(obj) || !obj.Visible() { + return + } + if _, is := named[obj]; !is { + t.Errorf("a %T is on the form with no name over it", obj) + } + }) +} + +// operable is a control somebody works with, as opposed to the words, the +// rules and the surfaces around one. +// +// Named by type rather than by "anything focusable", because a button is +// focusable and buttons say what they do on their own face - the bar at the +// foot of the screen is full of them and none belongs to the column of names. +func operable(o fyne.CanvasObject) bool { + switch o.(type) { + case *parts.Entry, *parts.Chooser, *parts.Toggle, *parts.Segments: + return true + } + return false +} diff --git a/internal/guard/manifestoffer_test.go b/internal/guard/manifestoffer_test.go new file mode 100644 index 00000000..d704b1e3 --- /dev/null +++ b/internal/guard/manifestoffer_test.go @@ -0,0 +1,135 @@ +package guard + +import ( + "os" + "path/filepath" + "strings" + "testing" + + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// What a finished run says about the record it left, and the way to open it. +// +// The command line has printed "manifest: " since there was a manifest, +// and the window said "3 files written." and nothing else - so the one thing +// this tool makes that other generators do not was, from a window, a file you +// found in the folder afterwards and wondered about. D1 asks for parity +// between the two surfaces and this is the kind that goes quietly: nothing the +// engine can do was missing, only the sentence about it. + +// TestAFinishedRunNamesTheManifestAndOffersToOpenIt. +// +// Neither half asks the window what it decided. The NAME is compared against +// the file that is actually on the disk after the run, and the BUTTON is +// pressed and its destination is compared against that same file - so a +// screen that names a manifest it did not write, or opens one it did not +// name, is red. A guard that read text.ManifestNamed and looked for it on the +// screen would be comparing the window with itself. +func TestAFinishedRunNamesTheManifestAndOffersToOpenIt(t *testing.T) { + dir := t.TempDir() + host, content, _ := keyedWindow(t) + screen := selectTab(t, content, text.TabOneTarget()) + + // Nothing has run, so there is nothing to open. Asked about what is SHOWN + // rather than what is in the tree: the button is built with the bar and + // hidden, so a guard that only looked for it would find it every time. + if shownButton(screen, text.ButtonOpenManifest()) != nil { + t.Fatal("the manifest button is on the screen before anything was written, " + + "so it points at a file that need not exist") + } + + entryUnder(t, screen, text.FieldOutputDir()).SetText(dir) + entryUnder(t, screen, text.FieldSize()).SetText("1kb") + entryUnder(t, screen, text.FieldTargetID()).SetText("done") + press(t, screen, text.ButtonGenerate()) + waitForManifest(t, host, dir) + join(host) + + // The file the run really wrote, found by looking rather than by naming: + // the manifest's name is a setting, so a guard holding the default would + // stop asking anything the day a screen wrote it somewhere else. + written := manifestIn(t, dir) + + if said := shownText(screen); !strings.Contains(said, filepath.Base(written)) { + t.Errorf("the run wrote %s and the screen never names it. It says:\n%s\n"+ + "Somebody generating from this window has no way to learn they got a manifest at all", + filepath.Base(written), said) + } + + button := shownButton(screen, text.ButtonOpenManifest()) + if button == nil { + t.Fatal("the run wrote a manifest and there is no way to open it") + } + button.OnTapped() + if host.fileCount != 1 { + t.Errorf("the manifest button was pressed and the desktop was asked to open a file %d times", host.fileCount) + } + if host.file != written { + t.Errorf("the button opens %q and the manifest of this run is %q", host.file, written) + } + // And the folder button is untouched by any of this, because two buttons + // that both open the folder would be one button drawn twice. + if host.folderCount != 0 { + t.Errorf("pressing the manifest button asked the desktop for a folder %d times", host.folderCount) + } +} + +// TestAPreviewNamesNoManifestAndOffersNone is the other end of it. +// +// A preview goes through the whole of planning with nothing written, so there +// is no record to name and no file to open - and a screen that said there was +// would be sending somebody to a file the run deliberately did not create. +// This is also the half that fails if the sentence is ever attached to the +// outcome rather than to the saving. +func TestAPreviewNamesNoManifestAndOffersNone(t *testing.T) { + dir := t.TempDir() + host, content, _ := keyedWindow(t) + screen := selectTab(t, content, text.TabOneTarget()) + + entryUnder(t, screen, text.FieldOutputDir()).SetText(dir) + entryUnder(t, screen, text.FieldSize()).SetText("1kb") + entryUnder(t, screen, text.FieldTargetID()).SetText("planned") + press(t, screen, text.ButtonPreview()) + join(host) + + // The preview really did go through, or the two questions below are being + // asked of a screen where nothing happened at all. + if said := shownText(screen); !strings.Contains(said, "1 file") { + t.Fatalf("the preview said nothing about what the form comes to, so this guard is "+ + "asking about a press that did not work. It says:\n%s\nRefusal: %q", said, anyRefusal(screen)) + } + if entries, err := os.ReadDir(dir); err != nil || len(entries) != 0 { + t.Fatalf("a preview wrote into %s: %v (err %v)", dir, whatIsIn(dir), err) + } + + if shownButton(screen, text.ButtonOpenManifest()) != nil { + t.Error("a preview wrote nothing and the screen offers to open a manifest") + } + if said := shownText(screen); strings.Contains(said, text.ManifestNamed("")) { + t.Errorf("a preview wrote nothing and the screen names a manifest:\n%s", said) + } +} + +// manifestIn is the one record a run left in a directory, found on the disk. +// +// It refuses to guess where there is more than one, because the question this +// helper answers - which file did the window just name - has no answer then. +func manifestIn(t *testing.T, dir string) string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("reading %s: %v", dir, err) + } + var found []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") { + found = append(found, filepath.Join(dir, e.Name())) + } + } + if len(found) != 1 { + t.Fatalf("%s holds %d files that could be the manifest: %v", dir, len(found), whatIsIn(dir)) + } + return found[0] +} diff --git a/internal/guard/readytorun_test.go b/internal/guard/readytorun_test.go new file mode 100644 index 00000000..5b32f3ae --- /dev/null +++ b/internal/guard/readytorun_test.go @@ -0,0 +1,115 @@ +package guard + +import ( + "strings" + "testing" + + "fyne.io/fyne/v2" + + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// Generate means the same thing on every screen it is on. +// +// It did not until 2026-09-23. The single batch screen opened with a name and +// a size typed in and ran on the first press. The batch screen opened with +// both of them empty under a red star each, so the same button under the same +// mark turned somebody down on the third tab after working on the first - and +// what they had learned on the first screen was that Generate simply works. + +// TestEveryWorkScreenOpensReadyToRun presses the button on a screen nobody has +// typed into, and asks the DISK whether anything came of it. +// +// Both screens, from one list, so the two cannot come apart again: a screen +// added tomorrow is held to the same promise by being added to it. The output +// directory is the one thing filled in, because a guard writing into whatever +// the window offers would write into the working directory of whoever runs the +// suite. +// +// Asked of the files rather than of the refusal area, and the difference +// matters: a screen can be silent and still have done nothing. The manifest on +// the disk is the only answer that cannot be arranged by the screen. +func TestEveryWorkScreenOpensReadyToRun(t *testing.T) { + for _, tab := range []string{text.TabOneTarget(), text.TabRecipe()} { + t.Run(tab, func(t *testing.T) { + dir := t.TempDir() + host, content, _ := keyedWindow(t) + screen := selectTab(t, content, tab) + + entryUnder(t, screen, text.FieldOutputDir()).SetText(dir) + press(t, screen, text.ButtonGenerate()) + // Waiting first, so that a refusal reported below is the screen's + // answer rather than a run still going. + join(host) + + if refusal := anyRefusal(screen); refusal != "" { + t.Fatalf("%s opens on a form Generate refuses: %q.\n"+ + "Somebody who has typed nothing has no way to know which box it means", tab, refusal) + } + manifestAfter(t, func() { join(host) }, dir, "manifest.json") + }) + } +} + +// TestOnlyTheFirstBatchArrivesFilledIn is the other half, and without it the +// guard above would be satisfied by filling every batch in. +// +// A batch is filled in because a form nobody has typed into has to be able to +// run. The SECOND one is a different question: two batches carrying one name +// is a refusal the recipe reader already words, so a copy arriving with the +// first one's name would be a form that has to be repaired before it can be +// used - which is why duplicating a batch leaves the copy's name empty too. +func TestOnlyTheFirstBatchArrivesFilledIn(t *testing.T) { + batches, _, _ := screenInAWindowWithHost(t, text.TabRecipe()) + + first := entryUnder(t, batches, text.FieldTargetID()) + if strings.TrimSpace(first.Text) == "" { + t.Fatal("the first batch opens with no name, so this guard is asking about a screen " + + "that never filled one in") + } + opening := first.Text + + press(t, batches, text.ButtonAddBatch()) + + names := entriesUnder(batches, text.FieldTargetID()) + if len(names) != 2 { + t.Fatalf("after adding a batch the screen draws %d boxes called %q", len(names), text.FieldTargetID()) + } + if got := strings.TrimSpace(names[1].Text); got != "" { + t.Errorf("a batch added to the screen arrives named %q, and the batch above it is named %q - "+ + "two batches with one name is a refusal, and this hands it to somebody who typed nothing", + got, opening) + } +} + +// entriesUnder is every box on a screen drawn under one name, in the order the +// screen draws them. +// +// entryUnder answers with ONE and is the right shape for a screen holding one +// of each. This screen repeats a whole block, so "the box called Batch name" +// is a question with as many answers as there are batches - and the defect +// this guard is about lives in the second answer. +func entriesUnder(o fyne.CanvasObject, label string) []*parts.Entry { + var found []*parts.Entry + walk(o, func(obj fyne.CanvasObject) { + field, ok := obj.(*fyne.Container) + if !ok || !parts.IsField(field) || len(field.Objects) < 2 { + return + } + if head, named := headingOf(field.Objects[0]); !named || head != label { + return + } + var box *parts.Entry + walk(field.Objects[1], func(inner fyne.CanvasObject) { + if e, is := inner.(*parts.Entry); is && box == nil { + box = e + } + }) + if box != nil { + found = append(found, box) + } + }) + return found +} diff --git a/internal/guard/recipescreen_test.go b/internal/guard/recipescreen_test.go index a7c99e8b..e7f2d2dc 100644 --- a/internal/guard/recipescreen_test.go +++ b/internal/guard/recipescreen_test.go @@ -56,6 +56,20 @@ func TestEveryRefusalAboutABatchMarksTheBoxOfThatBatch(t *testing.T) { fields := screen.Fields() setBox(t, fields, recipe.TargetAddress(c.fill, recipe.KeyID), "filled") setBox(t, fields, recipe.TargetAddress(c.fill, recipe.KeySize), "1kb") + // The other batch is EMPTIED rather than assumed empty, and that + // is the difference between a guard and a green one. The screen + // arrives with its first batch filled in since 2026-09-23 - so + // the case that puts the empty batch first went from asking about + // a misplaced refusal to asking about a run with nothing wrong + // with it, and passed by producing no refusal at all. + setBox(t, fields, recipe.TargetAddress(c.wantMarked, recipe.KeyID), "") + setBox(t, fields, recipe.TargetAddress(c.wantMarked, recipe.KeySize), "") + for _, setting := range []string{recipe.KeyID, recipe.KeySize} { + at := recipe.TargetAddress(c.wantMarked, setting) + if got := boxText(t, fields, at); got != "" { + t.Fatalf("%q still holds %q, so the batch this case is about is not the empty one", at, got) + } + } pressNamed(t, body, text.ButtonPreview()) diff --git a/internal/guard/regressiontable_test.go b/internal/guard/regressiontable_test.go index 85580e15..6527f8a1 100644 --- a/internal/guard/regressiontable_test.go +++ b/internal/guard/regressiontable_test.go @@ -55,7 +55,6 @@ var notYetJustified = []string{ "exeproperties_test.go", "foldedsections_test.go", - "guitext_test.go", "keyboard_test.go", "livecheck_test.go", "openlist_test.go", diff --git a/internal/guard/testdata/screens/about.png b/internal/guard/testdata/screens/about.png index 49451146..a2ea2379 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 f0030be8..4fbd1bc4 100644 --- a/internal/guard/testdata/screens/about.xml +++ b/internal/guard/testdata/screens/about.xml @@ -31,8 +31,8 @@ - - + + @@ -55,7 +55,54 @@ - + + + + + How to use it + + + + + + + Choose a preset, or fill in one batch on the first screen. + + + + + • + + + + + + + Press Generate. The files and a manifest land in the output folder. + + + + + • + + + + + + + Point your test at the manifest. For every file it says what the system under test should do with it - accept it, reject it or sanitize it - or + records the outcome as unspecified, where the right answer belongs to the application's own policy. + + + + + • + + + + + + @@ -89,7 +136,7 @@ - + @@ -115,7 +162,7 @@ - + @@ -163,7 +210,7 @@ - + @@ -188,7 +235,7 @@ - + @@ -212,8 +259,8 @@ - - + + diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index 890dcc19..55c2c7b3 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 87bfbf0e..159d2a1c 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -1,7 +1,7 @@ - + - - + + @@ -1500,10 +1500,10 @@ - - - - + + + + Fields @@ -1670,7 +1670,32 @@ - + + + + + + a named control with no setting behind it + + + + + + How the size is given + + + + + + + One size + A range + Around a limit + + + + + @@ -1783,7 +1808,7 @@ - + @@ -1826,7 +1851,7 @@ - + @@ -1862,7 +1887,7 @@ - + @@ -2101,7 +2126,7 @@ - + @@ -2235,7 +2260,7 @@ - + @@ -2353,7 +2378,7 @@ - + @@ -2417,7 +2442,7 @@ - + @@ -2470,7 +2495,7 @@ - + @@ -2911,10 +2936,10 @@ - - - - + + + + Title @@ -2950,7 +2975,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. @@ -2980,7 +3005,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. @@ -3108,7 +3133,45 @@ - + + + + + + a list whose items do not fit on one line + + + + + + + + + + + One line + + + + + • + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it Write a label inside each generated file, + including the ones that are far too small to hold it + + + + + • + + + + @@ -3128,7 +3191,7 @@ - + @@ -3181,7 +3244,7 @@ - + @@ -3427,7 +3490,7 @@ - + @@ -3505,7 +3568,7 @@ - + @@ -4211,7 +4274,7 @@ - + @@ -4917,7 +4980,7 @@ - + @@ -5035,7 +5098,7 @@ - + diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png index 082a912b..68e5b487 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 a18b1420..48d2bc3a 100644 --- a/internal/guard/testdata/screens/generate-chosen-by-key.xml +++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-chosen.png b/internal/guard/testdata/screens/generate-chosen.png index c46e3a84..45ce449f 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 659fb12e..e310030b 100644 --- a/internal/guard/testdata/screens/generate-chosen.xml +++ b/internal/guard/testdata/screens/generate-chosen.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-empty.png b/internal/guard/testdata/screens/generate-empty.png index 12f86c9f..573d3b8d 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 48b1a8e4..fbabf014 100644 --- a/internal/guard/testdata/screens/generate-empty.xml +++ b/internal/guard/testdata/screens/generate-empty.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png index d79b289c..d1dc30c0 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 4cf27f18..4a857b66 100644 --- a/internal/guard/testdata/screens/generate-focused.xml +++ b/internal/guard/testdata/screens/generate-focused.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-hovered.png b/internal/guard/testdata/screens/generate-hovered.png index e0925883..89f1de3d 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 ece703d0..dffef6d2 100644 --- a/internal/guard/testdata/screens/generate-hovered.xml +++ b/internal/guard/testdata/screens/generate-hovered.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index 5b44d6b3..96e919ac 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 33248009..ae5e82b2 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index 7f41e3a6..64cf0b12 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 c22a992c..37408b85 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index 6e750b9b..fdc15a90 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 1f5e338e..3273c33c 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png index da2ab987..15365edf 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 492dacdd..f5eccac8 100644 --- a/internal/guard/testdata/screens/generate-refused-both.xml +++ b/internal/guard/testdata/screens/generate-refused-both.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png index d87a266a..4aee4dd9 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 56b29e77..705729f6 100644 --- a/internal/guard/testdata/screens/generate-refused-setting.xml +++ b/internal/guard/testdata/screens/generate-refused-setting.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png index 07ba3398..25bbcbbb 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 0e7ca80b..14148abc 100644 --- a/internal/guard/testdata/screens/generate-refused.xml +++ b/internal/guard/testdata/screens/generate-refused.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png index 01dc4b6e..1547f706 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 1d72b60f..70a8230d 100644 --- a/internal/guard/testdata/screens/generate-switch-by-key.xml +++ b/internal/guard/testdata/screens/generate-switch-by-key.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-typed.png b/internal/guard/testdata/screens/generate-typed.png index eaac9515..00b3dc45 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 cf5994bb..06528c9f 100644 --- a/internal/guard/testdata/screens/generate-typed.xml +++ b/internal/guard/testdata/screens/generate-typed.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate-unchecked.png b/internal/guard/testdata/screens/generate-unchecked.png index d773ec8e..ff08a9f2 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 382b0228..1c7dd2aa 100644 --- a/internal/guard/testdata/screens/generate-unchecked.xml +++ b/internal/guard/testdata/screens/generate-unchecked.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png index 2f6d807f..036189e9 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 bbed224e..0bd87f60 100644 --- a/internal/guard/testdata/screens/generate.xml +++ b/internal/guard/testdata/screens/generate.xml @@ -49,7 +49,7 @@ - Files of one format and one size, as many as you need. + Files of one format and one size, with a manifest that says how the system under test should react to them. diff --git a/internal/guard/testdata/screens/recipe-contents.png b/internal/guard/testdata/screens/recipe-contents.png index 4d91a54c..f02f0165 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 68ac7192..2bb4cd56 100644 --- a/internal/guard/testdata/screens/recipe-contents.xml +++ b/internal/guard/testdata/screens/recipe-contents.xml @@ -32,8 +32,8 @@ - - + + @@ -93,18 +93,18 @@ - - - - - - + + + + + + - - + + @@ -112,8 +112,8 @@ - - + + @@ -128,7 +128,7 @@ - + @@ -177,10 +177,7 @@ - - - - + files @@ -214,19 +211,29 @@ - - - - - - - - One size - A range - Around a limit - - - + + + + How the size is given + + + + + + + + + + + + + One size + A range + Around a limit + + + + Size @@ -247,10 +254,7 @@ - - - - + 10mb @@ -262,7 +266,7 @@ - + 10 485 760 B @@ -270,7 +274,7 @@ - + File names @@ -301,7 +305,7 @@ - + @@ -327,7 +331,7 @@ - + @@ -353,7 +357,7 @@ - + @@ -449,7 +453,7 @@ - + @@ -581,8 +585,8 @@ - - + + diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.png b/internal/guard/testdata/screens/recipe-on-a-preset.png index 131f3190..1287862d 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 8f6df54f..d281ae43 100644 --- a/internal/guard/testdata/screens/recipe-on-a-preset.xml +++ b/internal/guard/testdata/screens/recipe-on-a-preset.xml @@ -32,8 +32,8 @@ - - + + @@ -152,18 +152,18 @@ - - - - - - + + + + + + - - + + @@ -171,8 +171,8 @@ - - + + @@ -192,7 +192,7 @@ - + @@ -241,10 +241,7 @@ - - - - + files @@ -278,19 +275,29 @@ - - - - - - - - One size - A range - Around a limit - - - + + + + How the size is given + + + + + + + + + + + + + One size + A range + Around a limit + + + + Size @@ -311,10 +318,7 @@ - - - - + 10mb @@ -326,7 +330,7 @@ - + 10 485 760 B @@ -334,7 +338,7 @@ - + File names @@ -365,7 +369,7 @@ - + @@ -391,7 +395,7 @@ - + @@ -423,7 +427,7 @@ - + @@ -555,8 +559,8 @@ - - + + @@ -584,11 +588,12 @@ - - - - - Files will go to /tfg/out + + + + + 29 files · 10.0 MB (10 518 427 B) · avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, + tiff, toml, txt, wav, webp, xlsx, xml, yaml, zip · will go to /tfg/out 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 c2408062..071e40c8 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 b518f6c6..031720dd 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 @@ -32,8 +32,8 @@ - - + + @@ -93,18 +93,18 @@ - - - - - - + + + + + + - - + + @@ -112,8 +112,8 @@ - - + + @@ -133,7 +133,7 @@ - + @@ -161,7 +161,7 @@ - + Batch name @@ -178,38 +178,21 @@ - + - - - - + files - - + - - - - - - target 1 has no Batch name - a Batch name anchors the seed of a target, so editing one target never moves the bytes - of another. - give it a Batch name, for example id: invoices. - - - - - - + How many files @@ -233,19 +216,29 @@ - - - - - - - - One size - A range - Around a limit - - - + + + + How the size is given + + + + + + + + + + + + + One size + A range + Around a limit + + + + Size @@ -266,35 +259,19 @@ - - - - + 10mb - + - - - - - - - - - - - - target 1 has no Size - every target declares its Size, which is what lets a dry run report exact numbers before anything - reaches the disk. - add size: 2mb, size-range: 1kb-8kb, a boundary, contains, or a plain number of bytes. + 10 485 760 B @@ -302,7 +279,7 @@ - + File names @@ -333,7 +310,7 @@ - + @@ -359,7 +336,7 @@ - + @@ -389,10 +366,10 @@ - - - - + + + + @@ -427,7 +404,7 @@ - + @@ -510,19 +487,29 @@ - - - - - - - - One size - A range - Around a limit - - - + + + + How the size is given + + + + + + + + + + + + + One size + A range + Around a limit + + + + Size @@ -563,7 +550,7 @@ - + File names @@ -594,7 +581,7 @@ - + @@ -620,7 +607,7 @@ - + @@ -652,7 +639,7 @@ - + @@ -769,7 +756,7 @@ - + @@ -780,15 +767,12 @@ - - - - - + + @@ -820,7 +804,7 @@ - Files will go to /tfg/out + 2 files · 10.0 MB (10 486 784 B) · avif · will go to /tfg/out diff --git a/internal/guard/testdata/screens/recipe-refused.png b/internal/guard/testdata/screens/recipe-refused.png index f6d40600..3666a4c0 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 f12cced0..7c7c6b6e 100644 --- a/internal/guard/testdata/screens/recipe-refused.xml +++ b/internal/guard/testdata/screens/recipe-refused.xml @@ -32,8 +32,8 @@ - - + + @@ -93,18 +93,18 @@ - - - - - - + + + + + + - - + + @@ -112,8 +112,8 @@ - - + + @@ -128,7 +128,7 @@ - + @@ -156,7 +156,7 @@ - + Batch name @@ -173,38 +173,21 @@ - + - - - - + files - - + - - - - - - target 1 has no Batch name - a Batch name anchors the seed of a target, so editing one target never moves the bytes - of another. - give it a Batch name, for example id: invoices. - - - - - - + How many files @@ -228,19 +211,29 @@ - - - - - - - - One size - A range - Around a limit - - - + + + + How the size is given + + + + + + + + + + + + + One size + A range + Around a limit + + + + Size @@ -261,35 +254,19 @@ - - - - + 10mb - + - - - - - - - - - - - - target 1 has no Size - every target declares its Size, which is what lets a dry run report exact numbers before anything - reaches the disk. - add size: 2mb, size-range: 1kb-8kb, a boundary, contains, or a plain number of bytes. + 10 485 760 B @@ -297,7 +274,7 @@ - + File names @@ -328,7 +305,7 @@ - + @@ -354,7 +331,7 @@ - + @@ -386,7 +363,7 @@ - + @@ -503,7 +480,7 @@ - + @@ -514,15 +491,12 @@ - - - - - + + @@ -554,7 +528,7 @@ - Files will go to /tfg/out + 1 file · 10.0 MB (10 485 760 B) · avif · will go to /tfg/out diff --git a/internal/guard/testdata/screens/recipe-two-batches.png b/internal/guard/testdata/screens/recipe-two-batches.png index 84dccd58..6cba1326 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 002f432e..99088625 100644 --- a/internal/guard/testdata/screens/recipe-two-batches.xml +++ b/internal/guard/testdata/screens/recipe-two-batches.xml @@ -32,8 +32,8 @@ - - + + @@ -93,18 +93,18 @@ - - - - - - + + + + + + - - + + @@ -112,8 +112,8 @@ - - + + @@ -133,7 +133,7 @@ - + @@ -182,10 +182,7 @@ - - - - + files @@ -219,19 +216,29 @@ - - - - - - - - One size - A range - Around a limit - - - + + + + How the size is given + + + + + + + + + + + + + One size + A range + Around a limit + + + + Size @@ -252,10 +259,7 @@ - - - - + 10mb @@ -267,7 +271,7 @@ - + 10 485 760 B @@ -275,7 +279,7 @@ - + File names @@ -306,7 +310,7 @@ - + @@ -332,7 +336,7 @@ - + @@ -362,10 +366,10 @@ - - - - + + + + @@ -400,7 +404,7 @@ - + @@ -486,19 +490,29 @@ - - - - - - - - One size - A range - Around a limit - - - + + + + How the size is given + + + + + + + + + + + + + One size + A range + Around a limit + + + + Size @@ -542,7 +556,7 @@ - + File names @@ -573,7 +587,7 @@ - + @@ -599,7 +613,7 @@ - + @@ -631,7 +645,7 @@ - + @@ -763,8 +777,8 @@ - - + + diff --git a/internal/guard/testdata/screens/recipe.png b/internal/guard/testdata/screens/recipe.png index 97b8c990..3666a4c0 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 b369ac67..87d48954 100644 --- a/internal/guard/testdata/screens/recipe.xml +++ b/internal/guard/testdata/screens/recipe.xml @@ -32,8 +32,8 @@ - - + + @@ -93,18 +93,18 @@ - - - - - - + + + + + + - - + + @@ -112,8 +112,8 @@ - - + + @@ -128,7 +128,7 @@ - + @@ -177,10 +177,7 @@ - - - - + files @@ -214,19 +211,29 @@ - - - - - - - - One size - A range - Around a limit - - - + + + + How the size is given + + + + + + + + + + + + + One size + A range + Around a limit + + + + Size @@ -247,10 +254,7 @@ - - - - + 10mb @@ -262,7 +266,7 @@ - + 10 485 760 B @@ -270,7 +274,7 @@ - + File names @@ -301,7 +305,7 @@ - + @@ -327,7 +331,7 @@ - + @@ -359,7 +363,7 @@ - + @@ -491,8 +495,8 @@ - - + + @@ -524,7 +528,7 @@ - Files will go to /tfg/out + 1 file · 10.0 MB (10 485 760 B) · avif · will go to /tfg/out diff --git a/internal/guard/typeshape_test.go b/internal/guard/typeshape_test.go index 39b896aa..71121c7e 100644 --- a/internal/guard/typeshape_test.go +++ b/internal/guard/typeshape_test.go @@ -41,7 +41,14 @@ const ( // ceilings. The busy state and its four controls left it that day for a // type of their own (runbusy.go), and the ratchet moved down to the next // widest: parts.Fields by methods, window.batch by fields. - mostMethods = 26 + // + // Lowered from 26 on 2026-09-23. The runner was one method past this and + // one field past the one below when the manifest got a button beside the + // folder's, and the ceiling is a ratchet - so what a finished run leaves + // behind and the way to each of it left for a type of their own + // (runoffers.go), taking two buttons, two paths and two ways to open + // them with it. The widest type by methods is parts.Fields now. + mostMethods = 25 mostFields = 21 // What counts as crowding, in the shape this package already uses diff --git a/internal/guard/window_test.go b/internal/guard/window_test.go index ad89e591..29183602 100644 --- a/internal/guard/window_test.go +++ b/internal/guard/window_test.go @@ -78,6 +78,12 @@ type fakeHost struct { canvas fyne.Canvas folder string folderCount int + // file and fileCount are the same for the one FILE the window offers to + // open, which is the manifest of a finished run. Kept apart from the + // folder above rather than counted together, because the guard's question + // is which of the two buttons was pressed and what each of them pointed at. + file string + fileCount int kept *keptInMemory @@ -307,6 +313,14 @@ func (h *fakeHost) OpenFolder(path string) { h.folderCount++ } +// OpenFile records the file a screen asked to have opened, for the same reason +// and with the same restraint: a stand in that really opened one would put an +// editor on somebody's screen for every guard that finishes a run. +func (h *fakeHost) OpenFile(path string) { + h.file = path + h.fileCount++ +} + // Remembered is a store in memory, which is the whole reason the screens take // one through the Host rather than reaching the toolkit's global preferences. // A guard can say what the window kept without a single byte reaching a disk, diff --git a/internal/gui/catalogue/fields.go b/internal/gui/catalogue/fields.go index 7116398a..d4980c8f 100644 --- a/internal/gui/catalogue/fields.go +++ b/internal/gui/catalogue/fields.go @@ -44,6 +44,15 @@ func fields() Entry { s := form() return s.AddToggle("label", "Label in each file", "", parts.NoDetail, parts.NewToggle(func(bool) {})) }}, + {"a named control with no setting behind it", func() fyne.CanvasObject { + // It looks like a field and is not one: nothing is ever refused + // about it, so it carries no room for a message and no star. The + // one in the application chooses between three ways of stating a + // size - see parts.Fields.Named. + s := form() + return s.Named("How the size is given", parts.NoDetail, + parts.NewSegments([]string{"One size", "A range", "Around a limit"}, nil)) + }}, {"a table: the names once over the columns, cells under them", func() fyne.CanvasObject { // Two rows, because one row cannot show what the header is for: // until 2026-09-16 every cell drew its own name, and the second diff --git a/internal/gui/catalogue/words.go b/internal/gui/catalogue/words.go index cd6c7078..5b6e7dae 100644 --- a/internal/gui/catalogue/words.go +++ b/internal/gui/catalogue/words.go @@ -15,10 +15,10 @@ func textRanks() Entry { return Entry{Name: "Title", Covers: []string{"Subtitle", "Titled", "Heading", "Subheading", "Prose", "Note", "Caption", "Bullets"}, States: []State{ {"the title of a screen", func() fyne.CanvasObject { return parts.Title("Single batch") }}, {"the sentence under a title", func() fyne.CanvasObject { - return parts.Subtitle("Files of one format and one size, as many as you need.") + return parts.Subtitle("Files of one format and one size, with a manifest that says how the system under test should react to them.") }}, {"a title with its sentence", func() fyne.CanvasObject { - return parts.Titled("Single batch", "Files of one format and one size, as many as you need.") + return parts.Titled("Single batch", "Files of one format and one size, with a manifest that says how the system under test should react to them.") }}, {"the name of a field", func() fyne.CanvasObject { return parts.Heading("Output directory") }}, {"the name of a block inside a section", func() fyne.CanvasObject { return parts.Subheading("Typically finds") }}, @@ -31,6 +31,19 @@ func textRanks() Entry { {"a list of short statements", func() fyne.CanvasObject { return parts.Bullets([]string{"Upload validators", "Size limits", "Archive handling"}) }}, + {"a list whose items do not fit on one line", func() fyne.CanvasObject { + // Here because the list above could not show it and the window + // now draws one: the About screen's three steps, where the third + // runs to two lines. Rule 4 of the owner's list asks every + // component to be in this catalogue with a very long text in it, + // and a marker beside a wrapped item is exactly the case that + // list of short statements cannot ask about (O235). + // Twice the long line rather than once: measured off the stored + // tree, one of them is 504 px inside 777 px of room and does not + // wrap at all, so a state built from it would have been a state + // that cannot show the thing it is named after. + return parts.Bullets([]string{"One line", longText + " " + longText}) + }}, {"a caption, the smallest rank", func() fyne.CanvasObject { return parts.Caption("10 485 760 B") }}, diff --git a/internal/gui/parts/fields.go b/internal/gui/parts/fields.go index e86f337a..9d21ca4e 100644 --- a/internal/gui/parts/fields.go +++ b/internal/gui/parts/fields.go @@ -105,10 +105,13 @@ type Fields struct { // nobody would find by looking. shortcuts func(fyne.Shortcut) - // bare are the controls on the form that have no setting and no name of - // their own and are still part of the form - the switch between the three - // ways of stating a size. Each remembers how many fields stood before it, - // so that KeepFirst can throw it away with the fields it arrived with. + // bare are the controls on the form that have no SETTING behind them and + // are still part of the form - the switch between the three ways of + // stating a size. They carry a name like everything else in the column + // since 2026-09-23, so what is bare about them is the registry entry + // rather than the label - see Named. Each remembers how many fields stood + // before it, so that KeepFirst can throw it away with the fields it + // arrived with. // // A second list rather than an entry in list with an empty key, because // list is what a refusal is addressed against and what the guards compare @@ -249,15 +252,28 @@ func (s *Fields) register(setting, label string, detail Detail, control fyne.Can return built.Object } -// Unlabelled is a row of the form for something that is not a field and has -// no name of its own - the switch that chooses between three ways of saying -// how big - so it stands in the column of controls like everything else, and -// freezes with them. -func (s *Fields) Unlabelled(control fyne.CanvasObject) fyne.CanvasObject { +// Named is a row of the form for a control that is not a field: it has a name +// and an explanation like everything else in the column, and no setting behind +// it, so nothing is ever refused about it and nothing of it reaches a recipe. +// The switch that chooses between three ways of saying how big is the one. +// +// It was Unlabelled until 2026-09-23, and drew the control with no name over +// it at all - which left the one control on the batch screen whose subject +// came from where it sat rather than from anything written. The registration +// is the half that did not change: a control that goes on the form has to +// freeze with the form, and that is what this list is for. +// +// The name is drawn here rather than by registering a field, and the +// difference is load bearing. A field carries the address a refusal is placed +// by, and a switch cannot be refused - one of its three is always chosen - +// so registering one would put a key in the registry that no message can ever +// arrive for, and every guard comparing the registry with what a run refuses +// would have an entry to explain. +func (s *Fields) Named(label string, detail Detail, control fyne.CanvasObject) fyne.CanvasObject { if d, ok := control.(fyne.Disableable); ok { s.bare = append(s.bare, bareControl{control: d, after: len(s.list)}) } - return control + return FieldStack(headingRow(label, detail, false), control) } // AddToggle is a switch, and since 2026-09-15 it is a field like any other: its diff --git a/internal/gui/run_cgo.go b/internal/gui/run_cgo.go index 8d483784..1857f02d 100644 --- a/internal/gui/run_cgo.go +++ b/internal/gui/run_cgo.go @@ -216,7 +216,21 @@ func (d desktop) OpenLink(address string) { // A refusal is swallowed for the same reason OpenLink swallows one: there is // nothing useful to say to somebody whose desktop has no file manager, and the // path is on the screen for anybody who wants to copy it. -func (d desktop) OpenFolder(path string) { +func (d desktop) OpenFolder(path string) { openPath(path) } + +// OpenFile asks the desktop to open one file with whatever opens that kind. +// +// The same address and the same swallowed refusal as the folder above, and +// two methods rather than one because they are not the same thing to the +// person pressing. A folder opens in a file manager every desktop has. A +// file opens in whatever is registered for its kind, or in a dialog asking +// which, or in nothing at all where neither exists - so this is the one of +// the two that can quietly do nothing, and the window keeps the folder +// button beside it for exactly that. +func (d desktop) OpenFile(path string) { openPath(path) } + +// openPath hands one absolute path to the desktop as a file address. +func openPath(path string) { absolute, err := filepath.Abs(path) if err != nil { return diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index bf8eff1f..6ccd44d6 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -39,6 +39,10 @@ "description": "The words on a button.", "other": "Open folder" }, + "ButtonOpenManifest": { + "description": "The words on a button.", + "other": "Open manifest" + }, "ButtonPreview": { "description": "The words on a button.", "other": "Preview" @@ -127,6 +131,10 @@ "description": "The longer explanation behind the button beside a field name.", "other": "Two sizes with a hyphen, as 1kb-8kb. Each file gets its own size, drawn from the seed, so the run repeats." }, + "DetailSizeWay": { + "description": "The longer explanation behind the button beside a field name.", + "other": "One size gives every file the same size. A range draws a different size for each file. Around a limit makes three files: one byte under the limit, one on it, one over." + }, "DetailTargetID": { "description": "The longer explanation behind the button beside a field name.", "other": "It reaches the manifest and the file names, so a test can tell these files from the rest of the run. The seeds are derived from it, so changing it changes the bytes." @@ -203,6 +211,10 @@ "description": "The name above a box somebody fills in.", "other": "Size range" }, + "FieldSizeWay": { + "description": "The name above a box somebody fills in.", + "other": "How the size is given" + }, "FieldTargetID": { "description": "The name above a box somebody fills in.", "other": "Batch name" @@ -276,6 +288,22 @@ "description": "The line under a field name, saying what the field does.", "other": "A short name for this batch." }, + "HowToUseChoose": { + "description": "Shown in the window.", + "other": "Choose a preset, or fill in one batch on the first screen." + }, + "HowToUsePress": { + "description": "Shown in the window.", + "other": "Press Generate. The files and a manifest land in the output folder." + }, + "HowToUseRead": { + "description": "Shown in the window.", + "other": "Point your test at the manifest. For every file it says what the system under test should do with it - accept it, reject it or sanitize it - or records the outcome as unspecified, where the right answer belongs to the application's own policy." + }, + "ManifestNamed": { + "description": "Shown in the window. Carries one value, {{.Name}}, which has to stay spelled exactly that way.", + "other": "Manifest: {{.Name}}" + }, "ManifestNotSaved": { "description": "Shown in the window. Carries one value, {{.Path}}, which has to stay spelled exactly that way.", "other": "the files were written and the manifest could not be saved to {{.Path}}" @@ -376,6 +404,10 @@ "description": "The heading over a group of fields.", "other": "File configuration" }, + "SectionHowToUse": { + "description": "The heading over a group of fields.", + "other": "How to use it" + }, "SectionLicence": { "description": "The heading over a group of fields.", "other": "Licence" @@ -438,7 +470,7 @@ }, "SubtitleGenerate": { "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, as many as you need." + "other": "Files of one format and one size, with a manifest that says how the system under test should react to them." }, "SubtitlePreset": { "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 60b37318..41a1a76a 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -12,8 +12,17 @@ import ( // one name rather than two. The old titles moved under the new ones first, // word for word, and the owner allowed them to be rewritten the same day: // "Generate files" under "Single batch" explained nothing the tab had not. +// +// SubtitleGenerate names the manifest as of 2026-09-23, and that is the one +// sentence a first start has in which to say what this tool is. It read +// "Files of one format and one size, as many as you need", which describes +// the mechanism and is true of every other generator of test files - so the +// screen the window opens on looked exactly like the tools this one is not. +// The thing it does that they do not is the manifest, and the manifest was +// named on no work screen at all: not in the form, not after a run. Named +// here it costs a clause on a line that was already there. func SubtitleGenerate() string { - return say("SubtitleGenerate", "Files of one format and one size, as many as you need.") + return say("SubtitleGenerate", "Files of one format and one size, with a manifest that says how the system under test should react to them.") } func SubtitlePreset() string { @@ -32,6 +41,46 @@ func AboutTagline() string { return say("AboutTagline", "Generate test files, and know how the system under test should react to them.") } +// SectionHowToUse heads the three steps under the tagline, and it is there +// because of what the screen was made of without it. +// +// Counted on 2026-09-22: About gave the thesis one sentence and then ran +// straight into the licence notice and the list of what the binary carries, +// so four fifths of the screen answered a question about redistribution - +// which is a real question and not the first one anybody has. Somebody who +// has just opened this program is asking what to do with it, and the answer +// was on no screen in the window. +func SectionHowToUse() string { return say("SectionHowToUse", "How to use it") } + +// HowToUseSteps is that answer, in the order somebody does it: choose what to +// make, make it, and then use what came out. +// +// The third step is the one the other two are for. It is also the only place +// in the window that says the manifest may decline to have an opinion - a run +// records outcome: unspecified wherever the right answer belongs to the +// application's own policy (manifest rule MF5), and a person writing a test +// against the file would otherwise meet that for the first time in the JSON. +// +// It names the outcomes with the SPELLING THE MANIFEST USES, and both halves +// of that were wrong when this was written on 2026-09-23. It said "turn it +// away" for an outcome the document calls reject, which is a second word for +// a contract value somebody is about to read - and it left "sanitize" out +// altogether, so the list promised three answers where the closed set has +// four (internal/recipe: accept, reject, sanitize, unspecified). The first +// half came from an outside review of #125, the second was found while +// checking it. American spelling because that is the value, not a preference. +// +// A list of sentences rather than one paragraph per step: the window draws +// these as the bullets the preset screen already uses for what a set +// typically finds, so the three steps are read at a glance rather than read. +func HowToUseSteps() []string { + return []string{ + say("HowToUseChoose", "Choose a preset, or fill in one batch on the first screen."), + say("HowToUsePress", "Press Generate. The files and a manifest land in the output folder."), + say("HowToUseRead", "Point your test at the manifest. For every file it says what the system under test should do with it - accept it, reject it or sanitize it - or records the outcome as unspecified, where the right answer belongs to the application's own policy."), + } +} + // The tabs across the top, which are where moving between screens lives. // // They were buttons in the row of actions under the last field until @@ -371,10 +420,31 @@ func FieldBoundary() string { return say("FieldBoundary", "Limit to test") } func SizeWayExact() string { return say("SizeWayExact", "One size") } func SizeWayRange() string { return say("SizeWayRange", "A range") } func SizeWayBoundary() string { return say("SizeWayBoundary", "Around a limit") } -func FieldGroup() string { return say("FieldGroup", "Kind of case") } -func FieldExpected() string { return say("FieldExpected", "Expected outcome") } -func FieldReason() string { return say("FieldReason", "Rule being tested") } -func FieldManifest() string { return say("FieldManifest", "Manifest file name") } + +// FieldSizeWay names that switch, and until 2026-09-23 it had no name at all. +// +// It was the one control on the batch screen standing between two named +// fields with nothing over it, so what it was about came from where it sat - +// which is guessing, and which puts it outside the rule that every element +// belongs to something. The words say HOW the size is stated, where the +// label under it says WHAT the box holds, for the reason written above the +// three words themselves. +func FieldSizeWay() string { return say("FieldSizeWay", "How the size is given") } + +// DetailSizeWay says what each of the three does, and it is behind the button +// because it is read once. +// +// All three in one sentence each, deliberately: the line under a box explains +// only the way already chosen, so before choosing there is nothing on the +// screen that compares them - and "Around a limit" is both the least obvious +// of the three and the one this tool is really for. +func DetailSizeWay() string { + return say("DetailSizeWay", "One size gives every file the same size. A range draws a different size for each file. Around a limit makes three files: one byte under the limit, one on it, one over.") +} +func FieldGroup() string { return say("FieldGroup", "Kind of case") } +func FieldExpected() string { return say("FieldExpected", "Expected outcome") } +func FieldReason() string { return say("FieldReason", "Rule being tested") } +func FieldManifest() string { return say("FieldManifest", "Manifest file name") } // The line under each of the recipe screen's own fields. func HintSizeRange() string { return say("HintSizeRange", "A different size for every file.") } diff --git a/internal/gui/text/text.go b/internal/gui/text/text.go index ca4f40d1..e3bbfd99 100644 --- a/internal/gui/text/text.go +++ b/internal/gui/text/text.go @@ -50,7 +50,17 @@ func ButtonGenerate() string { return say("ButtonGenerate", "Generate") } // ButtonOpenFolder shows the directory a finished run wrote into. It is on the // bar only while there is something to open. func ButtonOpenFolder() string { return say("ButtonOpenFolder", "Open folder") } -func ButtonCancel() string { return say("ButtonCancel", "Cancel") } + +// ButtonOpenManifest opens the record a finished run wrote, beside the button +// that opens the folder it wrote into, and under the same rule: on the bar +// only while there is one. +// +// "Manifest" rather than the file's name, because the name is a setting - the +// batch screen has a box for it - and a button whose words change with a box +// is a button somebody has to read twice. The name is in the sentence beside +// it, which is where a value belongs. +func ButtonOpenManifest() string { return say("ButtonOpenManifest", "Open manifest") } +func ButtonCancel() string { return say("ButtonCancel", "Cancel") } // files is a count with its noun, in the right number. // @@ -395,3 +405,34 @@ func WrittenWithFailures(written, failed int) string { func Written(written int) string { return sayf("Written", "{{.Files}} written.", map[string]any{"Files": files(written)}) } + +// ManifestNamed is the record a run left, said by name on the line that says +// the run is over. +// +// The command line has printed "manifest: " since there was one, and the +// window said "3 files written." and nothing else - so the one thing this tool +// makes that other generators do not was, from a window, something you found +// in the folder afterwards and wondered about. D1 asks for parity between the +// two surfaces, and this is the kind that is lost quietly: nothing the engine +// can do is missing, only the sentence about it. +// +// The name alone rather than the whole path, because the folder is named by +// the button beside this and by the field the run was started from, and a +// path on the line that says a run succeeded is a line that wraps. +func ManifestNamed(name string) string { + return sayf("ManifestNamed", "Manifest: {{.Name}}", map[string]any{"Name": name}) +} + +// SaidWithManifest joins what a run did to the record it left, in that order. +// +// Here rather than at the call site for the reason OneExplanation gives: what +// separates two sentences is a decision about writing rather than about +// running, and a language that ends a sentence some other way needs one place +// to change it. Handed back untouched when there is no manifest to name - a +// run refused before it started leaves none, and it must not be told it did. +func SaidWithManifest(said, name string) string { + if name == "" { + return said + } + return said + " " + ManifestNamed(name) +} diff --git a/internal/gui/window/about.go b/internal/gui/window/about.go index 32512362..e612fe3e 100644 --- a/internal/gui/window/about.go +++ b/internal/gui/window/about.go @@ -50,6 +50,17 @@ func About(h Host) fyne.CanvasObject { sections = append(sections, parts.Indented(parts.Prose(text.DrawingWithSoftwareRenderer()))) } sections = append(sections, + // What to do with the program, before what may be done with the source + // of it. Counted on the stored screen of 2026-09-22: the thesis had one + // sentence and everything under it was the licence and the list of what + // the binary carries, so four fifths of this screen answered a question + // about redistribution - a real question, and not the one somebody has + // on the day they open this. + // + // Bullets rather than paragraphs, and the same ones the preset screen + // draws under "Typically finds:" - three steps are read at a glance in + // a list and read one by one in prose. + parts.Section(text.SectionHowToUse(), parts.Bullets(text.HowToUseSteps())), // In a card like every other block on every other screen, so this reads // as a page of the application rather than as the one screen that was // left as it was. diff --git a/internal/gui/window/formdefaults.go b/internal/gui/window/formdefaults.go new file mode 100644 index 00000000..ad05c8b3 --- /dev/null +++ b/internal/gui/window/formdefaults.go @@ -0,0 +1,35 @@ +package window + +// The two values a work screen arrives with, in one place because two screens +// now have to arrive with the same ones. +// +// The single batch screen has opened with a name and a size typed in since +// there was a screen: press Generate and something happens. The batch screen +// opened with both of them empty and a red star on each, so the same button +// under the same mark did two different things depending on which tab somebody +// was on - Generate works on the first screen and refuses on the third. A +// tester learns the first screen and is turned down by the third. +// +// Only the two settings a run REFUSES when they are empty are filled in, and +// that line is where the fix stops. Everything else on the batch screen keeps +// its placeholder and stays unstated, which is O109 and untouchable rule 5: a +// value typed in is a value stated, and a form whose every box arrives +// carrying one can never say "I did not state this". How many files, the kind +// of case, the expectation and the reason are all settings a recipe is +// allowed to leave out, and they still are. +// +// A batch name of "files" is also why only the FIRST batch is filled. Two +// batches with one name is a refusal the recipe reader already words, so +// handing somebody a second batch carrying the first one's name would be a +// form that has to be repaired before it can be used - the same reason +// duplicateBatch leaves the copy's name empty. +const ( + // startingBatchName anchors the seeds of the batch and names the files, so + // it has no default anywhere in the engine and a run without one is + // refused. This is the window offering a first one, not a default. + startingBatchName = "files" + // startingSize is a size every format in the registry can produce - the + // largest minimum is a few thousand bytes - so a screen that opens on any + // format opens on a form that would run. + startingSize = "10mb" +) diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index b016799d..0d61fe2a 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -84,6 +84,21 @@ type Host interface { // address belongs to the one place that knows what system this is. OpenFolder(path string) + // OpenFile asks the desktop to open one file with whatever it opens that + // kind with. The manifest of a finished run is the only thing the window + // offers this way. + // + // Separate from OpenFolder although both end at the same call, because + // they are not the same thing to the person pressing: a folder opens in a + // file manager every desktop has, and a file opens in whatever is + // registered for its kind - which on a machine where nothing is + // registered for .json is a dialog asking what to use. Measured on this + // machine on 2026-09-23: .json has no association of its own in the + // registry and the manifest still opened, in the editor registered for + // it. Two names rather than one keep that difference where somebody + // reading this can see it. + OpenFile(path string) + // OpenLink hands an address to whatever the desktop uses for the web. // // The program does not fetch it. It asks the system to, on a press somebody @@ -188,7 +203,7 @@ type Generate struct { func NewGenerate(host Host, links ...fyne.CanvasObject) *Generate { g := &Generate{runner: newRunner(host.Later), host: host, tips: parts.NewTips(), settingsFolded: true} g.runner.settle = g.settle - g.runner.openFolder = host.OpenFolder + g.runner.offer.through(host) // This screen is one target and draws its boxes under the bare key, so a // refusal that arrives carrying a position belongs to the box of that name. g.runner.readdress = withoutTheTarget @@ -309,9 +324,9 @@ func (g *Generate) buildFields() { g.onDamageChosen) g.damage.pick.SetSelected(text.DamageNone()) - g.size = entry("10mb", "") + g.size = entry(startingSize, "") g.count = entry("1", "") - g.id = entry("files", "") + g.id = entry(startingBatchName, "") g.name = entry("", text.PlaceholderNameTemplate) g.outDir = entry(startingDirectory(), "") g.seed = entry("0", "") diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go index b60f812a..fdb6198c 100644 --- a/internal/gui/window/preset.go +++ b/internal/gui/window/preset.go @@ -53,7 +53,7 @@ type Preset struct { // NewPreset builds the screen. links are the buttons to the other screens. func NewPreset(host Host, links ...fyne.CanvasObject) *Preset { p := &Preset{runner: newRunner(host.Later), host: host, tips: parts.NewTips()} - p.runner.openFolder = host.OpenFolder + p.runner.offer.through(host) p.runner.settle = p.settle // No readdress here, and that is the boundary of this screen rather than an // omission. The other two screens draw boxes for the settings of a target, diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go index f26409d2..dbe03c14 100644 --- a/internal/gui/window/recipe.go +++ b/internal/gui/window/recipe.go @@ -163,7 +163,7 @@ type content struct { func NewRecipe(host Host, links ...fyne.CanvasObject) *Recipe { r := &Recipe{runner: newRunner(host.Later), host: host, tips: parts.NewTips()} r.runner.settle = r.settle - r.runner.openFolder = host.OpenFolder + r.runner.offer.through(host) // A refusal about a size belongs on the box the switch is showing. r.runner.readdress = r.readdressSizeWay // A box inside a folded batch cannot be brought into view by scrolling, so @@ -213,6 +213,12 @@ func NewRecipe(host Host, links ...fyne.CanvasObject) *Recipe { // to exist, and choosing it is what fills them in. The same ordering the // single batch screen needs, and for the same reason. r.batches[0].formatPick.SetSelected(format.IDs()[0]) + // And the two settings a run refuses without, so that Generate means the + // same thing here as it does on the first screen. Only the batch the + // screen opens with - see formdefaults.go for why not the ones added + // after it, and for why it is these two and nothing else. + r.batches[0].id.SetText(startingBatchName) + r.batches[0].size.SetText(startingSize) r.rebuild() // Said last, once every box it reads exists. diff --git a/internal/gui/window/recipesize.go b/internal/gui/window/recipesize.go index 997369fd..80cbc3dd 100644 --- a/internal/gui/window/recipesize.go +++ b/internal/gui/window/recipesize.go @@ -25,7 +25,9 @@ import ( // // The switch is drawn above them rather than beside the name of one, because it // belongs to all three and a control that belongs to three fields cannot sit -// inside one of their headings. +// inside one of their headings. It carries a name of its own since 2026-09-23, +// for the reason parts.Fields.Named gives: it was the one control on this +// screen whose subject had to be guessed from what stood next to it. func (r *Recipe) sizeWayFor(b *batch, at func(string) string, add func(setting, label, hint string, detail parts.Detail, control fyne.CanvasObject) fyne.CanvasObject, ) fyne.CanvasObject { @@ -65,7 +67,8 @@ func (r *Recipe) sizeWayFor(b *batch, at func(string) string, for _, key := range sizeWayKeys() { boxes = append(boxes, b.sizeBoxes[key]) } - return parts.FieldColumn(append([]fyne.CanvasObject{r.fields.Unlabelled(b.sizeWay)}, boxes...)...) + named := r.fields.Named(text.FieldSizeWay(), r.tips.Say(text.DetailSizeWay()), b.sizeWay) + return parts.FieldColumn(append([]fyne.CanvasObject{named}, boxes...)...) } // newSizeWaySwitch is the control itself, built with the batch rather than with diff --git a/internal/gui/window/run.go b/internal/gui/window/run.go index 1cfc792f..d94c9cc8 100644 --- a/internal/gui/window/run.go +++ b/internal/gui/window/run.go @@ -3,6 +3,7 @@ package window import ( "context" "errors" + "path/filepath" "sort" "strconv" "strings" @@ -79,21 +80,13 @@ type runner struct { previewBtn *parts.Button generateBtn *parts.Button - // openBtn shows the directory a finished run wrote into. - // - // It appears when there is something to open and goes away the moment the - // next run starts, which is the owner's decision of 2026-08-25. A button - // leading to a directory that does not exist yet is a button that does - // nothing, and this window has been getting rid of those. + // offer is what the finished run left and the way to each of it: the + // folder, the record, and the rule about when each is on the bar. // // In the row of actions rather than beside the output box, so the bar keeps // its height and the form does not move - the property - // TestTheFormDoesNotMoveWhenARunStarts holds. - openBtn *parts.Button - // wroteInto is the directory of the run that just finished, kept because - // the box on the screen can be edited afterwards and the button has to open - // where the files ACTUALLY went. - wroteInto string + // TestTheFormDoesNotMoveWhenARunStarts holds. See offers. + offer *offers // busy is whether work owns the screen and the face it wears for it - // the frozen form, Cancel, the bar. See runbusy.go. @@ -153,11 +146,6 @@ type runner struct { // to read the answer. settled chan struct{} - // openFolder is how this screen asks the desktop to show a directory. Held - // as a function rather than reaching for the host, because the runner is - // shared by three screens and none of them owns the window. - openFolder func(string) - // scroll is the part of this screen that moves, so a refusal can bring the // box it is about into view. Set by the screen, because only the screen // that built it knows which scroll holds its form. @@ -368,12 +356,7 @@ func newRunner(wait later) *runner { r.busy = &busy{fields: r.fields, preview: r.previewBtn, generate: r.generateBtn, cancel: cancel, bar: bar, later: wait} - r.openBtn = parts.NewButton(parts.Secondary, text.ButtonOpenFolder(), func() { - if r.wroteInto != "" && r.openFolder != nil { - r.openFolder(r.wroteInto) - } - }) - r.openBtn.Hide() + r.offer = newOffers(r.busy.relay) return r } @@ -579,8 +562,8 @@ func (r *runner) onGenerate() { // screen can be edited while a run is going and the button has to open // where the files ACTUALLY went. Hidden first, so a run that produces // nothing does not leave the offer from the run before it standing. - r.hideTheFolder() - r.wroteInto = opt.OutDir + r.offer.forget() + r.offer.wroteInto = opt.OutDir r.startRun(targets, opt) } @@ -655,7 +638,7 @@ func (r *runner) startRun(targets []engine.Target, opt engine.Options) { // The manifest is written here rather than after crossing back, because // it is disk work and the interface thread is the one thing that must // not wait on a disk. - saveErr := saveManifest(res, opt) + savedTo, saveErr := saveManifest(res, opt) // The room left on the disk is the room left AFTER the files, which // is not the number a preview measured before them. room := roomOn(opt.OutDir) @@ -663,7 +646,7 @@ func (r *runner) startRun(targets []engine.Target, opt engine.Options) { // stop, waiting on the channel closed below, and a worker waiting for // that thread to run something would be both of them waiting. r.holdBeforeFinishing() - fyne.Do(func() { r.runFinished(res, runErr, saveErr, room) }) + fyne.Do(func() { r.runFinished(res, runErr, saveErr, room, savedTo) }) close(done) }() } @@ -672,10 +655,14 @@ func (r *runner) startRun(targets []engine.Target, opt engine.Options) { // // Note what it does not do: clear stop. That is deliberate and the reason is at // the declaration of the field. -func (r *runner) runFinished(res *engine.Result, runErr, saveErr error, room diskRoom) { +// +// savedTo is where the record went, or nothing at all when no record was +// written - which is a refused run, a preview, and a run whose manifest could +// not be saved. The screen says nothing about a manifest in any of those. +func (r *runner) runFinished(res *engine.Result, runErr, saveErr error, room diskRoom, savedTo string) { r.busy.set(false, busyFace{}) - if room.known && r.wroteInto != "" { - r.line.measured(r.wroteInto, room.free) + if room.known && r.offer.wroteInto != "" { + r.line.measured(r.offer.wroteInto, room.free) } switch { @@ -695,10 +682,27 @@ func (r *runner) runFinished(res *engine.Result, runErr, saveErr error, room dis // learned on 2026-09-06: it is the one line standing between somebody and a // directory nothing in this toolset can ever clean up, and it was being // buried under notes about a label that did not fit. - said := append([]string{outcomeText(res, runErr)}, manifestReachNote(res)...) + // + // The record is named on the same line as the outcome rather than on one of + // its own, because it is part of the same fact: what this run produced. The + // command line has printed it since there was a manifest, and the window + // said only how many files - so the one thing this tool makes that others + // do not was, from a window, something you found in the folder afterwards. + outcome := text.SaidWithManifest(outcomeText(res, runErr), manifestNameOf(savedTo)) + said := append([]string{outcome}, manifestReachNote(res)...) r.say(append(said, notesOf(res)...)...) r.toneOfOutcome(res, runErr) - r.offerTheFolder(res) + r.offer.theFolder(res) + r.offer.theManifest(savedTo) +} + +// manifestNameOf is the file's own name, for a sentence that stands beside a +// button opening the folder it is in. Nothing where no record was written. +func manifestNameOf(path string) string { + if path == "" { + return "" + } + return filepath.Base(path) } // keepScroll remembers the scrolling area on the way past, so that a refusal diff --git a/internal/gui/window/runactions.go b/internal/gui/window/runactions.go index 95dd2aae..8c744119 100644 --- a/internal/gui/window/runactions.go +++ b/internal/gui/window/runactions.go @@ -5,7 +5,6 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/layout" - "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" ) @@ -79,30 +78,7 @@ func (r *runner) actions() fyne.CanvasObject { // Kept by the busy state as well, because the toolkit does not lay the // row out again when a button in it is hidden - see busy.relay. r.busy.row = container.NewHBox( - layout.NewSpacer(), r.previewBtn, r.generateBtn, r.busy.cancel, r.openBtn, layout.NewSpacer()) + layout.NewSpacer(), r.previewBtn, r.generateBtn, r.busy.cancel, + r.offer.folderBtn, r.offer.manifestBtn, layout.NewSpacer()) return r.busy.row } - -// offerTheFolder shows the way to the files, once there are some. -// -// Asked of the RESULT rather than of the box on the screen: a run that wrote -// nothing has nothing to show, and a run that was stopped after three files has -// three files somebody may well want to look at. The manifest is what knows. -func (r *runner) offerTheFolder(res *engine.Result) { - if res == nil || res.Manifest == nil || len(res.Manifest.Files) == res.Failures { - return - } - if r.wroteInto == "" { - return - } - r.openBtn.Show() - r.busy.relay() -} - -// hideTheFolder takes the offer away when the next run starts, so the button -// never points at the results of the run before this one. -func (r *runner) hideTheFolder() { - r.wroteInto = "" - r.openBtn.Hide() - r.busy.relay() -} diff --git a/internal/gui/window/runoffers.go b/internal/gui/window/runoffers.go new file mode 100644 index 00000000..05356b7f --- /dev/null +++ b/internal/gui/window/runoffers.go @@ -0,0 +1,118 @@ +package window + +import ( + "github.com/donislawdev/TestingFilesGenerator/internal/engine" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// What a finished run leaves behind, and the way to each of it. +// +// Its own type since 2026-09-23, when the manifest got a button beside the +// folder's and the runner went one method and one field past its ceilings. +// Those ceilings are ratchets, so the answer is to move state out and never +// to raise the number - and the seam was already there to be found: two +// buttons, two paths, one rule about when each is offered, and nothing in any +// of it about running anything. +// +// Both buttons are built hidden and shown when there is something behind +// them. A button leading to a file or a folder that need not exist yet is a +// button that does nothing, and this window has spent months getting rid of +// those. Both are taken away the moment the next run starts, so neither ever +// points at the results of the run before this one - which is worse than not +// being there, because somebody would open it, see the old files and believe +// them. +type offers struct { + folderBtn *parts.Button + manifestBtn *parts.Button + + // openFolder and openFile are how this asks the desktop, held as + // functions rather than reaching for the host: the runner is shared by + // three screens and none of them owns the window. See Host.OpenFile for + // why showing a directory and opening a file are two calls. + openFolder func(string) + openFile func(string) + + // wroteInto and wroteManifest are where the run that just finished put + // its files and its record. Kept rather than read back off the form, + // because the boxes on the screen can be edited afterwards and a button + // has to lead where the run ACTUALLY went. + wroteInto string + wroteManifest string + + // relay redraws the bar when a button appears or goes, because the + // toolkit does not lay the row out again by itself when a child of it is + // shown or hidden. See busy.relay. + relay func() +} + +// newOffers builds both buttons, hidden, and wires each to what it leads to. +func newOffers(relay func()) *offers { + o := &offers{relay: relay} + o.folderBtn = parts.NewButton(parts.Secondary, text.ButtonOpenFolder(), func() { + if o.wroteInto != "" && o.openFolder != nil { + o.openFolder(o.wroteInto) + } + }) + o.folderBtn.Hide() + o.manifestBtn = parts.NewButton(parts.Secondary, text.ButtonOpenManifest(), func() { + if o.wroteManifest != "" && o.openFile != nil { + o.openFile(o.wroteManifest) + } + }) + o.manifestBtn.Hide() + return o +} + +// through says which desktop these buttons reach. Called by every screen as +// it is built, because the host is what knows how to open anything. +func (o *offers) through(host Host) { + o.openFolder = host.OpenFolder + o.openFile = host.OpenFile +} + +// theFolder shows the way to the files, once there are some. +// +// Asked of the RESULT rather than of the box on the screen: a run that wrote +// nothing has nothing to show, and a run that was stopped after three files +// has three files somebody may well want to look at. The manifest is what +// knows. +func (o *offers) theFolder(res *engine.Result) { + if res == nil || res.Manifest == nil || len(res.Manifest.Files) == res.Failures { + return + } + if o.wroteInto == "" { + return + } + o.folderBtn.Show() + o.relay() +} + +// theManifest shows the way to the record, once there is one. +// +// Asked about the SAVING rather than about the run, which is the difference +// from the folder above: a run that wrote files and could not save its +// manifest has a folder worth opening and no record to open. The screen +// refuses about that in its own sentence, and a button pointing at the file +// that refusal is about would be the screen disagreeing with itself. +// +// The path is the one saving used rather than one worked out again here. The +// manifest's name is a field on the batch screen, so a second way of arriving +// at it is a second chance to name a different file. +func (o *offers) theManifest(path string) { + if path == "" { + return + } + o.wroteManifest = path + o.manifestBtn.Show() + o.relay() +} + +// forget takes both offers away when the next run starts. +func (o *offers) forget() { + o.wroteInto = "" + o.folderBtn.Hide() + o.wroteManifest = "" + o.manifestBtn.Hide() + o.relay() +} diff --git a/internal/gui/window/runreport.go b/internal/gui/window/runreport.go index 6b2330cb..fa50ffc9 100644 --- a/internal/gui/window/runreport.go +++ b/internal/gui/window/runreport.go @@ -66,14 +66,22 @@ func progressText(p engine.Progress, elapsed time.Duration) string { return line + text.TimeLeft(core.Roughly(left)) } -// saveManifest writes the record of what the run did. +// saveManifest writes the record of what the run did, and hands back where it +// put it. // // A run refused before it wrote anything gets none. Writing one would replace // the record of whatever was already in that directory, and that record is the // only thing cleanup can work from. -func saveManifest(res *engine.Result, opt engine.Options) error { +// +// The path comes back rather than being worked out again by whoever wants to +// name it, and that is the whole reason this returns two things. The manifest's +// name is a field on the batch screen, so "the manifest of this run" is not a +// constant - and a screen that says one name while the file has another is +// worse than a screen that says nothing. An empty path means no record was +// written, which is what the button and the sentence both ask about. +func saveManifest(res *engine.Result, opt engine.Options) (string, error) { if opt.DryRun || res == nil || !res.Started { - return nil + return "", nil } // Asked of the engine rather than joined here. This used to be // filepath.Join(opt.OutDir, opt.ManifestName), which is the same answer @@ -83,7 +91,7 @@ func saveManifest(res *engine.Result, opt engine.Options) error { // reached it. path := engine.ManifestPath(opt) if err := res.Manifest.Save(path); err != nil { - return fmt.Errorf("%s: %w", text.ManifestNotSaved(path), err) + return "", fmt.Errorf("%s: %w", text.ManifestNotSaved(path), err) } - return nil + return path, nil }