diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5844e36..d5b0db1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -191,6 +191,66 @@ because it turns other people's test suites red.
### Changed
+- **The window opens at 1120 by 760 and forgets the size it remembered once.**
+ It opened at 1000 by 1000, which was measured for the older single column
+ layout where the form carried the output directory and needed 958 px of
+ height. Moving that directory into the column on the right took the form down
+ to 337 px, so the window had been opening with about 563 px of empty space
+ below the form.
+
+ A size you had dragged the window to is remembered, and a remembered size wins
+ over any default - so this release ignores the one it had stored for you, once.
+ The next size you leave the window at is kept as before. Nothing is removed
+ from the settings file.
+
+- **The form lines up with the left edge of the window instead of sitting in the
+ middle of it.** With the report column pinned to the right, centring the form
+ in what was left put a margin on the left with nothing to answer it - at
+ 1520 px wide the form started 152 px in from the tabs above it.
+
+- **The buttons that start a run reach the right edge of the window.** They were
+ pushed to the right of the form's own column, which left them 156 px short of
+ the edge in a 1120 px window.
+
+- **The panel on the right is as tall as what it has to say.** It used to take
+ whatever height was left over and draw it as a panel, so at 1120 by 760 it was
+ a 414 px box around one sentence.
+
+- **Byte counts are grouped in threes, in the window and on the command line.**
+ A total used to print as `2516582400 B`. It now prints as `2 516 582 400 B`.
+
+ Grouped with a space rather than a comma because a comma means a thousands
+ mark in some countries and a decimal point in others, and this tool is read in
+ both. Machine output is untouched: `--json` and the manifest carry numbers
+ rather than sentences, so nothing that parses them sees any of this.
+
+ If you have a script reading a byte count out of the human output, it needs to
+ take the spaces out. Two of ours did, and both said the tool had broken rather
+ than that they had.
+
+- **The window has been rebuilt.** Same four screens, same settings, same
+ results - a different shape.
+
+ The form is on the left and everything about where the files go is on the
+ right, in a column that does not scroll away. It carries what the run has to
+ say, so the answer to a press stays next to the numbers it is about instead of
+ being pinned to the bottom of the window. On the batch screen that column also
+ shows the manifest name and the seed, which used to be below every batch.
+
+ Preview and Generate moved from the middle of the bar to its right hand end,
+ and each now shows the key that presses it - `Ctrl+P` and `Ctrl+Enter`, with
+ `Esc` on Cancel. Those keys have worked since 0.3.0-rc1 and nothing said so.
+
+ The lettering is Inter. Field names are smaller and quieter than the values in
+ them, section titles are smaller than they were, and every measured number -
+ a byte count, a total, a speed - is set in a monospaced face so digits line up.
+ The star marking a setting a run cannot do without is no longer red, so red on
+ a screen now means something was actually refused. The window opens 1120 by
+ 760 and cannot be made narrower than its two columns need.
+
+ `About` is two columns as well, with the licence on the left and everything
+ the binary carries in a list on the right that scrolls on its own.
+
- **Notes are reported once per thing they say, not once per file.** A run of
25 000 one-byte text files used to print 25 001 `note:` lines, every one of
them the same sentence about the label not fitting. It now prints one, with
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 1f6af9e..b34ebeb 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -604,6 +604,7 @@ the font files themselves.
|---|---|---|---|
| Noto Sans, four styles | `fyne.io/fyne/v2/theme` | OFL-1.1 | Copyright 2015 Google Inc. All Rights Reserved. |
| Inter, symbols only | `fyne.io/fyne/v2/theme` | OFL-1.1 | (c) 2020 The Inter Project Authors |
+| Inter, text faces, two weights | this repository, `internal/gui/typeface` | OFL-1.1 | Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) |
| DejaVu Sans Mono for Powerline | `fyne.io/fyne/v2/theme` | Bitstream-Vera | (c) 2003 Bitstream, Inc. DejaVu changes are in the public domain |
| EmojiOne Color | `fyne.io/fyne/v2/theme` | MIT, and read the note below | Copyright 2016 Adobe Systems Incorporated |
| Fyne icon set, 96 drawings and one image | `fyne.io/fyne/v2/theme` | BSD-3-Clause | (C) 2018 Fyne.io developers (see AUTHORS) |
@@ -633,7 +634,8 @@ and only those were replaced by their plain equivalents.
### SIL Open Font License 1.1
-Applies to Noto Sans and to Inter.
+Applies to Noto Sans, to the Inter symbols the toolkit carries, and to the two
+Inter text faces this repository embeds itself.
```
------------------------------
diff --git a/internal/cli/formats.go b/internal/cli/formats.go
index 6b9d830..466cb9c 100644
--- a/internal/cli/formats.go
+++ b/internal/cli/formats.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
)
@@ -109,8 +110,8 @@ func entryFor(d format.Descriptor) formatEntry {
// list and ignore the argument, ending with 0 - so there was no way to ask what
// a format accepts, and the silence looked like an answer.
func describeOne(d format.Descriptor, out io.Writer) {
- fmt.Fprintf(out, "%s - %s fidelity, %s deterministic, minimum %d B\n",
- d.ID, d.Fidelity, d.Determinism, smallestAccepted(d))
+ fmt.Fprintf(out, "%s - %s fidelity, %s deterministic, minimum %s\n",
+ d.ID, d.Fidelity, d.Determinism, core.ExactBytes(smallestAccepted(d)))
fmt.Fprintf(out, " extension %s\n", d.Extension)
fmt.Fprintf(out, " padding %s\n", d.Padding.Name)
fmt.Fprintf(out, " label %s\n", d.Label)
diff --git a/internal/cli/generate.go b/internal/cli/generate.go
index 4e516b4..45abb57 100644
--- a/internal/cli/generate.go
+++ b/internal/cli/generate.go
@@ -351,10 +351,16 @@ func sizesFromFlags(g *generateOpts, errOut io.Writer) (sizes []int64, low, high
if errors.Is(err, core.ErrBoundaryTooSmall) {
// A number somebody typed, so this is USAGE rather than a problem
// with a document. The end above it keeps the code it had.
+ // Ungrouped, unlike every other count this program prints, and the
+ // exception is deliberate: this echoes back the number somebody
+ // typed after --boundary. A message that quotes your input and
+ // respells it on the way is a message you have to translate before
+ // you can compare it with what you wrote.
fmt.Fprintf(errOut, "tfg: --boundary %d B is too small - %s\n", limit, err)
return nil, 0, 0, 0, ExitUsage
}
if err != nil {
+ // Ungrouped for the same reason as the line above it.
fmt.Fprintf(errOut, "tfg: --boundary %d B is too large - %s\n", limit, err)
return nil, 0, 0, 0, ExitRecipe
}
@@ -387,9 +393,9 @@ func produce(ctx context.Context, targets []engine.Target, opt engine.Options, g
// Echo the exact byte count. The exact number is the point of this tool,
// and it is what any other tool will show when the user goes to check the
// file.
- fmt.Fprintf(errOut, "%s in %s, %d B total\n",
+ fmt.Fprintf(errOut, "%s in %s, %s total\n",
core.Count(len(planned), "file", "files"), core.Count(len(targets), "target", "targets"),
- engine.TotalBytes(planned))
+ core.ExactBytes(engine.TotalBytes(planned)))
echoBoundaries(targets, planned, errOut)
echoManifestReach(planned, errOut)
@@ -530,10 +536,10 @@ func echoBoundaries(targets []engine.Target, planned []engine.PlannedFile, errOu
if t.BoundaryLimit <= 0 {
continue
}
- fmt.Fprintf(errOut, "boundary %q around %d B:\n", t.ID, t.BoundaryLimit)
+ fmt.Fprintf(errOut, "boundary %q around %s:\n", t.ID, core.ExactBytes(t.BoundaryLimit))
for _, f := range planned {
if f.Target == t {
- fmt.Fprintf(errOut, " %-26s %d B\n", f.Name, f.Plan.Bytes)
+ fmt.Fprintf(errOut, " %-26s %s\n", f.Name, core.ExactBytes(f.Plan.Bytes))
}
}
diff --git a/internal/cli/preset.go b/internal/cli/preset.go
index c98b8ab..09de047 100644
--- a/internal/cli/preset.go
+++ b/internal/cli/preset.go
@@ -537,9 +537,9 @@ func describePreset(e *preset.Expansion, b budget, out io.Writer) {
fmt.Fprintf(out, " --%-12s the global flag, this preset gives it a default\n", name)
}
- fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %d B total, format %s\n",
+ fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, format %s\n",
core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"),
- b.Bytes, strings.Join(b.Formats, ", "))
+ core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", "))
for _, note := range e.Notes() {
fmt.Fprintf(out, "\nnote: %s\n", note)
}
diff --git a/internal/cli/recipecmd.go b/internal/cli/recipecmd.go
index aa8ef7d..d3a11ff 100644
--- a/internal/cli/recipecmd.go
+++ b/internal/cli/recipecmd.go
@@ -94,8 +94,9 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int {
}, ExitOK)
}
- fmt.Fprintf(out, "%s is valid: %s, %s, %d B total\n%s\n",
- path, core.Count(len(rec.Targets), "target", "targets"), core.Count(len(planned), "file", "files"), engine.TotalBytes(planned), hash)
+ fmt.Fprintf(out, "%s is valid: %s, %s, %s total\n%s\n",
+ path, core.Count(len(rec.Targets), "target", "targets"), core.Count(len(planned), "file", "files"),
+ core.ExactBytes(engine.TotalBytes(planned)), hash)
return ExitOK
}
diff --git a/internal/core/humanise.go b/internal/core/humanise.go
index 37ac11c..3bfe001 100644
--- a/internal/core/humanise.go
+++ b/internal/core/humanise.go
@@ -3,6 +3,8 @@ package core
import (
"fmt"
"math"
+ "strconv"
+ "strings"
"time"
)
@@ -20,7 +22,10 @@ import (
func HumanBytes(n int64) string {
const unit = 1024
if n < unit {
- return fmt.Sprintf("%d B", n)
+ // Through ExactBytes rather than its own %d, so the two never spell one
+ // number two ways. Below 1024 there is nothing to group, which is
+ // exactly why this is easy to get wrong and leave wrong.
+ return ExactBytes(n)
}
div, exp := int64(unit), 0
for n/div >= unit && exp < 3 {
@@ -30,6 +35,66 @@ func HumanBytes(n int64) string {
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp])
}
+// ExactBytes writes a count out in full, grouped in threes, with its unit.
+//
+// The exact number is the point of this tool and it can never be replaced by a
+// rounded one - but eleven digits in a row is a number nobody reads, and both
+// surfaces printed it that way. "2516582400 B" was measured on the window's run
+// panel and on four lines of the command line, and the owner's report of it was
+// that the bytes are welcome and unreadable, which are both true at once.
+//
+// Grouped with a space rather than a comma, and that is the one choice here
+// worth writing down. A comma is the thousands mark in English and the decimal
+// mark for most of Europe, so "2,516" is either two and a half thousand or two
+// and a half depending on who is reading - and the people who read this run it
+// in every country. A space means the same thing everywhere.
+//
+// Machine output is untouched on purpose. Nothing in a manifest or under --json
+// goes through here, because a number there is a number and not a sentence.
+func ExactBytes(n int64) string {
+ return Grouped(n) + " B"
+}
+
+// Grouped is a plain number, spaced the same way a byte count is.
+//
+// It is the same spelling without the unit, for the counts that are not bytes -
+// how many files a run comes to, most of all, which reaches five digits on the
+// sets this tool is built for. Split out of ExactBytes on 2026-09-08 rather
+// than written again beside it, because two functions putting spaces into
+// numbers is two functions that can come to disagree about where.
+func Grouped(n int64) string {
+ return groupedInThrees(strconv.FormatInt(n, 10))
+}
+
+// groupedInThrees puts a space every three digits, counting from the right.
+//
+// Written out rather than reached for in a library because the one in the
+// standard library is about money: golang.org/x/text/message formats to a
+// LOCALE, and a locale is exactly what this must not have - the window and the
+// command line have to say the same thing on a Polish desktop and an American
+// one, and docs/UX.md has the surfaces agreeing as a rule rather than a hope.
+func groupedInThrees(digits string) string {
+ sign := ""
+ if strings.HasPrefix(digits, "-") {
+ sign, digits = "-", digits[1:]
+ }
+ if len(digits) <= 3 {
+ return sign + digits
+ }
+ lead := len(digits) % 3
+ if lead == 0 {
+ lead = 3
+ }
+ var out strings.Builder
+ out.Grow(len(digits) + (len(digits)-1)/3)
+ out.WriteString(digits[:lead])
+ for i := lead; i < len(digits); i += 3 {
+ out.WriteByte(' ')
+ out.WriteString(digits[i : i+3])
+ }
+ return sign + out.String()
+}
+
// Percent divides before multiplying where it has to, so a very large run does
// not wrap on the way to a number between nought and a hundred.
//
diff --git a/internal/guard/actionbar_test.go b/internal/guard/actionbar_test.go
index 7022b36..248deab 100644
--- a/internal/guard/actionbar_test.go
+++ b/internal/guard/actionbar_test.go
@@ -9,25 +9,26 @@ import (
"github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
)
-// The buttons that run something sit in the middle of the form.
+// The buttons that run something sit at the right hand end of the bar.
//
-// The owner's decision of 2026-08-19, and it REVERSES the one of 2026-08-18
-// that this guard used to hold. Both came from looking at the built window, and
-// the earlier reasoning is kept rather than deleted because it was a choice and
-// not a mistake - a reader who sees only the new rule cannot tell whether the
-// old one was ever tried.
+// The owner's decision of 2026-09-08, and it is the THIRD answer this one
+// question has had. All three came from looking at the built window and none
+// was a mistake, so all three are kept: at the left because that is where a
+// horizontal box puts things when nothing pushes them and nobody had chosen it
+// (measured 2026-08-18, the bar 820 px wide with the buttons at x=0 and x=73),
+// then at the right as the end of the reading path, then in the middle, and now
+// at the right again.
//
-// What it said before: they stood at the left, the owner asked why, and the
-// honest answer was that nobody had chosen it - measured from the stored tree
-// that morning, the bar is 820 px wide and the two buttons were at x=0 and
-// x=73, which is where a horizontal box puts things when nothing pushes them.
-// It was then set to the right edge, as the end of the reading path.
+// What settled it this time was the two column body arriving. The middle of a
+// bar has no relationship to the column somebody has just finished typing in,
+// and the right hand end is where every desktop this ships to puts the button
+// it wants pressed last.
//
// Written as a guard rather than left to the stored picture because the two
// answer different questions. The picture says the screen has not changed. This
// says which arrangement was chosen, so somebody reading the failure is told
// what the rule is rather than being handed two images to compare.
-func TestTheButtonsThatRunSomethingSitInTheMiddle(t *testing.T) {
+func TestTheButtonsThatRunSomethingSitAtTheRightHandEnd(t *testing.T) {
_, content := screenOnACanvas(t)
run := buttonNamed(content, text.ButtonGenerate())
@@ -48,21 +49,25 @@ func TestTheButtonsThatRunSomethingSitInTheMiddle(t *testing.T) {
t.Fatalf("there is no %q button, so this guard read the wrong tree", text.ButtonPreview())
}
- // The group is measured rather than one button, because being centred is a
- // property of the pair: either one alone can sit near the middle while the
- // two of them are plainly off to one side.
+ // The group is measured rather than one button, because being at an end is
+ // a property of the pair: either one alone can sit near the edge while the
+ // two of them are plainly somewhere else.
before := first.Position().X
after := row.Size().Width - (run.Position().X + run.Size().Width)
- if before <= slack || after <= slack {
- t.Errorf("the run buttons are hard against an edge: %.1f px before them and %.1f px "+
- "after, in a bar %.1f px wide. They were moved to the middle on 2026-08-19.",
- before, after, row.Size().Width)
+ if before <= slack {
+ t.Errorf("the run buttons are hard against the LEFT edge: %.1f px before them, in a "+
+ "bar %.1f px wide. Nothing should be there - that end belongs to the rail.",
+ before, row.Size().Width)
}
- if diff := before - after; diff > slack || diff < -slack {
- t.Errorf("the run buttons are not centred: %.1f px before them and %.1f px after, in a "+
- "bar %.1f px wide. What to do: keep a spacer at BOTH ends of the group in "+
- "runner.actions, because one spacer can only push the group to an end.",
+ // Far more room before them than after. "After" is not zero because the key
+ // name that presses Generate stands past it, which is the one thing allowed
+ // to be further right than the last button.
+ if before <= after {
+ t.Errorf("the run buttons are not at the right hand end: %.1f px before them and %.1f px "+
+ "after, in a bar %.1f px wide. What to do: keep ONE greedy spacer in front of the "+
+ "group in runner.actions - a spacer pushes, so one in front puts the group at the far "+
+ "end and a second one behind it would put it back in the middle.",
before, after, row.Size().Width)
}
}
diff --git a/internal/guard/actionbarheight_test.go b/internal/guard/actionbarheight_test.go
index c4e5903..479f217 100644
--- a/internal/guard/actionbarheight_test.go
+++ b/internal/guard/actionbarheight_test.go
@@ -293,52 +293,26 @@ func scrollIn(o fyne.CanvasObject) *container.Scroll {
// Found as a pair rather than separately, because the status line is an
// ordinary label and every screen has several - the one that belongs to a run
// is the one sharing a container with the bar.
-func runMessages(o fyne.CanvasObject) (*parts.Progress, *widget.Label) {
+func runMessages(o fyne.CanvasObject) (*parts.Progress, *parts.RunStatus) {
var bar *parts.Progress
- var status *widget.Label
+ var status *parts.RunStatus
+ // Both by type, since 2026-09-08. This used to look for a box holding a
+ // plain label beside a progress track, and the shape was enough while that
+ // pair stood alone in the action bar. When the run's messages moved into a
+ // panel of their own the panel's TITLE became a plain label above the same
+ // track, so the first box matching the shape was the panel and four guards
+ // started asking the title what colour a finished run is.
+ //
+ // The lesson is written down twice in this project already: ask for a thing
+ // by name, not by what it stands next to. parts.RunStatus exists for that.
walk(o, func(obj fyne.CanvasObject) {
- box, ok := obj.(*fyne.Container)
- if !ok || bar != nil {
- return
- }
- var foundBar *parts.Progress
- var foundLabel *widget.Label
- for _, child := range box.Objects {
- // The label has to be a child of this box, because that is what
- // says this is the row a run talks in. The track is looked for
- // underneath the child instead: it is wrapped in parts.Slim since
- // 2026-08-19, and a guard that insisted on a bare widget here read
- // the wrapper and declared the screen had no progress bar.
- if it, ok := child.(*widget.Label); ok {
- foundLabel = it
- continue
- }
- if it := progressUnder(child); it != nil {
- foundBar = it
- }
- }
- if foundBar != nil && foundLabel != nil {
- bar, status = foundBar, foundLabel
+ switch it := obj.(type) {
+ case *parts.RunStatus:
+ status = it
+ case *parts.Progress:
+ bar = it
}
})
return bar, status
}
-
-// progressUnder finds the progress track at or beneath an object.
-//
-// The type changed on 2026-08-20 when the track became a control of ours, and
-// it matters that this helper changed with it: nothing in the package would
-// have failed to compile, and runMessages only pairs a bar with a label when
-// it finds both - so a helper left looking for the toolkit's widget would have
-// gone on finding nothing and every guard reading a run's messages would have
-// stopped reading them.
-func progressUnder(o fyne.CanvasObject) *parts.Progress {
- var found *parts.Progress
- walk(o, func(obj fyne.CanvasObject) {
- if it, ok := obj.(*parts.Progress); ok && found == nil {
- found = it
- }
- })
- return found
-}
diff --git a/internal/guard/boundaryunits_test.go b/internal/guard/boundaryunits_test.go
index ead2004..4c48aa6 100644
--- a/internal/guard/boundaryunits_test.go
+++ b/internal/guard/boundaryunits_test.go
@@ -77,7 +77,11 @@ func TestABoundaryRunSaysTheNumberItBuiltAround(t *testing.T) {
// The three file lines carry the byte count as well, so asking whether the
// number appears at all left this green when the announcement lost it -
// which the mutation runner said out loud on 2026-08-18.
- if !strings.Contains(errOut, `boundary "files" around 15728640 B`) {
+ // The count is asked of the same function that prints it, since the digits
+ // were grouped on 2026-09-08. This guard is about whether the announcement
+ // carries the NUMBER, not about how it is spelt - and a literal here would
+ // have to be respelt by hand every time that spelling changes.
+ if !strings.Contains(errOut, `boundary "files" around `+core.ExactBytes(15728640)) {
t.Errorf("the run built a set around 15728640 B and never says so.\n"+
"Reason: 15mb can be read two ways, and printing the byte count is what lets somebody\n"+
"whose system meant 15000000 see it before a byte is written.\nWhat it said:\n%s", out)
diff --git a/internal/guard/cataloguecoverage_test.go b/internal/guard/cataloguecoverage_test.go
index 1a2d188..96865b6 100644
--- a/internal/guard/cataloguecoverage_test.go
+++ b/internal/guard/cataloguecoverage_test.go
@@ -38,7 +38,6 @@ var notThroughTheCatalogue = map[string]string{
"HeadingAbout": "the same name again, at the top of the About screen. Same reason.",
"CatalogueNotLoaded": "the sentence saying the catalogue could not be read. It cannot come from the catalogue - that is what it is about - so this one is English wherever it appears, and it is written to a terminal rather than to the window.",
"NoWindowInThisBuild": "written to standard error by a window binary with no window in it, so it is terminal text and D9 keeps the terminal English forever.",
- "ExactBytes": "the byte symbol, which follows what the command line prints rather than the language of the window - the comment above it says so. Translating one and not the other would make two numbers on one screen disagree about their unit.",
"PlaceholderNameTemplate": "an example of a file name template, so it is a value somebody could type rather than a sentence. Translating it would produce an example that does not work.",
"SupportURL": "an address.",
}
diff --git a/internal/guard/contains_test.go b/internal/guard/contains_test.go
index 5131bb6..49d6148 100644
--- a/internal/guard/contains_test.go
+++ b/internal/guard/contains_test.go
@@ -4,11 +4,11 @@ import (
stdzip "archive/zip"
"os"
"path/filepath"
- "strconv"
"strings"
"testing"
"github.com/donislawdev/TestingFilesGenerator/internal/cli"
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
)
// onlyArchive is the single .zip the run produced. Failing when there is not
@@ -56,7 +56,13 @@ func archiveMembers(t *testing.T, path string) ([]string, []int64) {
return names, sizes
}
-func sizeText(n int64) string { return strconv.FormatInt(n, 10) }
+// sizeText is a byte count as the command line writes it.
+//
+// Through core rather than strconv since 2026-09-08, when the digits were
+// grouped. Written out here it would be a second opinion about how a number is
+// spelt, and this guard is about whether the dry run predicted the right NUMBER
+// - not about how many spaces are in it.
+func sizeText(n int64) string { return core.ExactBytes(n) }
// "an archive holds real files of other formats" is the feature docs/
// MVP-FORMATS.md 5.7 calls the key one, and the difference between this tool
diff --git a/internal/guard/destination_test.go b/internal/guard/destination_test.go
index 45dd2c7..66499c0 100644
--- a/internal/guard/destination_test.go
+++ b/internal/guard/destination_test.go
@@ -104,7 +104,8 @@ func TestWhereTheFilesGoIsSaidOutsideTheScrollingPart(t *testing.T) {
func labelSaying(o fyne.CanvasObject, want string) *widget.Label {
var found *widget.Label
walk(o, func(obj fyne.CanvasObject) {
- label, ok := obj.(*widget.Label)
+ label := asLabel(obj)
+ ok := label != nil
if ok && found == nil && label.Text == want {
found = label
}
diff --git a/internal/guard/embeddedassets_test.go b/internal/guard/embeddedassets_test.go
index 5885ce9..489978e 100644
--- a/internal/guard/embeddedassets_test.go
+++ b/internal/guard/embeddedassets_test.go
@@ -145,11 +145,38 @@ func TestEveryEmbeddedAssetIsNamedInTheNotices(t *testing.T) {
// embeddedFiles asks one platform's build which files each package embeds.
//
+// ourOwnWork is what this module embeds that nobody else wrote.
+//
+// Keyed by package and file rather than by file alone, so a name reused
+// somewhere else in the tree does not inherit an excuse it was never given.
+//
+// Every entry carries its reason, and a short list of reasons is the point: the
+// moment this needs an entry that reads "a font", or "a picture somebody sent
+// us", the answer is a registry entry and a notice rather than a line here.
+var ourOwnWork = map[string]bool{
+ // The application icon, drawn from shapes by tools/appicon.py. There is no
+ // artwork anybody else made in this repository - see docs/LICENSING.md.
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/icon|chickpea.png": true,
+ // The English wording of the window, written here and generated from the
+ // code that says it.
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/text|locale/en.json": true,
+}
+
// CGO_ENABLED is set rather than inherited, for the reason written beside the
// notices guard: the toolkit hides its real dependencies behind cgo build
// constraints, so a shell with cgo off reports a tree with almost nothing in
-// it. Our own module is skipped - its embedded files are our own work, and the
-// question here is what somebody else's code brings along.
+// it.
+//
+// Our own module is NO LONGER skipped, and that changed on 2026-09-08 because
+// its premise stopped being true. It used to be skipped on the grounds that
+// what this module embeds is its own work, which held for an icon and a
+// catalogue of sentences - and then a third party font was embedded here rather
+// than arriving inside somebody else's module, and the licence obliging its
+// notice to travel with it applied to bytes no guard was looking at.
+//
+// So the skip became a list. What is genuinely ours is named in ourOwnWork with
+// a reason, and everything else this module embeds has to be in the registry
+// like anything from anywhere else.
func embeddedFiles(t *testing.T, target, goos string) map[string][]string {
t.Helper()
cmd := exec.Command("go", "list", "-deps", "-f",
@@ -163,10 +190,19 @@ func embeddedFiles(t *testing.T, target, goos string) map[string][]string {
found := map[string][]string{}
for _, line := range strings.Split(string(out), "\n") {
parts := strings.SplitN(strings.TrimSpace(line), "|", 3)
- if len(parts) != 3 || strings.Contains(parts[0], "donislawdev") {
+ if len(parts) != 3 {
continue
}
- found[parts[1]] = strings.Fields(parts[2])
+ var carried []string
+ for _, file := range strings.Fields(parts[2]) {
+ if ourOwnWork[parts[1]+"|"+file] {
+ continue
+ }
+ carried = append(carried, file)
+ }
+ if len(carried) > 0 {
+ found[parts[1]] = carried
+ }
}
return found
}
diff --git a/internal/guard/formwidth_test.go b/internal/guard/formwidth_test.go
index cc334a6..ebd9d22 100644
--- a/internal/guard/formwidth_test.go
+++ b/internal/guard/formwidth_test.go
@@ -8,7 +8,6 @@ import (
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/test"
- "fyne.io/fyne/v2/widget"
"github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
"github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
@@ -212,7 +211,8 @@ func TestTheRunSpeaksInsideTheSameColumnAsTheForm(t *testing.T) {
spoke := false
walk(content, func(obj fyne.CanvasObject) {
- label, ok := obj.(*widget.Label)
+ label := asLabel(obj)
+ ok := label != nil
if !ok || label.Text == "" || !label.Visible() {
return
}
diff --git a/internal/guard/generatewindow_test.go b/internal/guard/generatewindow_test.go
index 4edf2dc..f95270f 100644
--- a/internal/guard/generatewindow_test.go
+++ b/internal/guard/generatewindow_test.go
@@ -323,7 +323,8 @@ func errorShown(t *testing.T, o fyne.CanvasObject) string {
t.Helper()
var found string
walk(o, func(obj fyne.CanvasObject) {
- label, ok := obj.(*widget.Label)
+ label := asLabel(obj)
+ ok := label != nil
if ok && label.Importance == widget.DangerImportance && label.Text != "" {
found = label.Text
}
diff --git a/internal/guard/guitext_test.go b/internal/guard/guitext_test.go
index 455a4cd..8c5566c 100644
--- a/internal/guard/guitext_test.go
+++ b/internal/guard/guitext_test.go
@@ -67,28 +67,35 @@ var notWords = map[string]string{
"Translating a storage key would lose what was kept the day somebody changed language",
`"windowWidth"`: "the name the window files its width under, never shown",
`"windowHeight"`: "the name the window files its height under, never shown",
- `"file"`: "the scheme of a local address, which is a protocol name rather than prose",
- `"."`: "the working directory, when the system will not say which one it is",
+ `"windowWidth.2"`: "the same storage key, carrying the layout it was written for. " +
+ "Renamed on 2026-09-08 so a size remembered for the one column form is not " +
+ "restored into the two column one - see run_cgo.go",
+ `"windowHeight.2"`: "the height half of that pair, and it moves with the width or not at all",
+ `"file"`: "the scheme of a local address, which is a protocol name rather than prose",
+ `"."`: "the working directory, when the system will not say which one it is",
`"tfg-out"`: "the folder the window offers to write into. A directory name rather than " +
"prose, and translating it would move where somebody's files land",
- `". "`: "what joins two sentences the declaration already carries",
- `"png"`: "a format id in a comment example rather than a label",
- `"uxtheme.dll"`: "a Windows library this program loads, a file name and not a word",
- `"kernel32.dll"`: "a Windows library this program loads, a file name and not a word",
- `"GetProcAddress"`: "the Win32 entry point looked up by name, an identifier and not a word",
- `"windows"`: "a system name asked of the compiler",
- `"linux"`: "a system name asked of the compiler",
- `"darwin"`: "a system name asked of the compiler",
- `"GOOS="`: "an environment variable handed to the compiler",
- `"CGO_ENABLED=1"`: "an environment variable handed to the compiler",
- `"true"`: "compared against a declared default, never shown",
- `"\n"`: "what joins the lines of the status area, not a word",
- `"\n\n"`: "the blank line between two refusals the form could not place, not a word",
- `"Ag"`: "a sample measured to find how tall a row must be - an ascender and a descender, never drawn",
- `"%s: %w"`: "how one error is wrapped around another, both already worded",
- `"•"`: "the marker in front of a list item, a shape rather than a word",
- `"panel"`: "our name for a colour, in the palette the toolkit asks by name",
- `"fyneDo"`: "a migration flag the toolkit reads, never shown",
+ `". "`: "what joins two sentences the declaration already carries",
+ `"png"`: "a format id in a comment example rather than a label",
+ `"uxtheme.dll"`: "a Windows library this program loads, a file name and not a word",
+ `"kernel32.dll"`: "a Windows library this program loads, a file name and not a word",
+ `"GetProcAddress"`: "the Win32 entry point looked up by name, an identifier and not a word",
+ `"windows"`: "a system name asked of the compiler",
+ `"linux"`: "a system name asked of the compiler",
+ `"darwin"`: "a system name asked of the compiler",
+ `"GOOS="`: "an environment variable handed to the compiler",
+ `"CGO_ENABLED=1"`: "an environment variable handed to the compiler",
+ `"true"`: "compared against a declared default, never shown",
+ `"\n"`: "what joins the lines of the status area, not a word",
+ `"\n\n"`: "the blank line between two refusals the form could not place, not a word",
+ `"Ag"`: "a sample measured to find how tall a row must be - an ascender and a descender, never drawn",
+ `"%s: %w"`: "how one error is wrapped around another, both already worded",
+ `"•"`: "the marker in front of a list item, a shape rather than a word",
+ `"panel"`: "our name for a colour, in the palette the toolkit asks by name",
+ `"panel-raised"`: "the same, for the surface a report panel stands on",
+ `"Inter-Regular.ttf"`: "the name a font resource is cached under by the painter, not a word on the screen",
+ `"Inter-SemiBold.ttf"`: "the same, for the heavier face",
+ `"fyneDo"`: "a migration flag the toolkit reads, never shown",
// The application's own name and id are its identity rather than prose.
// An application is not renamed in another language, and the desktop uses
// both to decide which running program this is. What a person READS in the
diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go
index 884f711..cf8eadd 100644
--- a/internal/guard/layers_test.go
+++ b/internal/guard/layers_test.go
@@ -71,12 +71,13 @@ var layer = map[string]int{
"internal/engine": 3,
"internal/audit": 3,
- "internal/cli": 4,
- "internal/gui": 4,
- "internal/gui/parts": 4,
- "internal/gui/icon": 4,
- "internal/gui/text": 4,
- "internal/gui/window": 4,
+ "internal/cli": 4,
+ "internal/gui": 4,
+ "internal/gui/parts": 4,
+ "internal/gui/icon": 4,
+ "internal/gui/typeface": 4,
+ "internal/gui/text": 4,
+ "internal/gui/window": 4,
"cmd/tfg": 5,
"cmd/tfg-gui": 5,
@@ -149,11 +150,19 @@ var sameLayerAllowed = map[string][]string{
// which is where the golden images sit - an image of a whole screen
// changes with every layout change and stops being looked at.
"internal/gui/window": {"internal/gui/parts", "internal/gui/text"},
- // The sentences the window shows. It imports nothing of ours - a text
- // package that reached for the engine to word a message would put half a
- // message here and half where the engine says it, which is how two
- // wordings for one thing start.
- "internal/gui/parts": {"internal/gui/text"},
+ // The sentences the window shows, and the letters it shows them in.
+ //
+ // The text package reaches no further sideways than this - a text package
+ // that asked the engine to word a message would put half a message here and
+ // half where the engine says it, which is how two wordings for one thing
+ // start. It does reach DOWN to core, for counting a number out in bytes,
+ // and that is the opposite move: one function, both surfaces, so the window
+ // and the command line cannot spell one number two ways.
+ //
+ // The typeface package joined on 2026-09-08. It holds bytes and imports
+ // nothing, so it cannot carry a rule anywhere - which is the whole reason
+ // the faces are not sitting in this package.
+ "internal/gui/parts": {"internal/gui/text", "internal/gui/typeface"},
// And the package that opens a real window reaches all three. It is the
// only one that touches the toolkit's app package, so it is the only one
// that needs a C compiler.
diff --git a/internal/guard/leftedge_test.go b/internal/guard/leftedge_test.go
index 9060f0a..58eea38 100644
--- a/internal/guard/leftedge_test.go
+++ b/internal/guard/leftedge_test.go
@@ -108,30 +108,40 @@ func TestEverythingAPersonReadsStartsOnOneLeftEdge(t *testing.T) {
}
}
-// The bar's own words stand on the same edge as the form's.
+// What a run says stands on the same edge as the boxes beside it.
//
-// Named apart from the screens above because it is the one that was wrong for
-// a reason nobody could see: the action bar does have padding, and the column
-// inside it is centred in what the padding left - so the padding moved the
-// bar's edge and not its content. A guard reading the code would have found
-// the padding and stopped there.
-func TestTheActionBarSpeaksOnTheSameEdgeAsTheForm(t *testing.T) {
+// It used to be about the action bar, and that was the version of this question
+// that existed while the run's messages were pinned to the foot of the window.
+// It was worth naming apart from the screens above because it was wrong for a
+// reason nobody could see: the bar does have padding, and the column inside it
+// is centred in what the padding left, so the padding moved the bar's edge and
+// not its content. A guard reading the code would have found the padding and
+// stopped there.
+//
+// The messages moved into the pinned column on 2026-09-08, so the bar has no
+// words of its own left to line up and the question moved with them. It is the
+// same question: what a run says is read straight after what is in the boxes
+// above it, and two left edges a few pixels apart read as an assembly rather
+// than a layout. The neighbour is now the Output box in the same column rather
+// than a field on the other side of the window - lining a pinned column up with
+// a scrolling one would be asserting that two columns are one.
+func TestWhatARunSaysStandsOnTheSameEdgeAsItsColumn(t *testing.T) {
ourTheme(t)
content, _ := laidOutWindow(t)
generate := tabContent(t, content, text.TabOneTarget())
- field, ok := labelBox(generate, text.FieldFormat())
+ field, ok := labelBox(generate, text.FieldOutputDir())
if !ok {
- t.Fatal("the generate screen has no Format field")
+ t.Fatal("the generate screen has no output directory field, so this guard read the wrong tree")
}
- // What the bar says at rest, before anything has been pressed.
+ // What the run says at rest, before anything has been pressed.
status, ok := labelBox(generate, text.WritingTo(destinationShownAtRest(t, generate)))
if !ok {
- t.Skip("the bar is not naming a destination at rest, so there is nothing on it to line up")
+ t.Skip("nothing is naming a destination at rest, so there is nothing to line up")
}
if off := status.X - field.X; off > 1 || off < -1 {
- t.Errorf("a field name starts at %.1f px and the bar's own line at %.1f px, %.1f px apart",
- field.X, status.X, off)
+ t.Errorf("a field name in the pinned column starts at %.1f px and what the run says at "+
+ "%.1f px, %.1f px apart", field.X, status.X, off)
}
}
diff --git a/internal/guard/livecheck_test.go b/internal/guard/livecheck_test.go
index e189262..4c5a805 100644
--- a/internal/guard/livecheck_test.go
+++ b/internal/guard/livecheck_test.go
@@ -164,7 +164,7 @@ func sayingUnder(t *testing.T, o fyne.CanvasObject, label string) string {
}
var red []string
walk(block, func(inner fyne.CanvasObject) {
- if l, is := inner.(*widget.Label); is && l.Importance == widget.DangerImportance && l.Text != "" {
+ if l := asLabel(inner); l != nil && l.Importance == widget.DangerImportance && l.Text != "" {
red = append(red, l.Text)
}
})
diff --git a/internal/guard/notelemetry_test.go b/internal/guard/notelemetry_test.go
index c8ee43a..af36c8d 100644
--- a/internal/guard/notelemetry_test.go
+++ b/internal/guard/notelemetry_test.go
@@ -151,7 +151,13 @@ var allowedURLs = map[string]string{
"internal/format/svgfile/svg.go|http://www.w3.org/2000/svg": "SVG namespace, written into the image",
"internal/legal/spdx.go|https://github.com/donislawdev/TestingFilesGenerator": "this project, named in the bill of materials it writes",
"internal/legal/modules.go|https://github.com/nicksnyder": "part of a third party copyright line, copied into the notices we ship",
- "internal/version/version.go|https://www.gnu.org/licenses/gpl-3.0.html": "where the GPL text is, named by the licence notice this tool prints",
+ // The closing bracket is part of the key on purpose. The copyright line
+ // reads "... Authors (https://github.com/rsms/inter)" and the scan takes
+ // everything up to whitespace, so what it finds ends with the bracket - and
+ // a key spelled without it would name a literal that is not in the file.
+ "internal/legal/assets.go|https://github.com/rsms/inter)": "part of the Inter copyright line, which the OFL requires to travel " +
+ "verbatim with the font - trimming it to keep an address out of the source would be editing somebody's copyright notice",
+ "internal/version/version.go|https://www.gnu.org/licenses/gpl-3.0.html": "where the GPL text is, named by the licence notice this tool prints",
"internal/gui/text/screens.go|https://donislawdev.com/support/": "the support page, handed to the system browser on a click - " +
"untouchable rule 8 permits this and permits nothing else like it",
}
diff --git a/internal/guard/notices_test.go b/internal/guard/notices_test.go
index f155661..6b5af86 100644
--- a/internal/guard/notices_test.go
+++ b/internal/guard/notices_test.go
@@ -338,7 +338,7 @@ func shipped(t *testing.T, target string) (map[string]string, map[string]string)
func builtVersions(t *testing.T, target, goos string) map[string]string {
t.Helper()
cmd := exec.Command("go", "list", "-deps", "-f",
- "{{if .Module}}{{.Module.Path}}@{{.Module.Version}}{{end}}", target)
+ "{{if .Module}}{{.Module.Path}}@{{.Module.Version}}|{{.ImportPath}}{{end}}", target)
cmd.Env = append(os.Environ(), "GOOS="+goos, "CGO_ENABLED=1")
out, err := cmd.Output()
if err != nil {
@@ -346,12 +346,12 @@ func builtVersions(t *testing.T, target, goos string) map[string]string {
}
versions := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
- line = strings.TrimSpace(line)
- path, version, found := strings.Cut(line, "@")
- if !found || strings.Contains(path, "donislawdev") {
+ module, rest, found := strings.Cut(strings.TrimSpace(line), "@")
+ if !found || strings.Contains(module, "donislawdev") {
continue
}
- versions[path] = version
+ version, _, _ := strings.Cut(rest, "|")
+ versions[module] = version
}
return versions
}
diff --git a/internal/guard/presetwindow_test.go b/internal/guard/presetwindow_test.go
index 509063d..cab34e2 100644
--- a/internal/guard/presetwindow_test.go
+++ b/internal/guard/presetwindow_test.go
@@ -10,7 +10,6 @@ import (
"fyne.io/fyne/v2"
- "fyne.io/fyne/v2/widget"
"github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
"github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
@@ -392,7 +391,7 @@ func TestWhatAPresetFindsIsShownAsSeparateLines(t *testing.T) {
// somebody joins them back into a sentence.
labels := map[string]bool{}
walk(content, func(obj fyne.CanvasObject) {
- if l, ok := obj.(*widget.Label); ok {
+ if l := asLabel(obj); l != nil {
labels[l.Text] = true
}
})
diff --git a/internal/guard/refusalplacement_test.go b/internal/guard/refusalplacement_test.go
index 956d70f..0a1c519 100644
--- a/internal/guard/refusalplacement_test.go
+++ b/internal/guard/refusalplacement_test.go
@@ -7,6 +7,7 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
"github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
)
@@ -153,7 +154,7 @@ func sawItOnce(t *testing.T, content fyne.CanvasObject, label, refusal string) {
// screenshot of the top of the screen.
seen := 0
walk(content, func(obj fyne.CanvasObject) {
- if l, is := obj.(*widget.Label); is && strings.Contains(l.Text, refusal) {
+ if l := asLabel(obj); l != nil && strings.Contains(l.Text, refusal) {
seen++
}
})
@@ -171,7 +172,9 @@ func sawItOnce(t *testing.T, content fyne.CanvasObject, label, refusal string) {
// row rather than first in the field.
func fieldBox(o fyne.CanvasObject, label string) *fyne.Container {
var found *fyne.Container
- walk(o, func(obj fyne.CanvasObject) {
+ // Outside the report for the same reason controlUnder is: a reported fact
+ // is a name beside a value and so is a field, and this asks by name.
+ walkOutsideTheReport(o, func(obj fyne.CanvasObject) {
box, ok := obj.(*fyne.Container)
if !ok || len(box.Objects) < 2 || isHeadingExtra(box.Objects[1]) {
return
@@ -215,9 +218,46 @@ func underSomethingHidden(root fyne.CanvasObject) map[fyne.CanvasObject]bool {
func allText(o fyne.CanvasObject) string {
var out []string
walk(o, func(obj fyne.CanvasObject) {
- if l, ok := obj.(*widget.Label); ok && l.Text != "" {
- out = append(out, l.Text)
+ if s := wordsOn(obj); s != "" {
+ out = append(out, s)
}
})
return strings.Join(out, "\n")
}
+
+// wordsOn is what one object says, whatever kind of label it is.
+//
+// A type assertion on *widget.Label misses every type in parts that EMBEDS one,
+// and that is not a hypothetical: parts.RunStatus arrived on 2026-09-08 and
+// eleven guards immediately reported that the window had stopped saying what a
+// run was doing. It had not. They had stopped being able to hear it.
+//
+// parts.ByteCount and parts.RequiredMark embed a label too and are deliberately
+// NOT here. They were invisible to this before the change as well, so adding
+// them now would alter what a dozen guards read while nothing about them is
+// being worked on - and the star in particular would start appearing in the
+// middle of sentences these guards compare. Written down rather than fixed
+// quietly: this reads labels and the two of them are still missing.
+func wordsOn(obj fyne.CanvasObject) string {
+ if l := asLabel(obj); l != nil {
+ return l.Text
+ }
+ return ""
+}
+
+// asLabel is the label inside an object, or nil if it is not one.
+//
+// It hands back the EMBEDDED widget.Label rather than a copy of its text, so a
+// caller can go on to ask about importance, wrapping or where it ended up. That
+// works because ExtendBaseWidget leaves one BaseWidget in the object, so the
+// embedded label reports the size and position the outer widget was laid out
+// at - the two are the same struct, not two views of it.
+func asLabel(obj fyne.CanvasObject) *widget.Label {
+ switch it := obj.(type) {
+ case *widget.Label:
+ return it
+ case *parts.RunStatus:
+ return &it.Label
+ }
+ return nil
+}
diff --git a/internal/guard/refusalwidth_test.go b/internal/guard/refusalwidth_test.go
index a26820d..8ee5d8e 100644
--- a/internal/guard/refusalwidth_test.go
+++ b/internal/guard/refusalwidth_test.go
@@ -110,7 +110,8 @@ func TestARefusalNamesTheBoxTheWayTheScreenNamesIt(t *testing.T) {
func refusalLabelSaying(o fyne.CanvasObject, words string) *widget.Label {
var found *widget.Label
walk(o, func(obj fyne.CanvasObject) {
- label, is := obj.(*widget.Label)
+ label := asLabel(obj)
+ is := label != nil
if !is || found != nil {
return
}
diff --git a/internal/guard/runstate_test.go b/internal/guard/runstate_test.go
index fce7834..54b4438 100644
--- a/internal/guard/runstate_test.go
+++ b/internal/guard/runstate_test.go
@@ -85,22 +85,34 @@ func TestARefusalBringsTheBoxItIsAboutIntoView(t *testing.T) {
// scrolls to was already on the screen. Nothing was broken and this guard
// went red honestly - it had stopped reaching the state it exists for,
// which is the same shape as observation O118.
- w.Resize(fyne.NewSize(window.OpenSize.Width, 500))
+ w.Resize(fyne.NewSize(window.OpenSize.Width, 300))
content.Refresh()
- w.Resize(fyne.NewSize(window.OpenSize.Width, 501))
+ w.Resize(fyne.NewSize(window.OpenSize.Width, 301))
content.Refresh()
if room, form := scroll.Size().Height, scroll.Content.MinSize().Height; form <= room {
t.Fatalf("the form is %.0f px in %.0f px of room, so nothing has to scroll and this guard "+
"would pass without asking its question", form, room)
}
- // The seed sits in the last section, which is the part off the bottom of
- // this window - the whole case this is about.
- seed := entryUnder(t, content, text.FieldSeed())
- if seed == nil {
- t.Fatal("there is no seed box, so this guard read the wrong tree")
+ // The size is off the bottom of a window this short, which is the whole
+ // case this is about.
+ //
+ // It was the seed until 2026-09-08. The seed moved to the report column
+ // with the rest of Output, and that column does not scroll - so a guard
+ // aimed at it would have been asking whether a thing that cannot move
+ // moved. The box has to be one that really is down there.
+ //
+ // A value that is not a size rather than a name template this build cannot
+ // read, and the difference is where the refusal comes from: a bad size is
+ // turned down while the run is being settled, before any worker starts, so
+ // the answer is on the screen by the time the press returns. A template is
+ // refused by the engine, which answers from a worker, and this guard does
+ // not wait for one.
+ wrong := entryUnder(t, content, text.FieldSize())
+ if wrong == nil {
+ t.Fatal("there is no size box, so this guard read the wrong tree")
}
- seed.SetText("not a number")
+ wrong.SetText("not a size")
press(t, content, "Generate")
@@ -109,7 +121,7 @@ func TestARefusalBringsTheBoxItIsAboutIntoView(t *testing.T) {
"still wherever it was - %.0f px of form in %.0f px of room.",
scroll.Content.MinSize().Height, scroll.Size().Height)
}
- if focused := w.Canvas().Focused(); focused != seed {
+ if focused := w.Canvas().Focused(); focused != wrong {
t.Errorf("the keyboard went to %T rather than to the box the refusal is about", focused)
}
if shown := textIn(content); !strings.Contains(shown, text.RefusedBeforeWriting()) {
diff --git a/internal/guard/samename_test.go b/internal/guard/samename_test.go
index b8d3b0f..d10ddb7 100644
--- a/internal/guard/samename_test.go
+++ b/internal/guard/samename_test.go
@@ -34,13 +34,24 @@ func TestNoSectionIsNamedAfterAFieldInsideIt(t *testing.T) {
fields := map[string]bool{}
atAbsolute(screen, func(o fyne.CanvasObject, _ fyne.Position) {
label, is := o.(*widget.Label)
- if !is || label.Text == "" || !label.TextStyle.Bold {
+ if !is || label.Text == "" {
return
}
- switch label.SizeName {
- case theme.SizeNameHeadingText:
+ // A section title is heavy and at the heading rank. A field
+ // name is light and at the caption rank, which it became on
+ // 2026-09-08 - and until this was changed with it, this guard
+ // looked for a BOLD label and found no field names at all. It
+ // went green on every screen while asking nothing, which is
+ // O118 exactly: it had stopped reaching the state it guards.
+ //
+ // The line explaining a field is drawn at the same rank and in
+ // the same colour, and what tells the two apart is that an
+ // explanation wraps and a name does not.
+ switch {
+ case label.TextStyle.Bold && label.SizeName == theme.SizeNameHeadingText:
sections[label.Text] = true
- case "", theme.SizeNameText:
+ case !label.TextStyle.Bold && label.SizeName == theme.SizeNameCaptionText &&
+ label.Wrapping == fyne.TextWrapOff:
fields[label.Text] = true
}
})
diff --git a/internal/guard/sbom_test.go b/internal/guard/sbom_test.go
index ad8ebe2..f8f425b 100644
--- a/internal/guard/sbom_test.go
+++ b/internal/guard/sbom_test.go
@@ -2,6 +2,8 @@ package guard
import (
"encoding/json"
+ "os"
+ "os/exec"
"runtime"
"strings"
"testing"
@@ -167,6 +169,16 @@ func sbomInput(t *testing.T) legal.Document {
t.Helper()
window, _ := shipped(t, "../../cmd/tfg-gui")
command, _ := shipped(t, "../../cmd/tfg")
+ // The packages of our own module go in beside the modules, because an asset
+ // this repository embeds itself is attached by package rather than by
+ // module - see legal.embeddedItems. Both binaries link this module and only
+ // one of them links the package holding the text faces.
+ //
+ // The notices are asked the module question and are not given these: a
+ // package path in the map they read is a module with no row in their table,
+ // which is what they said the first time this was put in the shared helper.
+ addOurPackages(t, window, "../../cmd/tfg-gui")
+ addOurPackages(t, command, "../../cmd/tfg")
if len(window) < 5 || len(command) < 2 {
t.Skipf("the build reported %d and %d modules, too few to be the real sets", len(window), len(command))
}
@@ -183,6 +195,28 @@ func sbomInput(t *testing.T) legal.Document {
}
}
+// addOurPackages puts the import paths of this repository's own packages into a
+// map that otherwise holds modules.
+//
+// One query rather than three systems, because what is being asked is which
+// packages of ours a target links - and that does not vary by system here: the
+// one package this matters for holds bytes and no build constraints.
+func addOurPackages(t *testing.T, into map[string]string, target string) {
+ t.Helper()
+ cmd := exec.Command("go", "list", "-deps", "-f", "{{.ImportPath}}", target)
+ cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
+ out, err := cmd.Output()
+ if err != nil {
+ t.Skipf("go list is not available here: %v", err)
+ }
+ for _, line := range strings.Split(string(out), "\n") {
+ path := strings.TrimSpace(line)
+ if strings.Contains(path, "donislawdev") {
+ into[path] = ""
+ }
+ }
+}
+
func renderedSBOM(t *testing.T) spdxDoc {
t.Helper()
raw, err := legal.SPDX(sbomInput(t))
diff --git a/internal/guard/sizechoice_test.go b/internal/guard/sizechoice_test.go
index 8ed0422..9792358 100644
--- a/internal/guard/sizechoice_test.go
+++ b/internal/guard/sizechoice_test.go
@@ -188,7 +188,7 @@ func shownText(o fyne.CanvasObject) string {
var all []*widget.Label
var hidden []fyne.CanvasObject
walk(o, func(obj fyne.CanvasObject) {
- if l, ok := obj.(*widget.Label); ok && l.Text != "" {
+ if l := asLabel(obj); l != nil && l.Text != "" {
all = append(all, l)
}
if obj != nil && !obj.Visible() {
@@ -202,7 +202,7 @@ func shownText(o fyne.CanvasObject) string {
}
for _, root := range hidden {
walk(root, func(obj fyne.CanvasObject) {
- if l, ok := obj.(*widget.Label); ok {
+ if l := asLabel(obj); l != nil {
delete(out, l)
}
})
@@ -221,7 +221,7 @@ func shownText(o fyne.CanvasObject) string {
func anyRefusal(o fyne.CanvasObject) string {
var said []string
walk(o, func(obj fyne.CanvasObject) {
- if l, ok := obj.(*widget.Label); ok && l.Importance == widget.DangerImportance && l.Text != "" {
+ if l := asLabel(obj); l != nil && l.Importance == widget.DangerImportance && l.Text != "" {
said = append(said, l.Text)
}
})
diff --git a/internal/guard/smallestaccepted_test.go b/internal/guard/smallestaccepted_test.go
index cc4edb7..237b915 100644
--- a/internal/guard/smallestaccepted_test.go
+++ b/internal/guard/smallestaccepted_test.go
@@ -48,11 +48,17 @@ func smallestPrinted(t *testing.T, id string) int64 {
t.Fatalf("formats %s does not print a minimum on its first line: %q", id, first)
}
rest := first[i+len(marker):]
- end := strings.IndexByte(rest, ' ')
+ // Read up to the unit rather than up to the first space, and take the
+ // spaces out of what is left. The digits are grouped in threes as of
+ // 2026-09-08, so the count itself now CONTAINS spaces - and this guard read
+ // "1 220 B" as one byte and then reported that the tool refuses the minimum
+ // it advertises. It was right about what it saw and wrong about what it
+ // meant, which is what a parser splitting on the wrong thing always is.
+ end := strings.Index(rest, " B")
if end < 0 {
t.Fatalf("the minimum is not followed by a unit: %q", first)
}
- n, err := strconv.ParseInt(rest[:end], 10, 64)
+ n, err := strconv.ParseInt(strings.ReplaceAll(rest[:end], " ", ""), 10, 64)
if err != nil {
t.Fatalf("the minimum is not a number: %q", first)
}
diff --git a/internal/guard/smallfixes_test.go b/internal/guard/smallfixes_test.go
index e1c5f09..6dd037b 100644
--- a/internal/guard/smallfixes_test.go
+++ b/internal/guard/smallfixes_test.go
@@ -7,10 +7,12 @@ import (
"os"
"path/filepath"
"regexp"
+ "strconv"
"strings"
"testing"
"github.com/donislawdev/TestingFilesGenerator/internal/cli"
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
)
@@ -54,7 +56,15 @@ func TestASizeIsWrittenTheWayAPersonWritesIt(t *testing.T) {
if code != cli.ExitOK {
t.Fatalf("the size %q was refused: exit %d\n%s", size, code, errOut)
}
- if !strings.Contains(errOut, want+" B total") {
+ // Through the function that prints it, since the digits were
+ // grouped on 2026-09-08. What this guard is about is whether the
+ // size PARSED to the right number - the spelling of that number
+ // belongs to core and has its own guard.
+ n, convErr := strconv.ParseInt(want, 10, 64)
+ if convErr != nil {
+ t.Fatalf("the wanted size %q in this table is not a number: %v", want, convErr)
+ }
+ if !strings.Contains(errOut, core.ExactBytes(n)+" total") {
t.Errorf("the size %q came to something other than %s B:\n%s", size, want, errOut)
}
})
diff --git a/internal/guard/spacingscale_test.go b/internal/guard/spacingscale_test.go
index ce4faa1..2c28e9f 100644
--- a/internal/guard/spacingscale_test.go
+++ b/internal/guard/spacingscale_test.go
@@ -114,12 +114,28 @@ func TestTwoSectionsAreTheSameDistanceApartOnEveryScreen(t *testing.T) {
func TestTheGapBetweenSectionsIsWiderThanTheGapBetweenFields(t *testing.T) {
ourTheme(t)
content, _ := laidOutWindow(t)
- generate := tabContent(t, content, text.TabOneTarget())
- gaps := sectionGaps(t, generate)
+ // The two distances are read off two screens, and that is a change of
+ // 2026-09-08 rather than a shortcut. Output moved to the pinned column that
+ // day, so the generate screen's scrolling part holds ONE panel and has no
+ // gap between two sections to measure at all. The preset screen still
+ // stacks two in its scroll.
+ //
+ // Reading them apart is safe here because the sibling guard above asserts
+ // the SAME gap on every screen - so a distance taken from one of them is a
+ // statement about all of them, and if it ever stops being, that guard is
+ // the one that says so.
+ preset := tabContent(t, content, text.TabPresets())
+ gaps := sectionGaps(t, preset)
if len(gaps) == 0 {
- t.Fatal("the generate screen shows fewer than two sections, so there is no gap to measure")
+ t.Fatal("the preset screen shows fewer than two sections, so there is no gap to measure")
}
+
+ // The generate tab is opened AFTER the preset one has been read, because
+ // selecting a tab is what lays it out - a tab nobody is on has every widget
+ // at the origin with no size. In the other order this asked about a control
+ // that had not been laid out yet, and said so.
+ generate := tabContent(t, content, text.TabOneTarget())
between := gapBelowField(t, generate, text.FieldFormat(), text.FieldSize())
for _, gap := range gaps {
if gap <= between {
@@ -181,7 +197,8 @@ func labelBox(screen fyne.CanvasObject, words string) (band, bool) {
if ok {
return
}
- label, is := o.(*widget.Label)
+ label := asLabel(o)
+ is := label != nil
if !is || label.Text != words {
return
}
diff --git a/internal/guard/testdata/screens/about.png b/internal/guard/testdata/screens/about.png
index ac6da47..df7de8c 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 e08f617..fbf9e0a 100644
--- a/internal/guard/testdata/screens/about.xml
+++ b/internal/guard/testdata/screens/about.xml
@@ -5,163 +5,118 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Testing Files Generator 0.3.0-rc1
-
-
-
-
-
- Generate test files, and know how the system under test should react to them.
-
-
-
-
-
-
-
-
- Licence
-
-
-
-
- Testing Files Generator
- Copyright (C) 2026 DonislawDev
-
- Released under the GNU General Public License, version 3. The full text is at
- https://www.gnu.org/licenses/gpl-3.0.html, and in the LICENSE file if you have
- the source or the full release.
-
- There is no warranty, to the extent the law allows.
-
- The files you generate are yours. Generated files, recipes and manifests are
- output of this program and not derived works of it, so this licence does not
- reach them. You can generate fixtures, commit them and ship them inside a
- closed source product with no obligation of any kind.
-
- Code from other projects is compiled into this program. Their licences and
- copyright notices are in THIRD-PARTY-NOTICES.md, which comes with the source
- and with the full release.
-
+
+
+
+
+
+
+
+
+ Testing Files Generator 0.3.0-rc1
-
-
-
-
-
-
-
-
- Support
-
-
-
-
- Opens the support page in your browser. The tool is free and stays free - this pays for the time that goes into it.
-
-
-
-
- https://donislawdev.com/support/
-
+
+
+ Generate test files, and know how the system under test should react to them.
+
+
+
+
+
+
+
+ Licence
+
+
+
+
+ Testing Files Generator
+ Copyright (C) 2026 DonislawDev
+
+ Released under the GNU General Public License, version 3. The full text is at
+ https://www.gnu.org/licenses/gpl-3.0.html, and in the LICENSE file if you have
+ the source or the full release.
+
+ There is no warranty, to the extent the law allows.
+
+ The files you generate are yours. Generated files, recipes and manifests are
+ output of this program and not derived works of it, so this licence does not
+ reach them. You can generate fixtures, commit them and ship them inside a
+ closed source product with no obligation of any kind.
+
+ Code from other projects is compiled into this program. Their licences and
+ copyright notices are in THIRD-PARTY-NOTICES.md, which comes with the source
+ and with the full release.
+
+
+
+
+
+
+
+
+
+
+
+
+ Support
+
+
+
+
+ Opens the support page in your browser. The tool is free and stays free - this pays for the time
+ that goes into it.
+
+
+
+
+ https://donislawdev.com/support/
+
+
+
+
-
-
-
-
-
-
- Third party code compiled in
-
-
-
-
- Go runtime and standard library BSD-3-Clause Copyright 2009 The Go Authors
- fyne.io/fyne/v2 BSD-3-Clause (C) 2018 Fyne.io developers (see AUTHORS)
- fyne.io/systray Apache-2.0 2014 Brave New Software Project, Inc.
- github.com/BurntSushi/toml MIT (c) 2013 TOML authors
- github.com/FyshOS/fancyfs BSD-3-Clause (C) 2025 FyshOS developers (see AUTHORS)
- github.com/anthonynsimon/bild MIT (c) 2021 Anthony Najjar Simon
- github.com/clipperhouse/uax29/v2 MIT (c) 2020 Matt Sherman
- github.com/fsnotify/fsnotify BSD-3-Clause Copyright 2012 The Go Authors
- github.com/fyne-io/image BSD-3-Clause (c) 2022, Fyne.io
- github.com/fyne-io/oksvg BSD-3-Clause (c) 2018, Steven R Wiley
- github.com/gen2brain/gav1d BSD-2-Clause (c) 2018-2025 VideoLAN and dav1d authors, (c) 2016 Alliance for Open
- Media
- github.com/gen2brain/jxl BSD-3-Clause (c) the JPEG XL Project Authors
- github.com/go-gl/gl MIT (c) 2014 Eric Woroshow
- github.com/go-gl/glfw/v3.4/glfw BSD-3-Clause (c) 2012 The glfw3-go Authors. All rights reserved.
- github.com/go-text/render BSD-3-Clause 2021 The go-text authors
- github.com/go-text/typesetting BSD-3-Clause 2021 The go-text authors
- github.com/goccy/go-yaml MIT (c) 2019 Masaaki Goshima
- github.com/godbus/dbus/v5 BSD-2-Clause (c) 2013, Georg Reinke (), Google
- github.com/jeandeaual/go-locale MIT (c) 2020 Alexis Jeandeau
- github.com/jsummers/gobmp MIT (c) 2012-2015 Jason Summers
- github.com/mattn/go-runewidth MIT (c) 2016 Yasuhiro Matsumoto
- github.com/nfnt/resize ISC (c) 2012, Jan Schlicht
- github.com/nicksnyder/go-i18n/v2 MIT (c) 2014 Nick Snyder https://github.com/nicksnyder
- github.com/rymdport/portal Apache-2.0
- github.com/srwiley/oksvg BSD-3-Clause (c) 2018, Steven R Wiley
- github.com/srwiley/rasterx BSD-3-Clause (c) 2018, Steven R Wiley
- github.com/yuin/goldmark MIT (c) 2019 Yusuke Inuzuka
- golang.org/x/image BSD-3-Clause 2009 The Go Authors.
- golang.org/x/net BSD-3-Clause 2009 The Go Authors.
- golang.org/x/sys BSD-3-Clause 2009 The Go Authors.
- golang.org/x/text BSD-3-Clause Copyright 2009 The Go Authors
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- Files compiled in that are not code
-
-
-
-
- DejaVu Sans Mono for Powerline Bitstream-Vera Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. DejaVu
- changes are in public domain
- EmojiOne Color MIT Copyright 2016 Adobe Systems Incorporated
- Fyne icon set BSD-3-Clause (C) 2018 Fyne.io developers (see AUTHORS)
- Fyne shaders BSD-3-Clause (C) 2018 Fyne.io developers (see AUTHORS)
- Fyne translations BSD-3-Clause (C) 2018 Fyne.io developers (see AUTHORS)
- Inter OFL-1.1 (c) 2020 The Inter Project Authors
- Noto Sans OFL-1.1 Copyright 2015 Google Inc. All Rights Reserved.
+
+
+
+
+
+
+ Donate
@@ -169,42 +124,137 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Third party code compiled in
+
+
+
+
+ Go runtime and standard library BSD-3-Clause
+ Copyright 2009 The Go Authors
+ fyne.io/fyne/v2 BSD-3-Clause (C) 2018 Fyne.io
+ developers (see AUTHORS)
+ fyne.io/systray Apache-2.0 2014 Brave New
+ Software Project, Inc.
+ github.com/BurntSushi/toml MIT (c) 2013 TOML
+ authors
+ github.com/FyshOS/fancyfs BSD-3-Clause (C)
+ 2025 FyshOS developers (see AUTHORS)
+ github.com/anthonynsimon/bild MIT (c) 2021
+ Anthony Najjar Simon
+ github.com/clipperhouse/uax29/v2 MIT (c) 2020
+ Matt Sherman
+ github.com/fsnotify/fsnotify BSD-3-Clause
+ Copyright 2012 The Go Authors
+ github.com/fyne-io/image BSD-3-Clause (c) 2022,
+ Fyne.io
+ github.com/fyne-io/oksvg BSD-3-Clause (c) 2018,
+ Steven R Wiley
+ github.com/gen2brain/gav1d BSD-2-Clause (c)
+ 2018-2025 VideoLAN and dav1d authors, (c) 2016
+ Alliance for Open Media
+ github.com/gen2brain/jxl BSD-3-Clause (c) the
+ JPEG XL Project Authors
+ github.com/go-gl/gl MIT (c) 2014 Eric Woroshow
+ github.com/go-gl/glfw/v3.4/glfw BSD-3-Clause (c)
+ 2012 The glfw3-go Authors. All rights reserved.
+ github.com/go-text/render BSD-3-Clause 2021
+ The go-text authors
+ github.com/go-text/typesetting BSD-3-Clause
+ 2021 The go-text authors
+ github.com/goccy/go-yaml MIT (c) 2019 Masaaki
+ Goshima
+ github.com/godbus/dbus/v5 BSD-2-Clause (c)
+ 2013, Georg Reinke (),
+ Google
+ github.com/jeandeaual/go-locale MIT (c) 2020
+ Alexis Jeandeau
+ github.com/jsummers/gobmp MIT (c) 2012-2015
+ Jason Summers
+ github.com/mattn/go-runewidth MIT (c) 2016
+ Yasuhiro Matsumoto
+ github.com/nfnt/resize ISC (c) 2012, Jan Schlicht
+ github.com/nicksnyder/go-i18n/v2 MIT (c) 2014
+ Nick Snyder https://github.com/nicksnyder
+ github.com/rymdport/portal Apache-2.0
+ github.com/srwiley/oksvg BSD-3-Clause (c) 2018,
+ Steven R Wiley
+ github.com/srwiley/rasterx BSD-3-Clause (c) 2018,
+ Steven R Wiley
+ github.com/yuin/goldmark MIT (c) 2019 Yusuke
+ Inuzuka
+ golang.org/x/image BSD-3-Clause 2009 The Go
+ Authors.
+ golang.org/x/net BSD-3-Clause 2009 The Go
+ Authors.
+ golang.org/x/sys BSD-3-Clause 2009 The Go
+ Authors.
+ golang.org/x/text BSD-3-Clause Copyright 2009
+ The Go Authors
+
+
+
+
+
+
+
+
+
+
+
+ Files compiled in that are not code
+
+
+
+
+ DejaVu Sans Mono for Powerline Bitstream-Vera
+ Copyright (c) 2003 by Bitstream, Inc. All Rights
+ Reserved. DejaVu changes are in public domain
+ EmojiOne Color MIT Copyright 2016 Adobe
+ Systems Incorporated
+ Fyne icon set BSD-3-Clause (C) 2018 Fyne.io
+ developers (see AUTHORS)
+ Fyne shaders BSD-3-Clause (C) 2018 Fyne.io
+ developers (see AUTHORS)
+ Fyne translations BSD-3-Clause (C) 2018 Fyne.io
+ developers (see AUTHORS)
+ Inter OFL-1.1 (c) 2020 The Inter Project Authors
+ Inter, text faces OFL-1.1 Copyright (c) 2016 The
+ Inter Project Authors (https://github.com/rsms/inter)
+ Noto Sans OFL-1.1 Copyright 2015 Google Inc. All
+ Rights Reserved.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Donate
+
+
+
+
+
+
-
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png
index 3fc18f9..89f377f 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 f5d397b..52d8914 100644
--- a/internal/guard/testdata/screens/generate-chosen-by-key.xml
+++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml
@@ -5,344 +5,403 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- png
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ png
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for png
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for png
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -350,69 +409,39 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-chosen.png b/internal/guard/testdata/screens/generate-chosen.png
index 134d822..4e55010 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 1c13a8a..2600f27 100644
--- a/internal/guard/testdata/screens/generate-chosen.xml
+++ b/internal/guard/testdata/screens/generate-chosen.xml
@@ -5,344 +5,403 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- png
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ png
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for png
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for png
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -350,69 +409,39 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-empty.png b/internal/guard/testdata/screens/generate-empty.png
index e3ba87d..6139238 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 e5e6eff..df7786c 100644
--- a/internal/guard/testdata/screens/generate-empty.xml
+++ b/internal/guard/testdata/screens/generate-empty.xml
@@ -5,352 +5,411 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
-
-
-
-
-
- How many files
-
-
-
-
- *
+
+
+
+ target "files" asks for 0 files. Ask for at least one
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
-
+
+
-
-
-
-
-
-
- target "files" asks for 0 files. Ask for at least one
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -358,69 +417,48 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Nothing was written. Check the settings marked above.
+
+
+
+
+
+ Nothing was written. Check the settings marked
+ above.
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png
index 2cf1535..1647df2 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 d6d91bd..8b2c69d 100644
--- a/internal/guard/testdata/screens/generate-focused.xml
+++ b/internal/guard/testdata/screens/generate-focused.xml
@@ -5,345 +5,404 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
-
+
+
+
+
+
+
+
+
+
+ How many files
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -351,69 +410,39 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-hovered.png b/internal/guard/testdata/screens/generate-hovered.png
index 87def7f..db46f6b 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 37db1fd..608cf90 100644
--- a/internal/guard/testdata/screens/generate-hovered.xml
+++ b/internal/guard/testdata/screens/generate-hovered.xml
@@ -5,344 +5,403 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -350,80 +409,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
-
-
-
-
-
- Exact size of every file. Units count in 1024s, so 10mb
- is 10485760 bytes. A plain number is a count of bytes.
+
+
+
+
+
+
+ Exact size of every file. Units count in 1024s, so 10mb
+ is 10485760 bytes. A plain number is a count of
+ bytes.
+
-
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png
index 9f15be1..da688cd 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 62f74cc..be29083 100644
--- a/internal/guard/testdata/screens/generate-menu-hovered.xml
+++ b/internal/guard/testdata/screens/generate-menu-hovered.xml
@@ -5,344 +5,403 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -350,181 +409,151 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
- avif
+ avif
-
-
-
+
+
+
- bmp
+ bmp
-
-
-
+
+
+
- csv
+ csv
-
-
-
+
+
+
- docx
+ docx
-
-
-
+
+
+
- gif
+ gif
-
-
-
+
+
+
- html
+ html
-
-
-
+
+
+
- ico
+ ico
-
-
-
+
+
+
- jpg
+ jpg
-
-
-
+
+
+
- json
+ json
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png
index 2201cb3..13b3945 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 bc55d39..4f5a9e7 100644
--- a/internal/guard/testdata/screens/generate-menu-keyed.xml
+++ b/internal/guard/testdata/screens/generate-menu-keyed.xml
@@ -5,344 +5,403 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -350,181 +409,151 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
- avif
+ avif
-
-
-
+
+
+
- bmp
+ bmp
-
-
-
+
+
+
- csv
+ csv
-
-
-
+
+
+
- docx
+ docx
-
-
-
+
+
+
- gif
+ gif
-
-
-
+
+
+
- html
+ html
-
-
-
+
+
+
- ico
+ ico
-
-
-
+
+
+
- jpg
+ jpg
-
-
-
+
+
+
- json
+ json
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png
index 63b4541..66a8272 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 9c83d42..373e0d3 100644
--- a/internal/guard/testdata/screens/generate-menu.xml
+++ b/internal/guard/testdata/screens/generate-menu.xml
@@ -5,344 +5,403 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -350,181 +409,151 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
- avif
+ avif
-
-
-
+
+
+
- bmp
+ bmp
-
-
-
+
+
+
- csv
+ csv
-
-
-
+
+
+
- docx
+ docx
-
-
-
+
+
+
- gif
+ gif
-
-
-
+
+
+
- html
+ html
-
-
-
+
+
+
- ico
+ ico
-
-
-
+
+
+
- jpg
+ jpg
-
-
-
+
+
+
- json
+ json
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png
index d7b7a7b..e14d035 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 9e3e85a..540c849 100644
--- a/internal/guard/testdata/screens/generate-refused-both.xml
+++ b/internal/guard/testdata/screens/generate-refused-both.xml
@@ -5,359 +5,418 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- abc
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ abc
+
+
+
-
+
+
+
+
+
+
+
+
+
+ How many files
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ many
+
+
+
+
+
+
+
-
-
-
-
-
- How many files
+
+
+
+ Size "abc" has no number: write something like 10mb or 1048576
-
-
- *
+
+
+
+
+ How many files is "many", which is not a whole number. Write the digits out, such as 1 or 500
-
-
-
-
-
-
-
-
- many
+
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
- Size "abc" has no number: write something like 10mb or 1048576
-
-
-
-
-
-
- How many files is "many", which is not a whole number. Write the digits out, such as 1 or 500
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -365,69 +424,48 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Nothing was written. Check the settings marked above.
+
+
+
+
+
+ Nothing was written. Check the settings marked
+ above.
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png
index 93627aa..88b2aef 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 0f1beb6..18cd246 100644
--- a/internal/guard/testdata/screens/generate-refused-setting.xml
+++ b/internal/guard/testdata/screens/generate-refused-setting.xml
@@ -5,426 +5,485 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- png
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
-
-
+
+
+
+
+
+
+ Format
-
-
-
-
-
-
-
-
- How many files
-
-
-
-
- *
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- 1
-
+
+
+
+
+
+
+
+ png
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+
+
+ Size
-
-
-
-
-
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
+
+
+ *
-
-
+
+
+
+
+
+
+
+
+ 10 485 760 B
-
-
-
-
-
-
-
-
-
-
-
-
- Settings for png
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Width
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
-
-
-
-
-
+
-
-
-
-
-
-
-
-
- 99999
-
-
+
+
+
+
+
+
+ How many files
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
-
-
+
+
-
-
-
-
- Height
+
+
+
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
-
-
+
+
+
+
+
+
+
+
+ File names
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- worked out from the size
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Settings for png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Width
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 99999
+
+
+
+
+
+
+
+
+
+
+
+ Height
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ worked out from the size
+
+
+
+
+
+
+
+
+
+
+
+
+
+ png: Width cannot be "99999" - it takes a whole number of pixels from 1 to 20000
+
+
+
-
-
-
-
- png: Width cannot be "99999" - it takes a whole number of pixels from 1 to 20000
+
+
+ width times height cannot pass 40 megapixels, because the picture is held in memory while it is encoded
-
-
- width times height cannot pass 40 megapixels, because the picture is held in memory while it is encoded
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -432,69 +491,48 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
-
-
-
-
-
-
- Generate
+
+
+
+
+
+
+ This run
-
-
-
-
-
-
-
- Nothing was written. Check the settings marked above.
+
+
+
+
+
+ Nothing was written. Check the settings marked
+ above.
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png
index 6f70fdf..5fd01e4 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 5e9830f..993262b 100644
--- a/internal/guard/testdata/screens/generate-refused.xml
+++ b/internal/guard/testdata/screens/generate-refused.xml
@@ -5,354 +5,413 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 1 B
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 1 B
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
-
+
+
+
+
+
+
+
+
+
+ How many files
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
-
- How many files
-
-
-
-
- *
+
+
+
+ AVIF cannot be smaller than 311 B - the smallest picture this format draws codes to 303 B at
+ worst, and the file always carries a free box, which costs 8 B even when it holds nothing.
+ Requested: 1 B. Ask for 311 B or more, or set a smaller width and height, or a lower quality
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
- AVIF cannot be smaller than 311 B - the smallest picture this format draws codes to 303 B at worst, and the file always
- carries a free box, which costs 8 B even when it holds nothing. Requested: 1 B. Ask for 311 B or more, or set a smaller
- width and height, or a lower quality
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -360,69 +419,48 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Nothing was written. Check the settings marked above.
+
+
+
+
+
+ Nothing was written. Check the settings marked
+ above.
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png
index 1152d9f..156a989 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 33119bf..59e5e6e 100644
--- a/internal/guard/testdata/screens/generate-switch-by-key.xml
+++ b/internal/guard/testdata/screens/generate-switch-by-key.xml
@@ -5,344 +5,403 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -350,69 +409,39 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-typed.png b/internal/guard/testdata/screens/generate-typed.png
index 3ea5863..bc5a164 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 59b4e68..a8abc29 100644
--- a/internal/guard/testdata/screens/generate-typed.xml
+++ b/internal/guard/testdata/screens/generate-typed.xml
@@ -5,351 +5,410 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- abc
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ abc
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
-
- How many files
-
-
-
-
- *
+
+
+
+ Size "abc" has no number: write something like 10mb or 1048576
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
- Size "abc" has no number: write something like 10mb or 1048576
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -357,69 +416,39 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
-
-
-
-
-
-
- Generate
+
+
+
+
+
+
+ This run
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate-unchecked.png b/internal/guard/testdata/screens/generate-unchecked.png
index bb498b4..71d51a8 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 2e77aad..086ae00 100644
--- a/internal/guard/testdata/screens/generate-unchecked.xml
+++ b/internal/guard/testdata/screens/generate-unchecked.xml
@@ -5,344 +5,403 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -350,69 +409,39 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png
index f5dea61..2203102 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 385d183..028dd3a 100644
--- a/internal/guard/testdata/screens/generate.xml
+++ b/internal/guard/testdata/screens/generate.xml
@@ -5,344 +5,403 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Generate files
-
-
-
-
-
-
-
-
-
- File configuration
+
+
+
+
+
+
+
+
+ Generate files
-
-
-
-
-
- Format
-
-
-
-
-
-
+
+
+
+
+
+
+
+ File configuration
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
- 10485760 B
-
-
-
-
-
-
-
-
-
-
-
- 10mb
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+ 10 485 760 B
+
+
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
- How many files
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files
+
+
+
+
+ File names
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ files_0001
+
+
+
+
+
+
+
+
+
+
-
-
-
-
- File names
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+
+ Settings for avif
-
-
+
+
+
+
+
+
+
-
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
-
-
- *
+
+
+ *
-
+
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+ 0
-
+
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
-
-
-
-
+
+
+
+
@@ -350,69 +409,39 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png
index ced64e8..b0a3b6b 100644
Binary files a/internal/guard/testdata/screens/preset-menu-setting.png and b/internal/guard/testdata/screens/preset-menu-setting.png differ
diff --git a/internal/guard/testdata/screens/preset-menu-setting.xml b/internal/guard/testdata/screens/preset-menu-setting.xml
index d603adf..500aa03 100644
--- a/internal/guard/testdata/screens/preset-menu-setting.xml
+++ b/internal/guard/testdata/screens/preset-menu-setting.xml
@@ -5,239 +5,243 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Build a set for a question
-
-
-
-
-
-
-
-
-
- The question
+
+
+
+
+
+
+
+
+ Build a set for a question
-
-
-
-
-
- Preset
-
-
-
-
-
-
+
+
+
+
+
+
+
+ The question
-
-
-
-
-
-
-
- size-boundaries
+
+
+
+
+
+
+ Preset
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ size-boundaries
+
+
+
+
+
+
+
+
-
-
-
-
-
- Is a size limit enforced exactly where it is declared?
-
-
-
-
- Typically finds:
-
-
-
-
-
-
- off by one errors at the limit
+
+
+
+ Is a size limit enforced exactly where it is declared?
-
-
- •
-
-
-
-
-
-
- MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
+
+
+ Typically finds:
-
-
- •
-
-
-
-
-
-
- a limit enforced in the browser and not on the server
-
-
-
-
- •
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Settings
-
-
-
-
-
-
-
-
- Limit
+
+
+
+
+ off by one errors at the limit
+
-
-
-
-
-
-
-
-
-
+
+
+ •
+
-
-
-
-
-
-
-
-
-
-
- 10mb
-
-
-
-
-
+
+
+
+
+ MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
+
+
+
+
+ •
+
+
+
+
+
+
+ a limit enforced in the browser and not on the server
+
+
+
+
+ •
-
-
-
-
-
-
- Spread
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1B,1kb,1mb
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+ Limit
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- Format
-
-
-
-
-
-
-
+
+
+
+
+
+ Spread
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1B,1kb,1mb
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- pdf
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ pdf
+
+
+
+
+
+
+
+
@@ -246,172 +250,197 @@
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
-
-
-
-
-
-
- Generate
+
+
+
+
+
+
+ This run
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
-
+
-
+
@@ -419,66 +448,66 @@
-
+
- html
+ html
-
+
- ico
+ ico
-
+
- jpg
+ jpg
-
+
- json
+ json
-
+
- jxl
+ jxl
-
+
- log
+ log
-
+
- md
+ md
-
+
- pdf
+ pdf
-
+
- png
+ png
diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png
index 5ba1151..d6d7afb 100644
Binary files a/internal/guard/testdata/screens/preset-menu.png and b/internal/guard/testdata/screens/preset-menu.png differ
diff --git a/internal/guard/testdata/screens/preset-menu.xml b/internal/guard/testdata/screens/preset-menu.xml
index 6245239..23f099f 100644
--- a/internal/guard/testdata/screens/preset-menu.xml
+++ b/internal/guard/testdata/screens/preset-menu.xml
@@ -5,239 +5,243 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Build a set for a question
-
-
-
-
-
-
-
-
-
- The question
+
+
+
+
+
+
+
+
+ Build a set for a question
-
-
-
-
-
- Preset
-
-
-
-
-
-
+
+
+
+
+
+
+
+ The question
-
-
-
-
-
-
-
- size-boundaries
+
+
+
+
+
+
+ Preset
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ size-boundaries
+
+
+
+
+
+
+
+
-
-
-
-
-
- Is a size limit enforced exactly where it is declared?
-
-
-
-
- Typically finds:
-
-
-
-
-
-
- off by one errors at the limit
+
+
+
+ Is a size limit enforced exactly where it is declared?
-
-
- •
-
-
-
-
-
-
- MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
+
+
+ Typically finds:
-
-
- •
-
-
-
-
-
-
- a limit enforced in the browser and not on the server
-
-
-
-
- •
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Settings
-
-
-
-
-
-
-
-
- Limit
+
+
+
+
+ off by one errors at the limit
+
-
-
-
-
-
-
-
-
-
+
+
+ •
+
-
-
-
-
-
-
-
-
-
-
- 10mb
-
-
-
-
-
+
+
+
+
+ MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
+
+
+
+
+ •
+
+
+
+
+
+
+ a limit enforced in the browser and not on the server
+
+
+
+
+ •
-
-
-
-
-
-
- Spread
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1B,1kb,1mb
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+ Limit
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- Format
-
-
-
-
-
-
-
+
+
+
+
+
+ Spread
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1B,1kb,1mb
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- pdf
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ pdf
+
+
+
+
+
+
+
+
@@ -246,182 +250,207 @@
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
-
-
-
-
-
-
- Generate
+
+
+
+
+
+
+ This run
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
- size-boundaries
+ size-boundaries
diff --git a/internal/guard/testdata/screens/preset-refused.png b/internal/guard/testdata/screens/preset-refused.png
index 634aed6..567de63 100644
Binary files a/internal/guard/testdata/screens/preset-refused.png and b/internal/guard/testdata/screens/preset-refused.png differ
diff --git a/internal/guard/testdata/screens/preset-refused.xml b/internal/guard/testdata/screens/preset-refused.xml
index 9a9ce24..9fc5aee 100644
--- a/internal/guard/testdata/screens/preset-refused.xml
+++ b/internal/guard/testdata/screens/preset-refused.xml
@@ -5,246 +5,398 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Build a set for a question
-
-
-
-
-
-
-
-
-
- The question
+
+
+
+
+
+
+
+
+ Build a set for a question
-
-
-
-
-
- Preset
-
-
-
-
-
-
+
+
+
+
+
+
+
+ The question
-
-
-
-
-
-
-
- size-boundaries
+
+
+
+
+
+
+ Preset
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ size-boundaries
+
+
+
+
+
+
+
+
-
-
-
-
-
- Is a size limit enforced exactly where it is declared?
-
-
-
-
- Typically finds:
-
-
-
-
-
-
- off by one errors at the limit
+
+
+
+ Is a size limit enforced exactly where it is declared?
-
-
- •
-
-
-
-
-
-
- MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
-
-
-
-
- •
+
+
+ Typically finds:
+
+
+
+
+ off by one errors at the limit
+
+
+
+
+ •
+
+
+
+
+
+
+ MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
+
+
+
+
+ •
+
+
+
+
+
+
+ a limit enforced in the browser and not on the server
+
+
+
+
+ •
+
+
+
+
-
-
-
- a limit enforced in the browser and not on the server
-
-
-
-
- •
-
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+ Limit
+
+
+
+
+
+
+
+
+
+ 512 B
+
+
+
+
+
+
+
+
+
+
+
+ 512
+
+
+
+
+
+
+
+
+
+
+
+
+ the preset size-boundaries cannot build this set - under_1mb would be -1048064 B, and a file
+ cannot be smaller than nothing. Raise the Limit above 1051991 B, narrow the spread, or choose a
+ format with a smaller minimum. The Limit asked for was 512 B.
+
+
+
+
+
+
+
+
+
+ Spread
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1B,1kb,1mb
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Format
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ pdf
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
-
- Limit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
+
+
+
+ *
-
+
-
-
- 512 B
-
-
-
-
-
-
-
-
-
-
- 512
+
+
+
+
+
+
+
+
+ /tfg/out
-
-
+
+
+
+
+ Choose...
+
+
+
-
-
-
- the preset size-boundaries cannot build this set - under_1mb would be -1048064 B, and a file cannot be smaller than
- nothing. Raise the Limit above 1051991 B, narrow the spread, or choose a format with a smaller minimum. The Limit
- asked for was 512 B.
-
-
-
-
-
-
-
-
- Spread
+
+
+
+
+
+ Seed
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1B,1kb,1mb
-
-
-
-
-
+
+
+ *
-
-
-
-
-
-
-
-
-
- Format
-
-
-
+
-
-
-
-
-
-
- pdf
-
-
-
+
+
+
+
+
+
+
+
+ 0
+
+
-
+
@@ -252,162 +404,48 @@
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+ This run
+
-
-
-
-
-
-
- Output directory
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
-
-
-
-
-
-
-
-
- Choose...
+
+
+
+
+
+ Nothing was written. Check the settings marked
+ above.
-
-
-
-
-
-
-
-
-
- Seed
-
+
+
-
-
- *
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
-
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Nothing was written. Check the settings marked above.
-
-
-
-
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/preset.png b/internal/guard/testdata/screens/preset.png
index 9a21b16..eaff2e9 100644
Binary files a/internal/guard/testdata/screens/preset.png and b/internal/guard/testdata/screens/preset.png differ
diff --git a/internal/guard/testdata/screens/preset.xml b/internal/guard/testdata/screens/preset.xml
index 2177962..dfe111f 100644
--- a/internal/guard/testdata/screens/preset.xml
+++ b/internal/guard/testdata/screens/preset.xml
@@ -5,239 +5,243 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Build a set for a question
-
-
-
-
-
-
-
-
-
- The question
+
+
+
+
+
+
+
+
+ Build a set for a question
-
-
-
-
-
- Preset
-
-
-
-
-
-
+
+
+
+
+
+
+
+ The question
-
-
-
-
-
-
-
- size-boundaries
+
+
+
+
+
+
+ Preset
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ size-boundaries
+
+
+
+
+
+
+
+
-
-
-
-
-
- Is a size limit enforced exactly where it is declared?
-
-
-
-
- Typically finds:
-
-
-
-
-
-
- off by one errors at the limit
+
+
+
+ Is a size limit enforced exactly where it is declared?
-
-
- •
-
-
-
-
-
-
- MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
+
+
+ Typically finds:
-
-
- •
-
-
-
-
-
-
- a limit enforced in the browser and not on the server
-
-
-
-
- •
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Settings
-
-
-
-
-
-
-
-
- Limit
+
+
+
+
+ off by one errors at the limit
+
-
-
-
-
-
-
-
-
-
+
+
+ •
+
-
-
-
-
-
-
-
-
-
-
- 10mb
-
-
-
-
-
+
+
+
+
+ MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
+
+
+
+
+ •
+
+
+
+
+
+
+ a limit enforced in the browser and not on the server
+
+
+
+
+ •
-
-
-
-
-
-
- Spread
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1B,1kb,1mb
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+ Limit
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+ 10mb
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- Format
-
-
-
-
-
-
-
+
+
+
+
+
+ Spread
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1B,1kb,1mb
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- pdf
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ pdf
+
+
+
+
+
+
+
+
@@ -246,161 +250,186 @@
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
-
-
-
-
-
- Output directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
-
-
-
- Choose...
+
+
+
+
+ Choose...
+
-
+
+
-
-
-
-
-
-
-
- Seed
+
+
+
+
+
+ Seed
+
-
-
-
- *
+
+
+ *
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
-
-
-
-
-
-
- Generate
+
+
+
+
+
+
+ This run
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/recipe-contents.png b/internal/guard/testdata/screens/recipe-contents.png
index 3ebdf8e..750ead1 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 26736e9..fb0d490 100644
--- a/internal/guard/testdata/screens/recipe-contents.xml
+++ b/internal/guard/testdata/screens/recipe-contents.xml
@@ -5,394 +5,398 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Run several batches together
-
-
-
-
-
-
-
-
-
-
-
-
- Batch 1
-
-
-
-
-
-
-
-
-
-
-
-
- Duplicate
-
-
-
-
-
-
-
-
-
- Format
-
+
+
+
+
+
+
+
+
+ Run several batches together
+
+
+
+
+
+
+
+
+
+
+
+
+ Batch 1
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Duplicate
-
-
-
-
-
-
-
- zip
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
- Batch name
+
+
+
+
+
+
+ Format
+
-
-
-
- *
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+ zip
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Batch name
-
-
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- How many files
+
+
+
+
+
+
+ One size
+
+
+
+
+
+ A range
+
+
+
+
+
+ Around a limit
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Size
-
-
+
+
+
+ *
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- One size
-
-
-
-
-
- A range
-
-
-
-
-
- Around a limit
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
+
+
+
+
+
+ File names
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ files_0001
-
-
+
+
-
+
-
-
-
-
-
-
-
-
- File names
+
+
+
+
+ Settings for zip
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+ Notes for the manifest
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
- Settings for zip
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Notes for the manifest
-
-
-
-
-
-
-
-
-
-
-
-
-
- Files inside each archive
-
-
-
-
-
-
-
- Format
-
+
+
+
+ Files inside each archive
-
-
-
-
-
-
- (Select one)
-
-
-
+
+
+
+
+
+
+ Format
-
-
-
-
-
-
-
- How many files
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ (Select one)
-
-
+
+
+
+
+
+
+
+
+
+ How many files
-
-
-
-
-
-
-
- Size
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Size
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Remove
+
+
+
-
-
-
-
-
-
-
- Remove
-
-
-
-
+
+
+
+
+ Add files inside
+
+
-
-
-
-
- Add files inside
-
-
@@ -400,224 +404,249 @@
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
-
-
-
-
-
- Output directory
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
-
-
-
-
-
-
-
-
- Choose...
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
+
+
+
+
+
+
+
+
+ Add a batch
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
-
-
-
-
-
-
-
-
-
-
-
- Manifest file name
+
+
+ *
-
+
-
-
-
-
-
-
-
- manifest.json
-
-
-
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
+
+
+
+
+ Choose...
+
+
+
+
-
-
-
-
- Seed
+
+
+
+
+
+
+
+ Manifest file name
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ manifest.json
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+ Seed
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
-
-
-
-
-
-
- Generate
+
+
+
+
+
+
+ This run
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
-
-
- Add a batch
-
-
-
-
-
-
+
+
-
-
+
+
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 0e95174..381b6e2 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 6454f3f..f753409 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
@@ -5,807 +5,837 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Run several batches together
-
-
-
-
-
-
-
-
-
-
-
-
- Batch 1
-
-
-
-
-
-
-
-
-
-
-
-
- Duplicate
-
-
-
-
-
-
- Remove
-
-
-
-
-
-
-
-
-
- Format
-
+
+
+
+
+
+
+
+
+ Run several batches together
+
+
+
+
+
+
+
+
+
+
+
+
+ Batch 1
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Duplicate
-
-
-
-
-
-
-
- avif
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Remove
+
+
-
-
-
-
-
-
-
- Batch name
+
+
+
+
+
+
+ Format
+
-
-
-
- *
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Batch name
-
-
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+ How many files
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+ One size
+
+
+
+
+
+ A range
+
+
+
+
+
+ Around a limit
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
-
-
-
-
-
- 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.
-
-
-
-
-
-
-
-
-
-
- One size
-
-
-
-
-
- A range
-
-
-
-
-
- Around a limit
-
-
-
-
-
-
-
-
- Size
+
+
+
+
+
+ File names
-
-
- *
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ files_0001
-
-
+
+
-
+
-
-
-
-
- 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.
+
+
+
+
+ Settings for avif
+
-
-
-
-
-
-
-
-
-
- File names
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+ Notes for the manifest
-
-
+
+
+
+
+
+
+
-
-
-
-
- Settings for avif
+
+
+
+
+
+
+
+
+
+ Batch 2
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
- Notes for the manifest
+
+
+
+
+
+ Duplicate
-
-
-
-
+
+
+
+
+ Remove
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- Batch 2
-
-
-
-
-
-
-
-
-
-
-
-
- Duplicate
-
-
-
-
-
-
- Remove
-
-
-
-
-
-
-
-
-
- Format
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- second
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+ second
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- How many files
+
+
+
+
+
+
+ One size
+
+
+
+
+
+ A range
+
+
+
+
+
+ Around a limit
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Size
-
-
+
+
+
+ *
-
-
-
+
+
+
+
+
+
+
+ 1 024 B
+
+
+
+
+
+
+
+
+
+
+ 1kb
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- One size
-
-
-
-
-
- A range
-
-
-
-
-
- Around a limit
-
-
-
-
-
-
-
-
- Size
+
+
+
+
+
+ File names
-
-
- *
-
-
-
+
-
-
- 1024 B
-
-
-
-
-
-
-
-
-
- 1kb
+
+
+
+
+
+
+
+ files_0001
+
+
+
-
+
-
-
-
-
-
-
-
-
- File names
+
+
+
+
+ Settings for avif
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+ Notes for the manifest
-
-
+
+
+
+
+
+
+
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Notes for the manifest
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
-
-
-
-
-
- Output directory
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
-
-
-
-
-
-
-
-
- Choose...
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
+
+
+
+
+
+
+
+
+ Add a batch
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
-
-
-
-
-
-
-
-
-
-
-
- Manifest file name
+
+
+ *
-
+
-
-
-
-
-
-
-
- manifest.json
-
-
-
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
+
+
+
+
+ Choose...
+
+
+
+
-
-
-
-
- Seed
+
+
+
+
+
+
+
+ Manifest file name
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ manifest.json
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+ Seed
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Nothing was written. Check the settings marked above.
+
+
+
+
+
+ Nothing was written. Check the settings marked
+ above.
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
-
-
- Add a batch
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/recipe-refused.png b/internal/guard/testdata/screens/recipe-refused.png
index 7b2cfc4..a044811 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 70c83f1..8177ea4 100644
--- a/internal/guard/testdata/screens/recipe-refused.xml
+++ b/internal/guard/testdata/screens/recipe-refused.xml
@@ -5,534 +5,572 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Run several batches together
-
-
-
-
-
-
-
-
-
-
-
-
- Batch 1
-
-
-
-
-
-
-
-
-
-
-
-
- Duplicate
-
-
-
-
-
-
-
-
-
- Format
-
+
+
+
+
+
+
+
+
+ Run several batches together
+
+
+
+
+
+
+
+
+
+
+
+
+ Batch 1
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Duplicate
-
-
-
-
-
-
-
- avif
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
- Batch name
+
+
+
+
+
+
+ Format
+
-
-
-
- *
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Batch name
-
-
+
+
+
+ *
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ How many files
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+ One size
+
+
+
+
+
+ A range
+
+
+
+
+
+ Around a limit
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Size
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
-
-
-
-
-
- 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.
-
-
-
-
-
-
-
-
-
-
- One size
-
-
-
-
-
- A range
-
-
-
-
-
- Around a limit
-
-
-
-
-
-
-
-
- Size
+
+
+
+
+
+ File names
-
-
- *
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ files_0001
-
-
+
+
-
+
-
-
-
-
- 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.
+
+
+
+
+ Settings for avif
+
-
-
-
-
-
-
-
-
-
- File names
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+ Notes for the manifest
-
-
+
+
+
+
+
+
+
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Notes for the manifest
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
-
-
-
-
-
- Output directory
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
-
-
-
-
-
-
-
-
- Choose...
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
+
+
+
+
+
+
+
+
+ Add a batch
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
-
-
-
-
-
-
-
-
-
-
-
- Manifest file name
+
+
+ *
-
+
-
-
-
-
-
-
-
- manifest.json
-
-
-
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
+
+
+
+
+ Choose...
+
+
+
+
-
-
-
-
- Seed
+
+
+
+
+
+
+
+ Manifest file name
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ manifest.json
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+ Seed
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Nothing was written. Check the settings marked above.
+
+
+
+
+
+ Nothing was written. Check the settings marked
+ above.
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
-
-
- Add a batch
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/recipe-two-batches.png b/internal/guard/testdata/screens/recipe-two-batches.png
index 43df784..3f3a5f2 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 729d196..b67c7e8 100644
--- a/internal/guard/testdata/screens/recipe-two-batches.xml
+++ b/internal/guard/testdata/screens/recipe-two-batches.xml
@@ -5,794 +5,815 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Run several batches together
-
-
-
-
-
-
-
-
-
-
-
-
- Batch 1
-
-
-
-
-
-
-
-
-
-
-
-
- Duplicate
-
-
-
-
-
-
- Remove
-
-
-
-
-
-
-
-
-
- Format
-
+
+
+
+
+
+
+
+
+ Run several batches together
+
+
+
+
+
+
+
+
+
+
+
+
+ Batch 1
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Duplicate
-
-
-
-
-
-
-
- avif
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Remove
+
+
-
-
-
-
-
-
-
- Batch name
+
+
+
+
+
+
+ Format
+
-
-
-
- *
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- How many files
+
+
+
+
+
+
+ One size
+
+
+
+
+
+ A range
+
+
+
+
+
+ Around a limit
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Size
-
-
+
+
+
+ *
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- One size
-
-
-
-
-
- A range
-
-
-
-
-
- Around a limit
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
+
+
+
+
+
+ File names
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ files_0001
-
-
+
+
-
+
-
-
-
-
-
-
-
-
- File names
+
+
+
+
+ Settings for avif
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+ Notes for the manifest
-
-
+
+
+
+
+
+
+
-
-
-
-
- Settings for avif
+
+
+
+
+
+
+
+
+
+ Batch 2
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
- Notes for the manifest
+
+
+
+
+
+ Duplicate
-
-
-
-
+
+
+
+
+ Remove
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- Batch 2
-
-
-
-
-
-
-
-
-
-
-
-
- Duplicate
-
-
-
-
-
-
- Remove
-
-
-
-
-
-
-
-
-
- Format
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- avif
+
+
+
+
+
+
+ Format
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- Batch name
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Batch name
-
-
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- How many files
+
+
+
+
+
+
+ One size
+
+
+
+
+
+ A range
+
+
+
+
+
+ Around a limit
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Size
-
-
+
+
+
+ *
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- One size
-
-
-
-
-
- A range
-
-
-
-
-
- Around a limit
-
-
-
-
-
-
-
-
- Size
-
-
-
-
- *
+
+
+
+
+
+ File names
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ files_0001
-
-
+
+
-
+
-
-
-
-
-
-
-
-
- File names
+
+
+
+
+ Settings for avif
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+ Notes for the manifest
-
-
+
+
+
+
+
+
+
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Notes for the manifest
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
-
-
-
-
-
- Output directory
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
-
-
-
-
-
-
-
-
- Choose...
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
+
+
+
+
+
+
+
+
+ Add a batch
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
-
-
-
-
-
-
-
-
-
-
-
- Manifest file name
+
+
+ *
-
+
-
-
-
-
-
-
-
- manifest.json
-
-
-
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
+
+
+
+
+ Choose...
+
+
+
+
-
-
-
-
- Seed
+
+
+
+
+
+
+
+ Manifest file name
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ manifest.json
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+ Seed
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
-
-
-
-
-
-
- Generate
+
+
+
+
+
+
+ This run
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
-
-
- Add a batch
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/testdata/screens/recipe.png b/internal/guard/testdata/screens/recipe.png
index 83aab5b..e6e8363 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 5298321..b14c4ba 100644
--- a/internal/guard/testdata/screens/recipe.xml
+++ b/internal/guard/testdata/screens/recipe.xml
@@ -5,515 +5,544 @@
-
- Single batch
+
+ Single batch
-
+
Presets
-
+
Several batches
-
- About
+
+ About
-
-
-
+
+
+
-
-
-
-
-
-
- Run several batches together
-
-
-
-
-
-
-
-
-
-
-
-
- Batch 1
-
-
-
-
-
-
-
-
-
-
-
-
- Duplicate
-
-
-
-
-
-
-
-
-
- Format
-
+
+
+
+
+
+
+
+
+ Run several batches together
+
+
+
+
+
+
+
+
+
+
+
+
+ Batch 1
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Duplicate
-
-
-
-
-
-
-
- avif
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
- Batch name
+
+
+
+
+
+
+ Format
+
-
-
-
- *
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+ avif
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Batch name
+
+
+
+
+ *
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ How many files
+
-
-
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- How many files
+
+
+
+
+
+
+ One size
+
+
+
+
+
+ A range
+
+
+
+
+
+ Around a limit
-
-
-
-
-
-
-
-
- 1
+
+
+
+
+
+
+ Size
-
-
+
+
+
+ *
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- One size
-
-
-
-
-
- A range
-
-
-
-
-
- Around a limit
-
-
-
-
-
-
-
-
- Size
+
+
+
+
+
+ File names
-
-
- *
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ files_0001
-
-
+
+
-
+
-
-
-
-
-
-
-
-
- File names
+
+
+
+
+ Settings for avif
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- files_0001
-
-
-
-
+
+
+
+
+ Notes for the manifest
-
-
+
+
+
+
+
+
+
-
-
-
-
- Settings for avif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Notes for the manifest
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
-
-
-
-
-
- Output directory
-
-
-
-
- *
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /tfg/out
-
-
-
-
-
-
-
-
- Choose...
+
+
+ Ctrl+P
+
+
+
+
+
+
+ Generate
+
+
+
+
+ Ctrl+Enter
+
+
+
+
+
+
+
+
+
+
+
+ Donate
+
+
+
+
+
+
+
+
+ Add a batch
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
+
+ Output directory
-
-
-
-
-
-
-
-
-
-
-
- Manifest file name
+
+
+ *
-
+
-
-
-
-
-
-
-
- manifest.json
-
-
-
+
+
+
+
+
+
+
+
+ /tfg/out
+
-
-
+
+
+
+
+ Choose...
+
+
+
+
-
-
-
-
- Seed
+
+
+
+
+
+
+
+ Manifest file name
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ manifest.json
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- Write a label inside each file
+
+
+
+
+ Seed
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Write a label inside each file
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview
+
+
+
+
+
+
+ This run
-
-
-
-
- Generate
-
-
-
-
-
-
-
-
-
- Files will go to /tfg/out
+
+
+
+
+
+ Files will go to /tfg/out
+
-
-
-
+
+
+
-
-
-
-
-
-
- Donate
-
-
-
-
-
-
-
-
- Add a batch
-
-
-
-
-
-
+
+
-
-
+
+
diff --git a/internal/guard/twocolumns_test.go b/internal/guard/twocolumns_test.go
new file mode 100644
index 0000000..2aba68b
--- /dev/null
+++ b/internal/guard/twocolumns_test.go
@@ -0,0 +1,120 @@
+package guard
+
+import (
+ "testing"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/theme"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
+)
+
+// The window cannot be made narrower than the two columns it is laid out in.
+//
+// Fyne has no SetMinSize on a window. Checked in the pinned source rather than
+// assumed: internal/driver/glfw/window_desktop.go line 297 hands the window
+// manager view.SetSizeLimits(minWidth, minHeight, ...) and takes both numbers
+// from minSizeOnScreen, which is the CONTENT's MinSize. So the smallest a
+// window can be dragged to is not something anybody declares - it falls out of
+// whatever the layout happens to ask for.
+//
+// That makes it exactly the kind of number observation O118 is about. Nothing
+// states it, so nothing notices the day a layout change quietly lowers it, and
+// what a person would meet is a window they can squeeze until the report column
+// is a sliver and the form's boxes are narrower than the values in them. Which
+// is defect 2 from the redesign coming back from the other side.
+//
+// The floor asserted here is the form's own smallest width plus the report
+// column, and it is asked of the laid out screen rather than added up from the
+// constants - adding up the constants would be a copy of the layout's own
+// arithmetic and would agree with it however wrong both were.
+//
+// Every screen, because the window is one window: whichever tab somebody is on
+// when they drag the edge is the one that decides.
+func TestTheWindowCannotBeMadeTooNarrowToUse(t *testing.T) {
+ ourTheme(t)
+ content, _ := laidOutWindow(t)
+
+ // Below this a Windows path in the report column wraps to four lines and
+ // the panel stops being a summary. It is a judgement rather than a
+ // threshold from anywhere, and it is recorded as one in
+ // docs/GUI-REDESIGN-2026-09-08.md.
+ const floor = 900
+
+ for _, tab := range []string{
+ text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), text.TabAbout(),
+ } {
+ screen := tabContent(t, content, tab)
+ wide := screen.MinSize().Width
+ if wide < floor {
+ t.Errorf("the %s screen asks for %.0f px at its smallest and the window will let itself "+
+ "be dragged to that. Below about %d px the report column stops fitting a path on "+
+ "one line. What to do: this is not a number to lower - find the layout that stopped "+
+ "asking for the room it needs.", tab, wide, floor)
+ }
+ if wide < parts.ReportWidth {
+ t.Errorf("the %s screen asks for %.0f px, which is less than the %0.f px the report "+
+ "column alone is drawn at - so the column is being squeezed rather than the form",
+ tab, wide, float32(parts.ReportWidth))
+ }
+ }
+}
+
+// The four surfaces of this window stay in the order they were measured in.
+//
+// Page, panel, raised panel, box - each one lighter than the last, and that
+// ordering is the whole of what makes them tell apart. docs/UX.md section 8.1
+// puts a surface in the "decoration" role, which carries no contrast threshold
+// at all, so there is no number here to assert. What there is instead is the
+// sequence: a raised panel darker than the panel it stands beside, or lighter
+// than a box, is not a subtler version of the design - it is a different one.
+//
+// It matters because the two panels stand SIDE BY SIDE. The form's panel holds
+// nothing but boxes and the raised one holds no boxes at all, so if the raised
+// surface drifts up to meet the box colour, a box on the panel next to it reads
+// as the same surface. That was measured while choosing the value: the obvious
+// lighter candidate was 4.75 L* clear of the panel and only 1.78 from a box,
+// and it was rejected on the second number.
+//
+// In L* rather than in a contrast ratio, for the reason section 8.1 gives: the
+// ratio compresses against a light background, so one threshold would mean two
+// different things in the two variants. Both variants are checked, because the
+// light one is measured and guarded even though it is not installed.
+func TestTheSurfacesOfThisWindowStayInOrder(t *testing.T) {
+ for _, variant := range []struct {
+ name string
+ v fyne.ThemeVariant
+ }{{"dark", theme.VariantDark}, {"light", theme.VariantLight}} {
+ page := lightness(parts.PaletteColour(theme.ColorNameBackground, variant.v))
+ panel := lightness(parts.PaletteColour(parts.ColorNamePanel, variant.v))
+ raised := lightness(parts.PaletteColour(parts.ColorNamePanelRaised, variant.v))
+ box := lightness(parts.PaletteColour(theme.ColorNameInputBackground, variant.v))
+
+ // Dark runs upwards from the page and light runs downwards from it, so
+ // the rule is stated as distance from the page rather than as "lighter".
+ // Written the other way it would hold in one variant and be nonsense in
+ // the other.
+ steps := []struct {
+ name string
+ at float64
+ }{{"page", page}, {"panel", panel}, {"raised panel", raised}, {"box", box}}
+ for i := 1; i < len(steps); i++ {
+ from := abs(steps[i-1].at - page)
+ to := abs(steps[i].at - page)
+ if to <= from {
+ t.Errorf("%s: %s is %.2f L* from the page and %s is %.2f - so %s is not the further "+
+ "of the two and the stack has lost a rung. The four surfaces have to step away "+
+ "from the page in order or two of them read as one.",
+ variant.name, steps[i-1].name, from, steps[i].name, to, steps[i].name)
+ }
+ }
+ }
+}
+
+func abs(f float64) float64 {
+ if f < 0 {
+ return -f
+ }
+ return f
+}
diff --git a/internal/guard/window_test.go b/internal/guard/window_test.go
index 56e46ac..84681d3 100644
--- a/internal/guard/window_test.go
+++ b/internal/guard/window_test.go
@@ -405,36 +405,58 @@ func TestTheWindowOpensOnTheGenerateScreen(t *testing.T) {
// window uses. A walker that only knew about containers would stop at the
// scroll the generate screen sits in and report an empty screen.
func walk(o fyne.CanvasObject, visit func(fyne.CanvasObject)) {
- if o == nil {
+ walkPast(o, nil, visit)
+}
+
+// walkOutsideTheReport visits a screen without the panel that reports.
+//
+// A field is a name beside a control, and a reported fact is a name beside a
+// value. Those are the same shape, so a search for a field by its name finds a
+// report line just as readily - and on 2026-09-08 twenty four guards said "the
+// format field is a Label" about screens whose format field is a menu, because
+// the panel had grown a line reading "Format" beside the format it would use.
+//
+// The panel says which it is rather than this guessing from the shape. See
+// parts.IsReport, and ReportSection, which promised in words that it holds no
+// boxes at all long before anything could ask.
+func walkOutsideTheReport(o fyne.CanvasObject, visit func(fyne.CanvasObject)) {
+ walkPast(o, parts.IsReport, visit)
+}
+
+// walkPast is the recursion both of the above share, with a subtree it will
+// not enter.
+func walkPast(o fyne.CanvasObject, skip func(fyne.CanvasObject) bool, visit func(fyne.CanvasObject)) {
+ if o == nil || (skip != nil && skip(o)) {
return
}
visit(o)
+ walkInto := func(child fyne.CanvasObject) { walkPast(child, skip, visit) }
switch v := o.(type) {
case *fyne.Container:
for _, child := range v.Objects {
- walk(child, visit)
+ walkInto(child)
}
case *container.Scroll:
- walk(v.Content, visit)
+ walkInto(v.Content)
case *widget.Card:
// A card is a widget rather than a container, so nothing below it is
// reachable without this - and every field moved inside one on
// 2026-08-11. The first symptom was a nil type assertion in a guard
// that had been passing for weeks.
- walk(v.Content, visit)
+ walkInto(v.Content)
case *container.AppTabs:
// Every tab, including the ones not on show. A guard that only saw the
// selected one could not ask whether a screen it is not looking at
// still holds what it should - and the close intercept has to reach a
// run on the tab nobody is watching.
for _, item := range v.Items {
- walk(item.Content, visit)
+ walkInto(item.Content)
}
case *widget.PopUp:
// A field's longer explanation opens in one of these.
- walk(v.Content, visit)
+ walkInto(v.Content)
default:
- walkUnknown(o, visit)
+ walkUnknown(o, skip, visit)
}
}
@@ -458,7 +480,7 @@ func walk(o fyne.CanvasObject, visit func(fyne.CanvasObject)) {
// recursion into anything else - so a widget that holds children some other way
// is still missed, and the switch above is still where a case belongs when
// somebody notices.
-func walkUnknown(o fyne.CanvasObject, visit func(fyne.CanvasObject)) {
+func walkUnknown(o fyne.CanvasObject, skip func(fyne.CanvasObject) bool, visit func(fyne.CanvasObject)) {
value := reflect.ValueOf(o)
if value.Kind() != reflect.Ptr || value.IsNil() {
return
@@ -472,7 +494,12 @@ func walkUnknown(o fyne.CanvasObject, visit func(fyne.CanvasObject)) {
return
}
if child, ok := field.Interface().(fyne.CanvasObject); ok && child != nil {
- walk(child, visit)
+ // The skip travels through here as well, and forgetting that is how the
+ // first version of walkOutsideTheReport did nothing at all. Every screen
+ // is wrapped in a widget this switch does not name, so the walk reached
+ // the report panel through THIS branch - and a prune dropped on the way
+ // down is a prune that never happens.
+ walkPast(child, skip, visit)
}
}
@@ -548,6 +575,13 @@ func textIn(o fyne.CanvasObject) string {
case *widget.Label:
b.WriteString(v.Text)
b.WriteString("\n")
+ case *parts.RunStatus:
+ // The line a run talks on. It embeds a label rather than being one,
+ // so a case for *widget.Label does not reach it - and this reader is
+ // what a dozen guards use to ask what the window is saying. Without
+ // this they report a window that has gone silent about its own run.
+ b.WriteString(v.Text)
+ b.WriteString("\n")
case *widget.Button:
b.WriteString(v.Text)
b.WriteString("\n")
@@ -608,7 +642,7 @@ func buttonNames(o fyne.CanvasObject) []string {
// different box.
func controlUnder(o fyne.CanvasObject, label string) fyne.CanvasObject {
var found fyne.CanvasObject
- walk(o, func(obj fyne.CanvasObject) {
+ walkOutsideTheReport(o, func(obj fyne.CanvasObject) {
box, ok := obj.(*fyne.Container)
if !ok || len(box.Objects) < 2 {
return
@@ -757,7 +791,7 @@ func detailButtonIn(row *fyne.Container) *parts.DetailButton {
}
func headingOf(o fyne.CanvasObject) *widget.Label {
- if label, ok := o.(*widget.Label); ok {
+ if label := asLabel(o); label != nil {
return label
}
row, ok := o.(*fyne.Container)
diff --git a/internal/gui/parts/bytecount.go b/internal/gui/parts/bytecount.go
index b3066cf..81c9c6d 100644
--- a/internal/gui/parts/bytecount.go
+++ b/internal/gui/parts/bytecount.go
@@ -40,7 +40,21 @@ func newByteCount() *ByteCount {
// it wants it.
c.SizeName = theme.SizeNameCaptionText
c.Importance = widget.LowImportance
- c.Alignment = fyne.TextAlignTrailing
+ // The monospace face, and leading rather than trailing, since 2026-09-08.
+ //
+ // The two go together. It used to be pushed to the far edge of the column,
+ // where being aligned to that edge is what made it look placed rather than
+ // stranded - and at 215 px from the box it describes it was stranded
+ // anyway. It follows the explanation button now, so it aligns like anything
+ // else on the line.
+ //
+ // Monospace is what stops it reading as more of the label. A count of bytes
+ // is a measurement, the face says so, and the digits line up with every
+ // other number this window reports. Fyne exposes no OpenType features, so
+ // tabular figures cannot be turned on in the face the rest of the window
+ // uses - this is the one that has them by construction.
+ c.TextStyle = fyne.TextStyle{Monospace: true}
+ c.Alignment = fyne.TextAlignLeading
return c
}
diff --git a/internal/gui/parts/facts.go b/internal/gui/parts/facts.go
new file mode 100644
index 0000000..a195f87
--- /dev/null
+++ b/internal/gui/parts/facts.go
@@ -0,0 +1,157 @@
+package parts
+
+import (
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
+)
+
+// The pieces a report panel is made of.
+//
+// Why they exist at all. The column beside the form held one sentence and
+// whatever height was left over, so at 1120x760 it drew a 414 px panel around
+// 28 px of words - measured 2026-09-08, and the owner's report was that the
+// window looked emptier than before it was rebuilt. ReportColumn stopped taking
+// the leftover in the same change, and this is the other half: a report that
+// has something to say does not need the room hidden.
+//
+// What is NOT here is arithmetic. G1 says the window asks the engine rather
+// than working things out, and every number these draw arrives already worked
+// out - a count off engine.Target, a size off core.ParseSize, a total off
+// engine.TotalBytes. The parts spell numbers and never combine them.
+
+// Fact is one line of a report: a quiet name, and what it came to.
+//
+// Monospace on the value half for the reason ByteCount is monospace. Fyne
+// exposes no OpenType features in v2.8.1, so tabular figures cannot be turned
+// on in the face the rest of the window uses, and a column of numbers that does
+// not line up is a column that has to be read one row at a time. The face that
+// has them by construction is the one to use.
+type Fact struct {
+ object *fyne.Container
+ value *widget.Label
+}
+
+// NewFact makes a line, with nothing on it. It stays off the screen until it
+// has something to say - see Say.
+func NewFact(name string) *Fact {
+ value := widget.NewLabel("")
+ value.TextStyle = fyne.TextStyle{Monospace: true}
+ value.Alignment = fyne.TextAlignTrailing
+
+ f := &Fact{value: value}
+ f.object = container.NewBorder(nil, nil, quietLabel(name), value)
+ f.object.Hide()
+ return f
+}
+
+// Object is the line, to put on a screen.
+func (f *Fact) Object() fyne.CanvasObject { return f.object }
+
+// Say puts a value on the line, and an empty one takes the line off the screen.
+//
+// Hidden rather than blank, because a name with nothing beside it is a question
+// the panel asked and did not answer - and the column layout skips what is
+// hidden, so a line that says nothing costs no height either.
+func (f *Fact) Say(value string) {
+ if value == "" {
+ f.object.Hide()
+ return
+ }
+ f.value.SetText(value)
+ f.object.Show()
+}
+
+// SizeFact is a fact whose value is a number of bytes, said twice.
+//
+// Both halves, always. The readable one is what somebody is looking for and the
+// exact one is the whole point of this tool - a generator that says "about
+// 10 MB" has not done its job - so neither is allowed to stand alone. The
+// readable one leads because it answers the glance, and the exact one sits
+// under it in the rank of a note.
+type SizeFact struct {
+ object *fyne.Container
+ human *widget.Label
+ exact *widget.Label
+}
+
+// NewSizeFact makes the block, with nothing in it.
+func NewSizeFact(name string) *SizeFact {
+ human := widget.NewLabel("")
+ human.TextStyle = fyne.TextStyle{Monospace: true}
+
+ exact := widget.NewLabel("")
+ exact.TextStyle = fyne.TextStyle{Monospace: true}
+ exact.Importance = widget.LowImportance
+ exact.SizeName = theme.SizeNameCaptionText
+
+ s := &SizeFact{human: human, exact: exact}
+ // No gap between the three. They are one fact drawn on three lines, and the
+ // vertical scale says a gap of nought is what "inside one thing" looks like
+ // when the thing is a stack of text.
+ s.object = Column(0, quietLabel(name), human, exact)
+ s.object.Hide()
+ return s
+}
+
+// Object is the block, to put on a screen.
+func (s *SizeFact) Object() fyne.CanvasObject { return s.object }
+
+// Say puts a number of bytes on the screen, readable and exact.
+//
+// It is handed bytes rather than a string, so that the spelling of a byte count
+// happens in one place for the whole program. core.HumanBytes and
+// text.ExactBytes are that place, and they are the same two the command line
+// prints with.
+func (s *SizeFact) Say(bytes int64) {
+ s.human.SetText(core.HumanBytes(bytes))
+ s.exact.SetText(text.ExactBytes(bytes))
+ s.object.Show()
+}
+
+// Nothing takes the block off the screen, for a value that is not known yet.
+func (s *SizeFact) Nothing() { s.object.Hide() }
+
+// quietLabel is the name half of a fact, at the rank of a field's own name.
+//
+// The same rank on purpose. A report panel and a form panel stand side by side,
+// so a name in one that outweighed a name in the other would say the two are
+// different kinds of thing when they are not - both are the quiet half of a
+// pair whose other half is the content.
+func quietLabel(name string) *widget.Label {
+ label := widget.NewLabel(name)
+ label.Importance = widget.LowImportance
+ label.SizeName = theme.SizeNameCaptionText
+ return label
+}
+
+// Rule is the hairline between two groups of facts.
+//
+// theme.ColorNameSeparator rather than a colour of its own, and that is worth a
+// line because the palette here is measured and adding to it is not free - see
+// ColorNamePanelRaised for what one new value costs. This one was already
+// declared and already the right weight.
+func Rule() fyne.CanvasObject {
+ line := canvas.NewRectangle(PaletteColour(theme.ColorNameSeparator, theme.VariantDark))
+ line.SetMinSize(fyne.NewSize(0, 1))
+ return line
+}
+
+// Path is a directory drawn where somebody has to be able to read all of it.
+//
+// Wrapped rather than cut off. What it holds is where this program is about to
+// write on somebody's disk, and the end of a path is the part that says which
+// directory - so a path shortened at the right is a path that answers the wrong
+// half of the question. Monospace because it is a literal value, not prose.
+func Path(dir string) *widget.Label {
+ label := widget.NewLabel(dir)
+ label.TextStyle = fyne.TextStyle{Monospace: true}
+ label.SizeName = theme.SizeNameCaptionText
+ label.Wrapping = fyne.TextWrapBreak
+ return label
+}
diff --git a/internal/gui/parts/field.go b/internal/gui/parts/field.go
index 59c3ad6..0c7f712 100644
--- a/internal/gui/parts/field.go
+++ b/internal/gui/parts/field.go
@@ -139,6 +139,67 @@ func Note(content string) fyne.CanvasObject {
return label
}
+// ShortcutHint is the key that works on the button beside it.
+//
+// The shortcuts went in on 2026-08-25 and nothing on the screen has ever said
+// they exist - Ctrl+Enter runs, Ctrl+P previews, Escape stops. A menu bar would
+// have listed them and was turned down by the owner that day, because it would
+// have changed the look of the window right after an audit of the look, so the
+// window kept a feature only its author knew about.
+//
+// Beside the button rather than inside it. A key name set into a button's own
+// label makes the button wider than its word, and these three sit in a row
+// where width is the thing being spent.
+//
+// The same rank as a note under a field - quiet, small - because that is what
+// it is: something the eye should skip until it wants it. It is not wrapped,
+// since a key name that wrapped would be worse than one that was missing.
+func ShortcutHint(key string) fyne.CanvasObject {
+ label := widget.NewLabel(key)
+ label.Importance = widget.LowImportance
+ label.SizeName = theme.SizeNameCaptionText
+ return label
+}
+
+// ShortcutButton is a button and the key that presses it, kept together.
+//
+// It exists because of a ceiling rather than because of a design. Cancel is
+// hidden while nothing is running, so its key name has to be hidden with it -
+// and holding the two as separate fields put the runner at 23 fields against a
+// ceiling of 22. That ceiling is a ratchet and the rule with it is to move
+// state out rather than raise the number, so the pair became one thing.
+//
+// It turned out to be the better shape anyway. A button and the key that
+// presses it are one control in every way that matters: they appear together,
+// they go away together, and anywhere else in this window they would have to
+// be remembered to be kept in step by hand.
+type ShortcutButton struct {
+ *widget.Button
+ hint fyne.CanvasObject
+}
+
+// NewShortcutButton pairs a button with the key that presses it.
+func NewShortcutButton(button *widget.Button, key string) *ShortcutButton {
+ return &ShortcutButton{Button: button, hint: ShortcutHint(key)}
+}
+
+// Objects are the two things to put in a row, in reading order.
+func (s *ShortcutButton) Objects() []fyne.CanvasObject {
+ return []fyne.CanvasObject{s.Button, s.hint}
+}
+
+// Show and Hide move the pair together. Anything else about the button - enable,
+// disable, press - is the button's own and reaches it through the embedding.
+func (s *ShortcutButton) Show() {
+ s.Button.Show()
+ s.hint.Show()
+}
+
+func (s *ShortcutButton) Hide() {
+ s.Button.Hide()
+ s.hint.Hide()
+}
+
// ErrorArea is where a refusal is shown, and it is sized for a real one.
//
// G9 is a requirement on the layout rather than on the wording: a refusal in
diff --git a/internal/gui/parts/parts.go b/internal/gui/parts/parts.go
index 9766385..5e524ea 100644
--- a/internal/gui/parts/parts.go
+++ b/internal/gui/parts/parts.go
@@ -44,9 +44,26 @@ func Prose(text string) fyne.CanvasObject {
}
// Heading is the name of one field, above its control.
+//
+// Quieter and smaller than what it names, since 2026-09-08, and that inversion
+// is the single cheapest thing in this rebuild: it changes every screen at once
+// and costs one function.
+//
+// It was bold and the same size as the value in the box under it, so a screen
+// carried twice as much strong text as it had content - the name of a field
+// competing with the answer somebody typed into it. A name is chrome and a
+// value is the thing being read, and now they are drawn that way round. The
+// rank is carried by size AND colour together rather than by weight, because
+// fyne.TextStyle in v2.8.1 offers two weights and a four rank ramp cannot be
+// built out of them - see the theme's Font.
+//
+// Still a plain widget.Label, and still the FIRST thing in its row. Every walk
+// in this project finds a control by finding the label before it, so the type
+// and the position are load bearing in a way the styling is not.
func Heading(text string) fyne.CanvasObject {
label := widget.NewLabel(text)
- label.TextStyle = fyne.TextStyle{Bold: true}
+ label.SizeName = theme.SizeNameCaptionText
+ label.Importance = widget.LowImportance
return label
}
@@ -85,6 +102,280 @@ func Section(title string, content ...fyne.CanvasObject) fyne.CanvasObject {
return container.NewStack(panelSurface(), container.NewPadded(Column(GapField, body...)))
}
+// ReportSection is a panel that tells rather than asks.
+//
+// One per screen, and it holds no boxes at all: what the run will come to,
+// where the files go, how far a run has got and what the engine said when it
+// was turned down. Everything in it is read.
+//
+// A surface of its own rather than the section colour, because it stands beside
+// a panel that is nothing but boxes and the two have to be told apart before
+// they are read. See ColorNamePanelRaised for the measurement and for why the
+// obvious lighter value was rejected.
+func ReportSection(title string, content ...fyne.CanvasObject) fyne.CanvasObject {
+ body := make([]fyne.CanvasObject, 0, len(content)+1)
+ body = append(body, sectionTitle(title))
+ body = append(body, content...)
+ return container.New(reportPanel{},
+ container.NewStack(raisedSurface(), container.NewPadded(Column(GapField, body...))))
+}
+
+// reportPanel marks the region that reports, so that a walk looking for the
+// form's fields can tell it from one.
+//
+// It became necessary the day this panel grew lines of its own. A field is a
+// name beside a control and a reported fact is a name beside a value, which is
+// the same shape - so a guard that finds a field by looking for its name found
+// the report line instead, and answered "the format field is a Label" about a
+// screen whose format field is a menu. Twenty four guards said it at once.
+//
+// The panel already promised this in words: ReportSection "holds no boxes at
+// all". IsReport is that sentence made askable, so the promise is kept by
+// something other than everybody remembering it.
+type reportPanel struct{}
+
+func (reportPanel) MinSize(objects []fyne.CanvasObject) fyne.Size {
+ if len(objects) == 0 {
+ return fyne.Size{}
+ }
+ return objects[0].MinSize()
+}
+
+func (reportPanel) Layout(objects []fyne.CanvasObject, size fyne.Size) {
+ for _, o := range objects {
+ o.Resize(size)
+ o.Move(fyne.NewPos(0, 0))
+ }
+}
+
+// IsReport says whether this is the panel that reports rather than asks.
+//
+// For anything walking a screen in search of the form. What is inside here is
+// read and never typed into, so a walk that treats it as part of the form is
+// reading the answer where it should be reading the question.
+func IsReport(o fyne.CanvasObject) bool {
+ box, ok := o.(*fyne.Container)
+ if !ok {
+ return false
+ }
+ _, is := box.Layout.(reportPanel)
+ return is
+}
+
+// raisedSurface is what a report panel stands on. See panelSurface.
+func raisedSurface() *canvas.Rectangle {
+ rect := canvas.NewRectangle(PaletteColour(ColorNamePanelRaised, theme.VariantDark))
+ rect.CornerRadius = Theme().Size(theme.SizeNameCardRadius)
+ return rect
+}
+
+// ReportWidth is how wide the column beside the form is drawn.
+//
+// Fixed rather than shared out, and that is what gives this window a smallest
+// size at all. A border layout hands its edge slot exactly the width that slot
+// asks for and gives the rest to the middle, so the form absorbs every change
+// in window width and this column never moves - which is the behaviour wanted,
+// since a run's report is the same five lines whatever the window is doing.
+//
+// The consequence is the useful part: the window cannot be made narrower than
+// the form's own smallest width plus this. Fyne has no SetMinSize on a window -
+// checked in the pinned source, v2.8.1 - and takes the limit it hands the
+// window manager from the content's MinSize, so a floor is something a layout
+// produces rather than something anybody declares. That makes it exactly the
+// kind of number O118 is about: nothing states it, so nothing notices when a
+// layout change quietly lowers it. TestTheWindowCannotBeMadeTooNarrowToUse
+// asserts it instead.
+//
+// 400 comes from the longest thing in the column, which is a path. Below about
+// 340 a Windows path under a user directory wraps to four lines and the panel
+// stops being a summary.
+const ReportWidth = 400
+
+// ReportColumn is the pinned column beside the form.
+//
+// Outside the scroll on purpose. What it holds is the answer to the button
+// somebody is about to press, and an answer that scrolls out of sight while
+// they read the form is an answer they have to go looking for. It is also why
+// the action bar stopped carrying these lines: a bar that grows a message
+// grows, and a bar that grows moves the form under it.
+// Every child keeps its own height and the room left under them stays the page
+// behind, which is the owner's decision of 2026-09-08.
+//
+// It said the opposite until that day, and the reasoning is worth keeping
+// because it was the right worry answered the wrong way round. It ran: a form
+// is as tall as its questions, so without something willing to grow, the column
+// ends where its content ends and the rest of the window is blank - therefore
+// give the last child the leftover. What that actually bought was the same
+// blank drawn as a PANEL, with a fill, a radius and a heading on it. Measured
+// on 2026-09-08 at 1120x760: the report panel came out 414 px tall around 28 px
+// of words, so it was 93 per cent empty, and at the size the owner's window
+// remembers it was 1179 px around the same one sentence.
+//
+// A panel is a promise that something is inside it. Empty background promises
+// nothing, so it reads as a margin - and the same emptiness reads as a defect
+// the moment a border is drawn round it. The blank was never the problem worth
+// solving. The answer to a thin report is to give the report something to say,
+// which is what ReportSection carries now.
+// The last child is given the height left over, which About needs and no other
+// screen has: a scroll asks for almost no height of its own - that is what makes
+// it a scroll - so a column handing it its MinSize would draw a few pixels of
+// list and nothing else.
+//
+// It is About's alone since 2026-09-08, when the work screens went back to one
+// column on the owner's decision. There the report is a panel among panels and
+// is as tall as what it says, the way every other panel in this window is.
+func ReportColumn(children ...fyne.CanvasObject) fyne.CanvasObject {
+ return reportColumn(container.New(fillLast{}, children...))
+}
+
+// reportColumn is the width and the margins the column stands in.
+func reportColumn(stack fyne.CanvasObject) fyne.CanvasObject {
+ // The gap on the left is the one between the two columns. The one on the
+ // right is the window's own edge, and they are the same step because a
+ // column that hugged the window frame on one side and stood clear of the
+ // form on the other would read as pushed rather than placed.
+ return container.New(reportWidth{}, Inset(stack, GapColumn, 0, GapColumn, 0))
+}
+
+// fillLast stacks its children a section apart and hands the last one the rest.
+type fillLast struct{}
+
+func (fillLast) MinSize(objects []fyne.CanvasObject) fyne.Size {
+ size := fyne.Size{}
+ for i, o := range objects {
+ child := o.MinSize()
+ size.Width = fyne.Max(size.Width, child.Width)
+ size.Height += child.Height
+ if i > 0 {
+ size.Height += GapSection
+ }
+ }
+ return size
+}
+
+func (fillLast) Layout(objects []fyne.CanvasObject, size fyne.Size) {
+ if len(objects) == 0 {
+ return
+ }
+ y := float32(0)
+ for _, o := range objects[:len(objects)-1] {
+ height := o.MinSize().Height
+ o.Resize(fyne.NewSize(size.Width, height))
+ o.Move(fyne.NewPos(0, y))
+ y += height + GapSection
+ }
+ last := objects[len(objects)-1]
+ // Never below its own minimum. A window short enough to squeeze this would
+ // otherwise draw a panel with its content hanging out of the bottom, and
+ // the height a window will be given is not something this can refuse.
+ height := fyne.Max(size.Height-y, last.MinSize().Height)
+ last.Resize(fyne.NewSize(size.Width, height))
+ last.Move(fyne.NewPos(0, y))
+}
+
+// GapColumn is the space between the form and the column beside it.
+//
+// The same step as the space between two panels. They are the same kind of
+// distance - one group of things has ended and another has begun - and the
+// scale in this package has five steps precisely so that a sixth is not
+// invented for every new relationship.
+const GapColumn = GapSection
+
+// FloorWidth is the narrowest this window is any use at.
+//
+// Two columns, a path in one of them and boxes in the other, and below about
+// this the path in the report column wraps to four lines and the panel stops
+// being a summary. A judgement recorded as one - see
+// docs/GUI-REDESIGN-2026-09-08.md - rather than a threshold from anywhere.
+const FloorWidth = 1000
+
+// AtLeastWide refuses to be laid out narrower than the window needs.
+//
+// It exists because a scroll reports a small minimum however tall or wide its
+// contents are, which is what a scroll is FOR - so with the form inside one,
+// the whole window asked for about 660 px at its smallest and could be dragged
+// there. Measured on 2026-09-08 before this: 832, 659, 820 and 730 px on the
+// four screens, all well under what two columns need.
+//
+// Fyne has no SetMinSize on a window and takes the limit it hands the window
+// manager from the content's MinSize - internal/driver/glfw/window_desktop.go
+// line 297, read in the pinned source. So a floor is something a layout
+// produces, and this is the layout that produces it.
+//
+// It raises the minimum and nothing else. Given more it lays the child out in
+// everything it was given, so a wide window is unaffected.
+func AtLeastWide(o fyne.CanvasObject, width float32) fyne.CanvasObject {
+ return container.New(&atLeastWide{width: width}, o)
+}
+
+type atLeastWide struct{ width float32 }
+
+func (a *atLeastWide) MinSize(objects []fyne.CanvasObject) fyne.Size {
+ size := fyne.Size{}
+ for _, o := range objects {
+ size = size.Max(o.MinSize())
+ }
+ size.Width = fyne.Max(size.Width, a.width)
+ return size
+}
+
+func (a *atLeastWide) Layout(objects []fyne.CanvasObject, size fyne.Size) {
+ for _, o := range objects {
+ o.Resize(size)
+ o.Move(fyne.NewPos(0, 0))
+ }
+}
+
+// Inset puts space around something, on the sides asked for.
+//
+// container.NewPadded exists and pads all four sides by the theme's padding,
+// which is 6 here. The two columns of this window need more than that between
+// them and none at all where they meet the window's own edge, and a layout that
+// can only say one number cannot say that.
+func Inset(o fyne.CanvasObject, left, top, right, bottom float32) fyne.CanvasObject {
+ return container.New(&inset{left: left, top: top, right: right, bottom: bottom}, o)
+}
+
+type inset struct{ left, top, right, bottom float32 }
+
+func (i *inset) MinSize(objects []fyne.CanvasObject) fyne.Size {
+ size := fyne.Size{}
+ for _, o := range objects {
+ size = size.Max(o.MinSize())
+ }
+ return fyne.NewSize(size.Width+i.left+i.right, size.Height+i.top+i.bottom)
+}
+
+func (i *inset) Layout(objects []fyne.CanvasObject, size fyne.Size) {
+ for _, o := range objects {
+ o.Resize(fyne.NewSize(size.Width-i.left-i.right, size.Height-i.top-i.bottom))
+ o.Move(fyne.NewPos(i.left, i.top))
+ }
+}
+
+// reportWidth asks for ReportWidth and lays its child out in whatever it gets.
+//
+// It asks rather than insists. Handed less than it wanted the child still fills
+// the space, because a column that drew itself 400 px wide in a 300 px slot
+// would paint over the form.
+type reportWidth struct{}
+
+func (reportWidth) MinSize(objects []fyne.CanvasObject) fyne.Size {
+ size := fyne.Size{}
+ for _, o := range objects {
+ size = size.Max(o.MinSize())
+ }
+ size.Width = ReportWidth
+ return size
+}
+
+func (reportWidth) Layout(objects []fyne.CanvasObject, size fyne.Size) {
+ for _, o := range objects {
+ o.Resize(size)
+ o.Move(fyne.NewPos(0, 0))
+ }
+}
+
// FieldColumn stacks fields the way a section stacks them, for the boxes a screen
// refills at run time.
//
@@ -225,7 +516,14 @@ func Divider() fyne.CanvasObject {
type dividerLayout struct{}
func (dividerLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
- return fyne.NewSize(1+theme.Padding()*4, 0)
+ // Widened on 2026-09-08, and the guard behind it is what asked. The run
+ // buttons gained the key that presses each of them, so the gap between two
+ // controls that DO belong together went from about 12 px to about 60 - and
+ // this room stayed where it was, which left the two least related buttons
+ // in the window drawn tighter than a pair. The rule was never a number: it
+ // is that separating has to be wider than belonging together, so when the
+ // one moves this moves with it.
+ return fyne.NewSize(1+theme.Padding()*10, 0)
}
func (dividerLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
@@ -258,22 +556,30 @@ func (dividerLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
// with the form bought nothing and spent 78 px of margin saying so. Pass nil
// on a screen that has none.
func ActionBar(rail fyne.CanvasObject, content ...fyne.CanvasObject) fyne.CanvasObject {
- // The padding goes inside the column as well as around the bar, and that is
+ // The padding goes inside the row as well as around the bar, and that is
// what puts the bar's own words on the same left edge as the form's.
//
- // Measured on 2026-08-20: the outer padding here is cancelled by the
- // centring. A section is 820 px wide with its padding INSIDE it, so its
- // fields start 6 px in from its edge - while this column is centred within
- // what the padding left over, which puts it back at the section's edge
- // rather than at its content. The status line and every field name on the
- // screen above it were 6 px apart, which is the distance that reads as a
- // mistake rather than as an indent.
- column := container.New(readableWidth{}, Indented(container.NewVBox(content...)))
+ // Full width since 2026-09-08, where it was capped at ColumnWidth and
+ // centred before. That cap was the reason the run buttons never reached the
+ // edge they were moved to: actions() pushes them with a greedy spacer, and
+ // a spacer only ever reaches the end of the thing it is IN. Measured at
+ // 1120 px on 2026-09-08, before this changed - the last of them ended at
+ // 964, which is 156 px short of the window and reads as a group that
+ // drifted rather than one that was placed.
+ //
+ // It also stopped being a choice the moment readableWidth stopped centring.
+ // Left aligned at 820 the same group would have ended at 832, so the bar
+ // had to say what it wanted rather than borrow the form's answer. What the
+ // form and the bar share is the left edge, and Indented is the whole of it.
+ column := Indented(container.NewVBox(content...))
standing := fyne.CanvasObject(column)
if rail != nil {
- // Laid over the column rather than beside it. Sharing the row, the rail
- // would take width from one side only and the buttons the column
- // centres would sit off centre by half of it.
+ // Laid over the row rather than in it. Sharing the row would work now
+ // that the buttons are pushed to the far edge, and it did NOT while
+ // they were centred - a rail taking width from one side moved them by
+ // half of it. Kept overlaid because the rail's job is to sit at the
+ // left edge whatever the row does, and an overlay says that in one
+ // line rather than depending on how the row happens to lay out.
//
// The vertical box is what keeps the rail one row tall. Handed straight
// to a stack it would be resized to the whole bar, and a Donate button
@@ -429,14 +735,21 @@ func (f fixedWidth) Layout(objects []fyne.CanvasObject, size fyne.Size) {
}
// readableWidth gives its one child the lesser of the space offered and
-// ColumnWidth, centred. A VBox stretches its children to whatever it is
-// given, which is the whole window, and that is the entire defect.
-//
-// Centred rather than left aligned, changed on 2026-08-12. Held at the left it
-// traded one kind of waste for another: at 1100 px the form ended at 822 and
-// left 278 px of nothing down the right hand side, and maximised it left three
-// thousand. Space split either side reads as a margin, and the same space all
-// on one side reads as a column that failed to fill the window.
+// ColumnWidth, centred. A VBox stretches its children to whatever it is given,
+// which is the whole window, and that is the entire defect.
+//
+// Centred rather than left aligned, decided 2026-08-12 and decided again on
+// 2026-09-08 after a day spent the other way round. Held at the left the column
+// trades one kind of waste for another: it is the ONLY thing on the screen, so
+// at 1100 px it ends at 822 and leaves 278 px of nothing down one side, and
+// maximised it leaves three thousand. Space split either side reads as a
+// margin, and the same space all on one side reads as a column that failed to
+// fill the window.
+//
+// It went left for one day, while a report column was pinned to the right edge
+// and centring in what remained put a margin on the LEFT with nothing to answer
+// it. That layout is gone - the owner asked for one column back - so the reason
+// went with it.
type readableWidth struct{}
func (readableWidth) MinSize(objects []fyne.CanvasObject) fyne.Size {
diff --git a/internal/gui/parts/progress.go b/internal/gui/parts/progress.go
index e495adf..a1fe7cd 100644
--- a/internal/gui/parts/progress.go
+++ b/internal/gui/parts/progress.go
@@ -9,6 +9,36 @@ import (
"fyne.io/fyne/v2/widget"
)
+// RunStatus is the line a run talks on.
+//
+// A type of its own for the reason DetailButton and RequiredMark are: guards
+// and probes find things in this window by walking the tree, and until
+// 2026-09-08 this one was found by SHAPE - a plain label standing beside a
+// progress track. That held while the pair was alone in the action bar. The
+// moment it moved into a panel with a title, the panel's title was also a plain
+// label beside the same track, and four guards began reading the words "This
+// run" and reporting that a finished run had not gone green.
+//
+// They were not wrong about what they saw. Recognising a thing by its place
+// among its neighbours breaks the first time somebody puts a neighbour next to
+// it, and this project has that lesson written down twice already. A name
+// cannot be stood next to.
+type RunStatus struct {
+ widget.Label
+}
+
+// NewRunStatus makes the line, hidden, with nothing on it.
+//
+// Hidden rather than empty, because a hidden widget costs no height here - so a
+// screen at rest is not carrying a blank row waiting to be spoken into.
+func NewRunStatus() *RunStatus {
+ s := &RunStatus{}
+ s.ExtendBaseWidget(s)
+ s.Wrapping = fyne.TextWrapWord
+ s.Hide()
+ return s
+}
+
// Progress is how far along a run is, drawn as a groove that fills.
//
// A control of our own rather than the toolkit's, and it is the track that
diff --git a/internal/gui/parts/required.go b/internal/gui/parts/required.go
index 80f6526..67b2cf2 100644
--- a/internal/gui/parts/required.go
+++ b/internal/gui/parts/required.go
@@ -36,13 +36,18 @@ func newRequiredMark() *RequiredMark {
m := &RequiredMark{}
m.ExtendBaseWidget(m)
m.Text = text.RequiredMark
- // Bold, because it stands beside a bold name and a light star next to heavy
- // words reads as a smudge rather than as a mark.
- m.TextStyle = fyne.TextStyle{Bold: true}
- // The palette's error colour, asked for by role rather than by value. The
- // same name ErrorArea uses, so a field's mark and a field's refusal cannot
- // come apart.
- m.Importance = widget.DangerImportance
+ m.SizeName = theme.SizeNameCaptionText
+ // Quiet rather than red, since 2026-09-08, and this is a correction with a
+ // count behind it: five of these stood on the generate screen at rest, in
+ // the SAME red a refusal uses, so the screen showed error colour before
+ // anything was wrong. A reader who has learnt that red means "this one is
+ // broken" has to unlearn it on every fresh screen.
+ //
+ // What made the swap safe is that the meaning was never in the colour. UX1
+ // says colour is never the only carrier, so the mark was already a shape -
+ // a star that the fields beside it do not have - and the shape is untouched.
+ // The red now appears only where something is actually refused.
+ m.Importance = widget.LowImportance
return m
}
@@ -65,9 +70,20 @@ func headingRow(label string, detail Detail, required bool, trailing fyne.Canvas
if detail.Text != "" && detail.on != nil {
row = append(row, newDetailButton(detail))
}
- // Last, and laid out against the far edge rather than after the button -
- // see headingLine. It belongs to the box below rather than to the name, so
- // crowding it against the name would make it read as part of the name.
+ // Last on the line, following the button.
+ //
+ // It was pushed to the FAR edge of the column until 2026-09-08, on the
+ // reasoning that a count describing the box underneath would read as part of
+ // the label if it stood next to it, and that the empty half of the column
+ // was there to be used. That was true of a column 820 px wide. It stopped
+ // being true when the form moved into a column of about 660 with two fields
+ // to a row: measured off the stored screen, "1 B" sat 215 px from the box it
+ // describes, which is the same defect this project had already fixed once
+ // when a menu's arrow stood 746 px from its value.
+ //
+ // What keeps it from reading as part of the name now is not distance but
+ // shape - it is set in the monospace face, so it reads as a measurement
+ // beside words rather than as more words. See ByteCount.
if trailing != nil {
row = append(row, trailing)
}
@@ -149,17 +165,6 @@ func (h headingLine) Layout(objects []fyne.CanvasObject, size fyne.Size) {
// with no star laid out to the pixel as it was.
o.Resize(fyne.NewSize(width, size.Height))
- // A count of bytes is pushed to the far edge instead of following the
- // name. It describes the box underneath rather than the words beside
- // it, and the whole point of putting it on this line was the empty half
- // of the column - laid out next to the name it would sit in the middle
- // of nothing, and read as part of the label.
- if _, trailing := o.(*ByteCount); trailing {
- o.Resize(fyne.NewSize(size.Width-x, size.Height))
- o.Move(fyne.NewPos(x, 0))
- continue
- }
-
if !first {
x += gapBefore(o)
}
diff --git a/internal/gui/parts/ring.go b/internal/gui/parts/ring.go
index 77b49da..1e942ec 100644
--- a/internal/gui/parts/ring.go
+++ b/internal/gui/parts/ring.go
@@ -110,6 +110,16 @@ func WithRing(control fyne.CanvasObject) (fyne.CanvasObject, *Ring) {
// kept in step. Everything else keeps whatever the toolkit does for it - a box
// to type in already draws its own border in the primary colour.
func wireRing(control fyne.CanvasObject, ring *Ring) {
+ // Through a theme wrapper first, because it says nothing about what the
+ // control IS. Menu puts one round a chooser to take its fill off, and the
+ // first version of that left this looking at the wrapper - so the menu was
+ // never handed its ring, drew no edge at rest, and came out as a word with
+ // an arrow beside it and no boundary at all. Nothing went red: a ring
+ // nobody wired has no state to draw and so has nothing to be wrong about.
+ if over, wrapped := control.(*container.ThemeOverride); wrapped {
+ wireRing(over.Content, ring)
+ return
+ }
if r, ok := control.(ringed); ok {
r.useRing(ring)
}
@@ -241,7 +251,38 @@ func NewChooser(options []string, changed func(string)) *Chooser {
//
// So a menu is now as wide as its longest value plus its arrow, which is the
// sentence ShapedFor has carried since 2026-08-20 without anything doing it.
-func Menu(c *Chooser) fyne.CanvasObject { return Sized(menuWidth(c), c) }
+func Menu(c *Chooser) fyne.CanvasObject {
+ return Sized(menuWidth(c), container.NewThemeOverride(c, unfilled{Theme()}))
+}
+
+// unfilled takes the fill off a control, leaving its edge.
+//
+// The owner's decision B of 2026-09-08. A menu and a box to type in shared
+// ColorNameInputBackground to the byte and the same corner radius, so the only
+// things telling them apart were an arrow and a width - and the toolkit gives
+// no other way to say it, because widget.Select reads that one colour name
+// (fyne v2.8.0 widget/select.go line 437, read in the pinned module). A theme
+// for the subtree is the mechanism, the same one QuietUnlessChosen uses.
+//
+// Shape rather than shade, which is UX1: a control you type in has a fill and a
+// control you press has an outline, and that difference survives somebody who
+// cannot separate two greys 1.2 apart.
+//
+// Measured before it went in, because the edge is now the whole of what marks
+// this control: #55555C on the panel #2A2A2D is 1.93:1. Low, and it is what the
+// edge has always been - the fill it replaces was 1.23:1 against the same
+// panel, so nothing on that menu was ever above 2. Raising the edge to the 3:1
+// a boundary is usually held to needs about #7A7A82, which would make every
+// menu louder than every box beside it. Left as decided and written down here
+// rather than changed quietly.
+type unfilled struct{ fyne.Theme }
+
+func (u unfilled) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
+ if name == theme.ColorNameInputBackground {
+ return color.Transparent
+ }
+ return u.Theme.Color(name, variant)
+}
// menuWidth is the room a chooser needs to show any of its values.
//
diff --git a/internal/gui/parts/theme.go b/internal/gui/parts/theme.go
index 038544b..bca78c0 100644
--- a/internal/gui/parts/theme.go
+++ b/internal/gui/parts/theme.go
@@ -6,6 +6,8 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/typeface"
)
// The palette, computed before the first widget existed and wired on
@@ -31,6 +33,29 @@ import (
// colours at call sites.
const ColorNamePanel fyne.ThemeColorName = "panel"
+// ColorNamePanelRaised is the surface for a panel that reports rather than asks.
+//
+// One panel on each screen says what the run will come to - how many files, how
+// many bytes, where they go, and what the engine said when it was asked. It
+// holds no boxes at all, and it stands beside one that is nothing but boxes, so
+// the two have to be told apart at a glance rather than by reading them.
+//
+// The value is a measurement and the ordering is the point. In L*: page 11.26,
+// panel 17.17, this 20.08, a box 23.69. It sits between the panel it must
+// differ from and the box it must not be mistaken for, roughly halfway to each,
+// so neither gap collapses. The obvious lighter choice, #34343A at 21.91, was
+// rejected on the second number rather than the first: it is 4.75 clear of the
+// panel but only 1.78 from a box, and a box on the panel NEXT TO IT would then
+// read as the same surface.
+//
+// The light variant mirrors the relationship rather than the number, because
+// there the stack runs the other way - page 100.00, panel 96.21, this 94.12,
+// box 91.72.
+//
+// This is the decoration role from docs/UX.md section 8.1, which carries no
+// threshold, so what is guarded is the ORDER and not a distance.
+const ColorNamePanelRaised fyne.ThemeColorName = "panel-raised"
+
var (
darkColours = map[fyne.ThemeColorName]color.Color{
theme.ColorNameBackground: hex(0x1E, 0x1E, 0x1E),
@@ -208,6 +233,7 @@ var (
// divider is.
theme.ColorNameSeparator: hex(0x45, 0x45, 0x49),
ColorNamePanel: hex(0x2A, 0x2A, 0x2D),
+ ColorNamePanelRaised: hex(0x30, 0x30, 0x36),
// What is written ON one of those colours, which is a different
// question from what they contrast with. Section 8 computed them as
@@ -266,6 +292,7 @@ var (
// palette.
theme.ColorNameSeparator: hex(0xD6, 0xD6, 0xD9),
ColorNamePanel: hex(0xF4, 0xF4, 0xF5),
+ ColorNamePanelRaised: hex(0xEE, 0xEE, 0xEF),
// The other way round here, for the same reason: these are dark enough
// to read on a white page, so what is written on them is white.
@@ -326,11 +353,11 @@ func (o ours) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.C
func (o ours) Size(name fyne.ThemeSizeName) float32 {
switch name {
case theme.SizeNameSubHeadingText:
- return 20 // the screen title
+ return 18 // the screen title
case theme.SizeNameHeadingText:
- return 17 // a section title, drawn by the card
+ return 14 // a section title
case theme.SizeNameCaptionText:
- return 12 // an explanation under a field, at 11 it was hard work
+ return 12 // a field name, and an explanation under a control
case theme.SizeNamePadding:
return 6 // room between things, the toolkit's 4 was tight
case theme.SizeNameInnerPadding:
@@ -353,11 +380,49 @@ func (o ours) Size(name fyne.ThemeSizeName) float32 {
// toolkit's 8 was measured as too loose there as well.
return 6
case theme.SizeNameCardRadius:
- return 8
+ return 10
+ case theme.SizeNameInputRadius:
+ // A box is rounded less than the panel it sits on, so the two edges
+ // read as two things rather than one nested shape. 6 against 10.
+ return 6
}
return o.Theme.Size(name)
}
+// Font is the letters this program is written in. See internal/gui/typeface.
+//
+// Two faces and four styles, which is all fyne.TextStyle offers in v2.8.1 -
+// measured in the pinned source rather than assumed, because a type ramp built
+// on Medium and SemiBold and Bold is a ramp this toolkit cannot address.
+//
+// Monospace and Symbol fall through to the toolkit's own. Symbol is how the
+// toolkit draws the marks inside its controls, and monospace is what every
+// number this window reports is set in - a count of bytes lines up under
+// another count of bytes only if the digits are one width, and Fyne exposes no
+// OpenType features, so tabular figures cannot be switched on in Inter. The
+// face that has them by construction is the one already in the binary.
+//
+// Italic answers with the upright face on purpose. docs/UX.md section 8.5 bans
+// italic outright, citing what it costs a reader with dyslexia, so there is no
+// italic to return and a call that asks for one gets text that can be read.
+func (o ours) Font(style fyne.TextStyle) fyne.Resource {
+ switch {
+ case style.Monospace, style.Symbol:
+ return o.Theme.Font(style)
+ case style.Bold:
+ return interSemiBold
+ }
+ return interRegular
+}
+
+// The faces as toolkit resources, made once. A StaticResource is a name and a
+// slice, and the painter caches by name, so building these per call would ask
+// it to shape the same face under the same name over and over.
+var (
+ interRegular = &fyne.StaticResource{StaticName: "Inter-Regular.ttf", StaticContent: typeface.Regular}
+ interSemiBold = &fyne.StaticResource{StaticName: "Inter-SemiBold.ttf", StaticContent: typeface.SemiBold}
+)
+
// Theme is the look of this tool, for the app to install and for a probe or a
// guard to render with. A picture taken under a different theme is a picture
// of a screen nobody has.
diff --git a/internal/gui/run_cgo.go b/internal/gui/run_cgo.go
index 67b7730..d87b8e6 100644
--- a/internal/gui/run_cgo.go
+++ b/internal/gui/run_cgo.go
@@ -88,10 +88,27 @@ func (d desktop) Remembered() window.Remembered { return stored{fyne.CurrentApp(
// handles a preference key. The keys are here and nowhere else.
type stored struct{ prefs fyne.Preferences }
+// The two size keys carry the layout they were written for, and that suffix is
+// the whole of the "forget it once" the owner asked for on 2026-09-08.
+//
+// A size remembered for the one column window is not a size for this one. The
+// form lost 621 px when Output moved into the report column, so somebody who
+// had dragged the window tall enough for the old form came back to a window
+// that opened at that height around a form less than half of it - and a
+// remembered size beats every default, so a new OpenSize would never have
+// reached them at all.
+//
+// Renamed rather than deleted, which is untouchable rule 7 read literally: this
+// program does not remove things from a file it did not create. The old pair
+// stays in preferences.json, unread, costing two numbers. The next layout that
+// moves this much bumps the suffix again.
+//
+// Both keys move together or neither does. Split, the window would come back
+// with a remembered width and a default height, which is a shape nobody chose.
const (
keyDirectory = "outputDirectory"
- keyWidth = "windowWidth"
- keyHeight = "windowHeight"
+ keyWidth = "windowWidth.2"
+ keyHeight = "windowHeight.2"
)
func (s stored) Directory() string { return s.prefs.String(keyDirectory) }
diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json
index c154a75..d809e89 100644
--- a/internal/gui/text/locale/en.json
+++ b/internal/gui/text/locale/en.json
@@ -111,6 +111,30 @@
"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."
},
+ "FactBatches": {
+ "description": "Shown in the window.",
+ "other": "Batches"
+ },
+ "FactEachFile": {
+ "description": "Shown in the window.",
+ "other": "Each file"
+ },
+ "FactFiles": {
+ "description": "Shown in the window.",
+ "other": "Files"
+ },
+ "FactFormat": {
+ "description": "Shown in the window.",
+ "other": "Format"
+ },
+ "FactFreeOnDisk": {
+ "description": "Shown in the window.",
+ "other": "Free on disk"
+ },
+ "FactTotal": {
+ "description": "Shown in the window.",
+ "other": "Total"
+ },
"FieldBoundary": {
"description": "The name above a box somebody fills in.",
"other": "Limit to test"
@@ -328,6 +352,10 @@
"description": "The heading over a group of fields.",
"other": "Support"
},
+ "SectionThisRun": {
+ "description": "The heading over a group of fields.",
+ "other": "This run"
+ },
"SettingKey": {
"description": "Shown in the window. Carries one value, {{.Key}}, which has to stay spelled exactly that way.",
"other": "Written as {{.Key}} in a recipe."
@@ -336,6 +364,18 @@
"description": "Shown in the window. Carries one value, {{.Format}}, which has to stay spelled exactly that way.",
"other": "Settings for {{.Format}}"
},
+ "ShortcutCancel": {
+ "description": "Shown in the window.",
+ "other": "Esc"
+ },
+ "ShortcutGenerate": {
+ "description": "Shown in the window.",
+ "other": "Ctrl+Enter"
+ },
+ "ShortcutPreview": {
+ "description": "Shown in the window.",
+ "other": "Ctrl+P"
+ },
"SizeWayBoundary": {
"description": "Shown in the window.",
"other": "Around a limit"
diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go
index 97e66e4..fd3ec58 100644
--- a/internal/gui/text/screens.go
+++ b/internal/gui/text/screens.go
@@ -1,8 +1,9 @@
package text
import (
- "strconv"
"strings"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
)
// Headings, one per screen. They name what the screen is for rather than what
@@ -53,10 +54,36 @@ func ButtonChoose() string { return say("ButtonChoose", "Choose...") }
// the field label cannot say.
func SectionConfiguration() string { return say("SectionConfiguration", "File configuration") }
func SectionOutput() string { return say("SectionOutput", "Output") }
-func SectionPreset() string { return say("SectionPreset", "The question") }
-func SectionSettings() string { return say("SectionSettings", "Settings") }
-func SectionLicence() string { return say("SectionLicence", "Licence") }
-func SectionSupport() string { return say("SectionSupport", "Support") }
+
+// SectionThisRun names the panel that reports rather than asks.
+//
+// "This run" rather than "Status" or "Summary". It says which run is being
+// described - the one the settings on the left add up to, not the last one -
+// and that distinction is the whole reason the panel is worth having: the
+// window can now answer "what am I about to do" before anything is pressed.
+func SectionThisRun() string { return say("SectionThisRun", "This run") }
+
+// The names of the lines inside that panel.
+//
+// Each one is the quiet half of a pair whose other half is a number the engine
+// worked out. They are nouns rather than sentences because they stand in a
+// column and are read down it - "Files" beside "1" says what a sentence would
+// have needed eight words for.
+//
+// FactEachFile rather than "Size", which is what the box on the left is called.
+// The two are the same number when every file in a batch is the same size and
+// are not the same question, so giving them one name would have said they were.
+func FactBatches() string { return say("FactBatches", "Batches") }
+func FactFiles() string { return say("FactFiles", "Files") }
+func FactFormat() string { return say("FactFormat", "Format") }
+func FactEachFile() string { return say("FactEachFile", "Each file") }
+func FactTotal() string { return say("FactTotal", "Total") }
+func FactFreeOnDisk() string { return say("FactFreeOnDisk", "Free on disk") }
+
+func SectionPreset() string { return say("SectionPreset", "The question") }
+func SectionSettings() string { return say("SectionSettings", "Settings") }
+func SectionLicence() string { return say("SectionLicence", "Licence") }
+func SectionSupport() string { return say("SectionSupport", "Support") }
// The two headings over what this binary carries that somebody else wrote.
//
@@ -508,8 +535,15 @@ func OneExplanation(line, detail string) string {
// "B" rather than "bytes" because that is what the command line prints - one
// vocabulary for one thing across both surfaces - and it sidesteps the plural
// a number always drags behind it.
+//
+// The counting out moved to core on 2026-09-08, when the digits were grouped.
+// That sentence about one vocabulary across both surfaces was true and was
+// held up by nothing: the command line had its own "%d B" in four places, so
+// grouping here alone would have made one product spell one number two ways.
+// This is now the same function both surfaces call, and it is not translated,
+// because there is no word in it.
func ExactBytes(n int64) string {
- return strconv.FormatInt(n, 10) + " B"
+ return core.ExactBytes(n)
}
// RefusedBeforeWriting is what the foot of the form says when a press was
diff --git a/internal/gui/text/text.go b/internal/gui/text/text.go
index 88db185..1ca2ab7 100644
--- a/internal/gui/text/text.go
+++ b/internal/gui/text/text.go
@@ -52,6 +52,19 @@ func ButtonGenerate() string { return say("ButtonGenerate", "Generate") }
func ButtonOpenFolder() string { return say("ButtonOpenFolder", "Open folder") }
func ButtonCancel() string { return say("ButtonCancel", "Cancel") }
+// The keys that press those buttons without the mouse.
+//
+// They have worked since 2026-08-25 and the window has never said so. A menu
+// bar would have listed them and was turned down the same day, so nothing did.
+//
+// Through here rather than written at the widget for the reason every word is:
+// a literal in internal/gui is what the text guard looks for. They are also
+// genuinely translatable - a keyboard sold in one country does not always carry
+// the same word on the same key, and "Ctrl" is not what every desktop calls it.
+func ShortcutPreview() string { return say("ShortcutPreview", "Ctrl+P") }
+func ShortcutGenerate() string { return say("ShortcutGenerate", "Ctrl+Enter") }
+func ShortcutCancel() string { return say("ShortcutCancel", "Esc") }
+
// files is a count with its noun, in the right number.
//
// The doc above this package has described "file(s)" as a dodge since the day
diff --git a/internal/gui/typeface/Inter-Regular.ttf b/internal/gui/typeface/Inter-Regular.ttf
new file mode 100644
index 0000000..b7aaca8
Binary files /dev/null and b/internal/gui/typeface/Inter-Regular.ttf differ
diff --git a/internal/gui/typeface/Inter-SemiBold.ttf b/internal/gui/typeface/Inter-SemiBold.ttf
new file mode 100644
index 0000000..47f8ab1
Binary files /dev/null and b/internal/gui/typeface/Inter-SemiBold.ttf differ
diff --git a/internal/gui/typeface/LICENCE-Inter.txt b/internal/gui/typeface/LICENCE-Inter.txt
new file mode 100644
index 0000000..9b2ca37
--- /dev/null
+++ b/internal/gui/typeface/LICENCE-Inter.txt
@@ -0,0 +1,92 @@
+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION AND CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/internal/gui/typeface/typeface.go b/internal/gui/typeface/typeface.go
new file mode 100644
index 0000000..6e14967
--- /dev/null
+++ b/internal/gui/typeface/typeface.go
@@ -0,0 +1,63 @@
+// Package typeface carries the letters this program is written in.
+//
+// A package of bytes and nothing else, for the same reason internal/gui/icon is
+// one: everything in internal/gui that touches the toolkit sits behind the cgo
+// build tag, so a guard asking what we actually ship would need a C compiler to
+// see it. Bytes import nothing, so they live here in the open and can be read
+// whatever the build, while the line that turns them into a toolkit resource
+// stays behind the tag.
+//
+// Only the window links this. The command line prints to a terminal and the
+// letters there are the terminal's, so shipping a face inside that binary would
+// be 812 kB nobody can see - and docs/QUALITY.md already has a guard saying the
+// bill of materials must not mix the two binaries.
+//
+// # Which faces, and why only two
+//
+// Inter 4.1, the static Regular and SemiBold, unmodified. fyne.TextStyle in
+// v2.8.1 offers Bold, Italic, Monospace and Symbol and nothing between them -
+// measured, not assumed - so a type ramp cannot be carried by weight. Two faces
+// is what the toolkit can address, and the rest of the difference between a
+// screen title, a section title and a field name is carried by size and colour.
+//
+// SemiBold rather than Bold for the heavy one. At 14 px on a dark ground Bold
+// closes up, and every heavy word in this window is short - a section title or
+// a button.
+//
+// Italic is deliberately absent. docs/UX.md section 8.5 bans it, citing that it
+// costs readers with dyslexia, so the theme answers the italic style with the
+// upright face and there is nothing here to answer it with.
+//
+// # Licence
+//
+// SIL Open Font License 1.1, Copyright (c) 2016 The Inter Project Authors,
+// read from LICENCE-Inter.txt at tag v4.1 rather than from memory, which is
+// what docs/SECURITY.md section 4 asks for. There is no Reserved Font Name, so
+// these files may be shipped as they are. They are shipped as they are: unmodified, un-subset, so
+// nothing here needs renaming and the bytes can be compared against the
+// upstream release.
+//
+// The licence text travels with the release in THIRD-PARTY-NOTICES.md, which is
+// what the OFL asks for, and the file below is the copy this repository keeps.
+package typeface
+
+import _ "embed"
+
+// Regular is the face nearly every word on screen is set in.
+//
+//go:embed Inter-Regular.ttf
+var Regular []byte
+
+// SemiBold is the face for a section title, a screen title and a button.
+//
+//go:embed Inter-SemiBold.ttf
+var SemiBold []byte
+
+// Licence is the OFL text that has to travel with the faces.
+//
+// Embedded rather than left as a file beside the binary, because a file beside
+// the binary is a file somebody unzips without, and then the obligation is
+// unmet by accident. The notices command reads this.
+//
+//go:embed LICENCE-Inter.txt
+var Licence string
diff --git a/internal/gui/window/about.go b/internal/gui/window/about.go
index 9dbc554..c85491f 100644
--- a/internal/gui/window/about.go
+++ b/internal/gui/window/about.go
@@ -18,48 +18,52 @@ import (
)
// OpenSize is what the window opens at. Wide enough for the generate form to
-// hold its fields and for a refusal to wrap at its own line breaks rather than
-// at the frame, which is G9 as a measurement rather than a wish.
+// hold its fields beside the report column, and for a refusal to wrap at its
+// own line breaks rather than at the frame, which is G9 as a measurement
+// rather than a wish.
//
-// Widened on 2026-08-12, decision of the owner. At 720 the form was narrower
-// than the 820 it is allowed, so the column cap did nothing and every screen
-// scrolled from the moment it opened - and a form that has to be scrolled
-// before it can be read is a form nobody sees the shape of.
+// Both numbers are measured by tools/probes/formheight, which prints what each
+// form needs against the room the scroll it sits in actually gets. Read off the
+// laid out window rather than worked out from the window height, and that
+// distinction cost an attempt: subtracting the action bar and forgetting the
+// tab strip above the form said "fits" for a screen whose last field was cut
+// off in the render taken a minute later.
//
-// The height is measured rather than chosen. tools/probes/formheight prints
-// what the generate form needs against the room the scroll it sits in actually
-// gets: 768 px needed, 786 px given at this size, so it clears with 18 to
-// spare.
+// The height is the half worth explaining, because it was WRONG for three
+// weeks in a way nothing could notice. It said 1000 and the reasoning under it
+// was sound for the window it was written against: a one column window whose
+// form carried Output, needed 958 px and got 826, so 1000 took a 232 px
+// shortfall down to 132. Every word of that was true on 2026-08-19.
//
-// The room is read off the laid out window rather than worked out from the
-// window height, and that distinction cost an attempt. Subtracting the action
-// bar and forgetting the tab strip above the form said "fits" for a screen
-// whose last field was cut off in the render taken a minute later.
+// Then the two column layout moved Output out of the form and the form lost
+// 621 px, and nobody measured again. Measured on 2026-08-08 at 1000x1000:
+// Single batch needs 337 px of form and the scroll is handed 900, so the window
+// opened with 563 px of nothing in it, every time, before anybody clicked
+// anything. Presets 594, Several batches 494. A height chosen to close a
+// shortfall stayed on after the shortfall turned into a surplus - which is what
+// a measured number does when the thing it measured moves.
//
-// The preset screen still scrolls, at 1019 px on 2026-08-18, and that is not
-// a failure to fix here: it carries a list whose length is the preset's rather
-// than ours, and a window sized for the longest one would be sized for nothing
-// else. The number is reprinted by tools/probes/formheight, so it is worth
-// re-reading rather than trusting - it said 1011 when it was written.
-// Raised from 900 on 2026-08-19, on the owner's decision, because at 900 none
-// of the three forms fitted the room it left them (O102).
+// It was 1120x760 for one afternoon, while the window had two columns. The
+// owner asked for one column back the same day, and one column is a different
+// shape: everything that stood beside the form now stands under it, so the form
+// is taller and needs none of the width. Measured with the probe right after
+// the change - 864 px for Single batch, 1008 for Presets, 847 for Several
+// batches, against the 660 px a 760 tall window hands the scroll.
//
-// 1000 rather than more, and the ceiling is somebody else's screen rather than
-// taste. A window taller than the screen it opens on cannot be reached at the
-// bottom at all, which is worse than one that scrolls, and this toolkit offers
-// no portable way to ask how big the screen is - checked in the driver
-// interface on 2026-08-19, there is none - so the number has to be safe rather
-// than clever. A 1080p screen leaves about 1040 px once the taskbar has taken
-// its share, so 1000 fits it with room to spare and the owner's own screen,
-// measured the same day at 3840x2088 of usable area, is not the constraint.
+// 900 wide is ColumnWidth plus a margin either side. Anything more is margin
+// and nothing else, because the column will not grow past 820 - a line of text
+// wider than that stops being readable, which is the whole reason for the cap.
//
-// It does not make the forms fit and is not meant to: Single batch needs 958 px
-// of form and gets 826 px here. On a 1080p screen it would not fit even
-// maximised, which is a fact about the form rather than about the window. What
-// it does is take the shortfall from 232 px to 132 px, and the destination -
-// the one field whose absence had a named cost - is on the status line now
-// whatever the window is doing.
-var OpenSize = fyne.NewSize(1000, 1000)
+// 1000 tall is the ceiling rather than the fit, and the ceiling is somebody
+// else's screen rather than taste: a window taller than the screen it opens on
+// cannot be reached at the bottom at all, which is worse than one that scrolls,
+// and this toolkit offers no portable way to ask how big the screen is -
+// checked in the driver interface on 2026-08-19, and again in v2.8.1, there is
+// none. A 1080p screen leaves about 1040 px once the taskbar has taken its
+// share. So Single batch and Several batches fit, and Presets still scrolls by
+// about 50 px - it carries a list whose length is the preset's rather than
+// ours, and a window sized for the longest one would be sized for nothing else.
+var OpenSize = fyne.NewSize(900, 1000)
// About is what the licence screen says.
//
@@ -102,9 +106,27 @@ func About(h Host) fyne.CanvasObject {
parts.Section(text.SectionSupport(),
parts.Prose(text.DetailDonate()), parts.Prose(text.SupportURL)),
}
- sections = append(sections, carried()...)
page := parts.Screen(text.HeadingAbout(version.Version), sections...)
+ // What the binary carries moves into the column beside the page, on the
+ // same layout the three work screens use.
+ //
+ // Measured on the stored screen before this changed: three labels of 355,
+ // 622 and 164 px, so 1141 px of unbroken text in a window 1060 px tall,
+ // and the whole of it inside one scroll under the licence. Reading who
+ // wrote what meant scrolling past the licence every time.
+ //
+ // It is the same split as everywhere else, which is the point rather than a
+ // coincidence: what this program IS on the left, and what it carries on the
+ // right. Nothing here is a form, so this is the one screen where the right
+ // hand column holds no boxes at all.
+ // Scrolled, unlike the report panel on the work screens. What stands there
+ // is five lines about one run and is meant to be taken in at a glance. This
+ // is a list of everything the binary carries - thirty odd modules and seven
+ // fonts - and a list that cannot be read to the end is the one kind of
+ // notice that fails at its only job.
+ beside := parts.ReportColumn(container.NewVScroll(parts.Stacked(carried()...)))
+
// The same bar the work screens carry, holding only the Donate button.
//
// This screen starts no run and has nothing else to put there, so the bar is
@@ -119,7 +141,8 @@ func About(h Host) fyne.CanvasObject {
// the list of what the binary carries now, and a licence notice that cannot
// be read to the end is the one kind of notice that fails at its only job.
return container.NewBorder(
- nil, parts.ActionBar(rail(donateButton(h))), nil, nil, container.NewVScroll(page))
+ nil, parts.ActionBar(rail(donateButton(h))), nil, beside,
+ container.NewVScroll(parts.Inset(page, parts.GapColumn, 0, 0, 0)))
}
// carried is what this binary contains that somebody else wrote, read out of
diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go
index b5708f7..6f460ae 100644
--- a/internal/gui/window/generate.go
+++ b/internal/gui/window/generate.go
@@ -185,15 +185,19 @@ func NewGenerate(host Host, links ...fyne.CanvasObject) *Generate {
// The sheet goes over the whole screen rather than inside the scroll,
// because a scroll clips what it draws - an explanation opened near the
// foot of the form would be cut off at the edge of the viewport.
+ // Built in this order and held in locals rather than composed inline,
+ // because every one of these calls REGISTERS its boxes as it builds them
+ // and Go evaluates arguments left to right. Inline, the border's right hand
+ // slot would be built before its middle, so the output boxes would be
+ // registered before the ones they stand beside on the screen - and the walks
+ // that read that list would report a screen nobody sees.
+ form := parts.Screen(text.HeadingGenerate(), g.downTheColumn()...)
g.body = g.tips.Over(container.NewBorder(
nil,
- parts.ActionBar(rail(append([]fyne.CanvasObject{donateButton(host)}, links...)...),
- g.actions(), g.progress(), g.problem.Object()),
- nil, nil,
- (g.keepScroll(container.NewVScroll(parts.Screen(
- text.HeadingGenerate(),
- g.settingsSection()...,
- )))),
+ parts.ActionBar(rail(append([]fyne.CanvasObject{donateButton(host)}, links...)...), g.actions()),
+ nil,
+ nil,
+ g.keepScroll(container.NewVScroll(parts.Inset(form, parts.GapColumn, 0, parts.GapColumn, 0))),
))
// Everything built above belongs to the screen whatever format is chosen.
// What a format declares comes after this mark and is replaced with it.
@@ -207,6 +211,9 @@ func NewGenerate(host Host, links ...fyne.CanvasObject) *Generate {
// Said last, once the box it reads exists.
g.runner.destination = g.OutDir
g.runner.sayDestination()
+ // And what those settings come to, so the panel is not blank on a screen
+ // nobody has typed in yet.
+ g.runner.sayFirst()
// A host that wants to wait for work in flight is told how, here as well
// as in Open. A screen built on its own - which is how most of the
// guards build one - never goes through Open, and would otherwise have
@@ -294,6 +301,42 @@ func entry(text, placeholder string) *parts.Entry {
// apart here against 23 px on the preset screen, which is the same relationship
// drawn two ways. Worse than uneven: 7 px is less than the padding inside a
// panel, so Output read as glued to the panel above it.
+// reportColumn is the pinned column beside the form.
+//
+// It holds the two things that are not questions: where the files land, and
+// what the run has to say. Both used to be somewhere else and both were in the
+// wrong place for the same reason.
+//
+// Output was the second panel of the form. It is not a setting about the files
+// - it is where they go - and it sat under a form that scrolls, so on a screen
+// with a format declaring settings it went off the bottom.
+//
+// The bar and the status line were in the action bar. Measured on 2026-09-08
+// before this changed: the last field on this screen ended 392 px above the
+// bar, so the window carried a third of its height in nothing while the one
+// sentence a person was waiting for was pinned to the floor, as far from the
+// form as it could get. A bar that carries a message also grows when the
+// message arrives, which is what TestTheFormDoesNotMoveWhenARunStarts was
+// written to hold shut - and a bar that cannot grow holds it by construction.
+func (g *Generate) downTheColumn() []fyne.CanvasObject {
+ return append(g.settingsSection(),
+ g.outputSection(),
+ parts.ReportSection(text.SectionThisRun(), g.runPanel()...),
+ )
+}
+
+// outputSection is where the files go, and what marks them.
+func (g *Generate) outputSection() fyne.CanvasObject {
+ add := g.fields.Add
+ return parts.Section(text.SectionOutput(),
+ add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(), g.tips.Say(text.DetailOutputDir()),
+ chooserFor(g.host, g.outDir)),
+ add(engine.SettingSeed, text.FieldSeed(), text.HintSeed(), g.tips.Say(text.DetailSeed()),
+ parts.Numeric(g.seed)),
+ g.fields.AddToggle(engine.SettingLabel, text.FieldLabel(), "", g.tips.Say(text.DetailLabel()), g.label),
+ )
+}
+
func (g *Generate) settingsSection() []fyne.CanvasObject {
// Every field goes through the same call and every one of them can be told
// it was the box that was refused - O73, and since 2026-08-12 without the
@@ -329,15 +372,6 @@ func (g *Generate) settingsSection() []fyne.CanvasObject {
// every format has.
g.propBox,
),
- parts.Section(text.SectionOutput(),
- add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(), g.tips.Say(text.DetailOutputDir()),
- chooserFor(g.host, g.outDir)),
- g.fields.Row(
- add(engine.SettingSeed, text.FieldSeed(), text.HintSeed(), g.tips.Say(text.DetailSeed()),
- parts.Numeric(g.seed)),
- g.fields.AddToggle(engine.SettingLabel, text.FieldLabel(), "", g.tips.Say(text.DetailLabel()), g.label),
- ),
- ),
}
}
diff --git a/internal/gui/window/open.go b/internal/gui/window/open.go
index f1df43f..5ba66a2 100644
--- a/internal/gui/window/open.go
+++ b/internal/gui/window/open.go
@@ -42,11 +42,17 @@ func Open(h Host) {
// hardest for. It also loses its Back button: a tab is its own way out.
// Every screen goes back to the ordinary theme, because the strip they hang
// under is drawn quieter and a theme reaches everything below it.
+ //
+ // Every screen is held to a smallest width, and it is done here rather than
+ // on each screen because a window has ONE smallest size whichever tab is in
+ // front. See parts.AtLeastWide for why the layout has to produce it: this
+ // toolkit takes the limit it gives the window manager from the content, and
+ // a form inside a scroll asks for almost nothing.
tabs := container.NewAppTabs(
- container.NewTabItem(text.TabOneTarget(), parts.AtFullStrength(gen.Object())),
- container.NewTabItem(text.TabPresets(), parts.AtFullStrength(pre.Object())),
- container.NewTabItem(text.TabRecipe(), parts.AtFullStrength(rec.Object())),
- container.NewTabItem(text.TabAbout(), parts.AtFullStrength(About(h))),
+ container.NewTabItem(text.TabOneTarget(), parts.AtLeastWide(parts.AtFullStrength(gen.Object()), parts.FloorWidth)),
+ container.NewTabItem(text.TabPresets(), parts.AtLeastWide(parts.AtFullStrength(pre.Object()), parts.FloorWidth)),
+ container.NewTabItem(text.TabRecipe(), parts.AtLeastWide(parts.AtFullStrength(rec.Object()), parts.FloorWidth)),
+ container.NewTabItem(text.TabAbout(), parts.AtLeastWide(parts.AtFullStrength(About(h)), parts.FloorWidth)),
)
// The output directory follows whoever is looking, and that is a fix for a
diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go
index 7ee7dc2..30107f7 100644
--- a/internal/gui/window/preset.go
+++ b/internal/gui/window/preset.go
@@ -89,26 +89,36 @@ func NewPreset(host Host, links ...fyne.CanvasObject) *Preset {
// The same shape as the other screen, so the window is one interface rather
// than two. What the preset is and what it finds go in the card with the
// chooser, because they are the answer to the question that card asks.
+ // Built into locals in the order the boxes are meant to be registered in.
+ // See the note on the same assembly in generate.go - a border evaluates its
+ // arguments left to right, so composing this inline would register the
+ // output boxes before the ones above them on the screen.
+ form := parts.Screen(
+ text.HeadingPreset(),
+ parts.Section(text.SectionPreset(),
+ p.fields.Add(settingPreset, text.FieldPreset(), text.HintPreset(),
+ p.tips.Say(text.DetailPreset()), p.pick),
+ p.about,
+ ),
+ parts.Section(text.SectionSettings(), p.paramBox),
+ )
+ beneath := []fyne.CanvasObject{
+ parts.Section(text.SectionOutput(),
+ p.fields.Add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(),
+ p.tips.Say(text.DetailOutputDir()), chooserFor(p.host, p.outDir)),
+ p.fields.Add(engine.SettingSeed, text.FieldSeed(), text.HintSeed(),
+ p.tips.Say(text.DetailSeed()), parts.Numeric(p.seed)),
+ ),
+ parts.ReportSection(text.SectionThisRun(), p.runPanel()...),
+ }
p.body = p.tips.Over(container.NewBorder(
nil,
- parts.ActionBar(rail(append([]fyne.CanvasObject{donateButton(host)}, links...)...),
- p.actions(), p.progress(), p.problem.Object()),
- nil, nil,
- (p.keepScroll(container.NewVScroll(parts.Screen(
- text.HeadingPreset(),
- parts.Section(text.SectionPreset(),
- p.fields.Add(settingPreset, text.FieldPreset(), text.HintPreset(),
- p.tips.Say(text.DetailPreset()), p.pick),
- p.about,
- ),
- parts.Section(text.SectionSettings(), p.paramBox),
- parts.Section(text.SectionOutput(),
- p.fields.Add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(),
- p.tips.Say(text.DetailOutputDir()), chooserFor(p.host, p.outDir)),
- p.fields.Add(engine.SettingSeed, text.FieldSeed(), text.HintSeed(),
- p.tips.Say(text.DetailSeed()), parts.Numeric(p.seed)),
- ),
- )))),
+ parts.ActionBar(rail(append([]fyne.CanvasObject{donateButton(host)}, links...)...), p.actions()),
+ nil,
+ nil,
+ p.keepScroll(container.NewVScroll(parts.Inset(
+ parts.Stacked(form, parts.Stacked(beneath...)),
+ parts.GapColumn, 0, parts.GapColumn, 0))),
))
// Everything built above belongs to the screen whatever preset is chosen.
@@ -122,6 +132,9 @@ func NewPreset(host Host, links ...fyne.CanvasObject) *Preset {
// Said last, once the box it reads exists.
p.runner.destination = p.OutDir
p.runner.sayDestination()
+ // And what those settings come to, so the panel is not blank on a screen
+ // nobody has typed in yet.
+ p.runner.sayFirst()
// A host that wants to wait for work in flight is told how, here as well
// as in Open. A screen built on its own - which is how most of the
// guards build one - never goes through Open, and would otherwise have
diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go
index 2bba102..43c357f 100644
--- a/internal/gui/window/recipe.go
+++ b/internal/gui/window/recipe.go
@@ -189,12 +189,19 @@ func NewRecipe(host Host, links ...fyne.CanvasObject) *Recipe {
r.addBtn = widget.NewButton(text.ButtonAddBatch(), r.addBatch)
r.runner.alsoDisabled = append(r.runner.alsoDisabled, r.addBtn)
+ // The batches scroll on the left and where they go is pinned on the right.
+ // This screen is the one that most needs it: ten batches is eight screens
+ // of form, and until now the output box and every word the run said were
+ // below all of them.
+ form := parts.Screen(text.HeadingRecipe(), r.batchBox, r.outBox,
+ parts.ReportSection(text.SectionThisRun(), r.runPanel()...))
r.body = r.tips.Over(container.NewBorder(
nil,
parts.ActionBar(rail(append([]fyne.CanvasObject{donateButton(host), parts.Divider(), r.addBtn}, links...)...),
- r.actions(), r.progress(), r.problem.Object()),
- nil, nil,
- (r.keepScroll(container.NewVScroll(parts.Screen(text.HeadingRecipe(), r.batchBox, r.outBox)))),
+ r.actions()),
+ nil,
+ nil,
+ r.keepScroll(container.NewVScroll(parts.Inset(form, parts.GapColumn, 0, parts.GapColumn, 0))),
))
// The format of the first batch has to be chosen for its declared settings
@@ -206,6 +213,9 @@ func NewRecipe(host Host, links ...fyne.CanvasObject) *Recipe {
// Said last, once the box it reads exists.
r.runner.destination = r.OutDir
r.runner.sayDestination()
+ // And what those settings come to, so the panel is not blank on a screen
+ // nobody has typed in yet.
+ r.runner.sayFirst()
// A host that wants to wait for work in flight is told how, here as well
// as in Open. A screen built on its own - which is how most of the
// guards build one - never goes through Open, and would otherwise have
diff --git a/internal/gui/window/run.go b/internal/gui/window/run.go
index f66f6c6..425a552 100644
--- a/internal/gui/window/run.go
+++ b/internal/gui/window/run.go
@@ -79,7 +79,11 @@ type runner struct {
previewBtn *widget.Button
generateBtn *widget.Button
- cancelBtn *widget.Button
+ // cancelBtn carries the key that presses it, because the two are hidden and
+ // shown together - see parts.ShortcutButton. Preview and Generate are on the
+ // bar whatever is happening, so their hints are built in place and left
+ // alone, and only this one has to be kept in step.
+ cancelBtn *parts.ShortcutButton
// openBtn shows the directory a finished run wrote into.
//
// It appears when there is something to open and goes away the moment the
@@ -97,8 +101,12 @@ type runner struct {
wroteInto string
bar *parts.Progress
- status *widget.Label
+ status *parts.RunStatus
problem *parts.ErrorArea
+ // facts is what the panel says about the run the form adds up to, kept
+ // here rather than on each screen because all three screens report the
+ // same six things and a copy per screen is three copies to keep in step.
+ facts *runFacts
// fields is every box on the screen, by the setting the engine names it by.
// A refusal that says which setting it is about lands under that box
@@ -408,10 +416,15 @@ func (r *runner) recheck(setting string) {
// the parts of it this cannot see, because a format minimum and a name
// already taken are the engine's answers rather than settle's.
r.fields.Clear(setting)
+ // Settled before the blank check rather than after it, so that the panel
+ // beside the form answers for an empty box too - by going quiet. It used to
+ // return here, which left the last good numbers standing beside a form that
+ // no longer describes them.
+ targets, _, err := r.settle()
+ r.showTheRun(targets, err)
if r.fields.Blank(setting) {
return
}
- _, _, err := r.settle()
for _, one := range spread(err) {
var about interface{ AboutSetting() string }
if errors.As(one, &about) && r.placeOf(about.AboutSetting()) == setting {
@@ -421,6 +434,46 @@ func (r *runner) recheck(setting string) {
}
}
+// runPanel is everything the panel beside the form holds, in reading order:
+// what the run comes to, then what it has to say about itself.
+//
+// The facts come first because they are true before anything is pressed, and
+// the run's own line last because for most of a window's life there is nothing
+// on it. A panel that led with an empty line would open by saying nothing.
+func (r *runner) runPanel() []fyne.CanvasObject {
+ return append(r.facts.objects(), r.progress(), r.problem.Object())
+}
+
+// sayFirst fills the panel before anybody has typed.
+//
+// Without it the window opens with a panel that answers nothing until a
+// keystroke, which is the state this whole change is about - the screen already
+// holds a format, a count and a size, so it can already say what they come to.
+func (r *runner) sayFirst() {
+ if r.settle == nil {
+ return
+ }
+ targets, _, err := r.settle()
+ r.showTheRun(targets, err)
+}
+
+// showTheRun puts what the form adds up to into the panel beside it.
+//
+// Silence on a refusal, and that is the point rather than a shortcut. A form
+// that will not settle does not describe a run, so numbers left standing beside
+// it would be describing one nobody asked for - the same rule ByteCount follows
+// for a box holding something that is not a size.
+func (r *runner) showTheRun(targets []engine.Target, err error) {
+ if r.facts == nil {
+ return
+ }
+ if err != nil {
+ r.facts.Nothing()
+ return
+ }
+ r.facts.From(targets)
+}
+
// clearProblems empties every place a refusal can appear, not just the last one
// used. Clearing only the foot of the form would leave a message under a field
// after the value that caused it was fixed.
@@ -554,11 +607,12 @@ func newRunner() *runner {
// screen twice.
r.bar.Hide()
- r.status = widget.NewLabel("")
- r.status.Wrapping = fyne.TextWrapWord
// Nothing to say yet, so nothing takes up room. See say.
- r.status.Hide()
+ r.status = parts.NewRunStatus()
r.problem = parts.NewErrorArea()
+ // Every line off the screen until the form settles into something. See
+ // runFacts, and showTheRun for when it is asked.
+ r.facts = newRunFacts()
r.previewBtn = widget.NewButton(text.ButtonPreview(), r.onPreview)
r.generateBtn = widget.NewButton(text.ButtonGenerate(), r.onGenerate)
@@ -578,7 +632,8 @@ func newRunner() *runner {
// button. The rank it needs is "as pressable as Preview and not competing
// with Generate", and Generate is disabled while this one is showing
// anyway.
- r.cancelBtn = widget.NewButton(text.ButtonCancel(), r.onCancel)
+ r.cancelBtn = parts.NewShortcutButton(
+ widget.NewButton(text.ButtonCancel(), r.onCancel), text.ShortcutCancel())
r.cancelBtn.Disable()
r.cancelBtn.Hide()
@@ -687,6 +742,11 @@ func (r *runner) previewFinished(res *engine.Result, planned []engine.PlannedFil
r.refuse(runErr)
return
}
+ // The two numbers only a plan can give. Asked of the engine and of the
+ // disk, not worked out here - engine.TotalBytes adds the plan up, and a
+ // disk that will not answer says nothing rather than a guess.
+ free, err := core.AvailableBytes(opt.OutDir)
+ r.facts.Previewed(engine.TotalBytes(planned), free, err == nil)
r.say(append([]string{previewText(planned, opt.OutDir)}, manifestReachNote(res)...)...)
}
diff --git a/internal/gui/window/runactions.go b/internal/gui/window/runactions.go
index cef16cc..787ca29 100644
--- a/internal/gui/window/runactions.go
+++ b/internal/gui/window/runactions.go
@@ -7,6 +7,8 @@ import (
"fyne.io/fyne/v2/widget"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
)
// The buttons that start, stop and follow a run.
@@ -47,7 +49,10 @@ func (r *runner) PressGenerate() { pressIfLive(r.generateBtn) }
func (r *runner) PressPreview() { pressIfLive(r.previewBtn) }
-func (r *runner) PressCancel() { pressIfLive(r.cancelBtn) }
+// Through the button inside, because what pressIfLive asks about is the state
+// of the control somebody could click - the key name beside it has no state of
+// its own to be wrong about.
+func (r *runner) PressCancel() { pressIfLive(r.cancelBtn.Button) }
// pressIfLive presses a button somebody could have pressed.
//
@@ -62,21 +67,32 @@ func pressIfLive(b *widget.Button) {
}
func (r *runner) actions() fyne.CanvasObject {
- // Centred, on the owner's decision of 2026-08-19, which reverses the one of
- // 2026-08-18 that put them at the right edge. Both were reports from
- // looking at the built window, and the reasoning for the first is kept
- // above rather than deleted because it was not wrong - it was a choice, and
- // this is a different one.
+ // At the right edge, on the owner's decision of 2026-09-08, which reverses
+ // the one of 2026-08-19 and restores the one of 2026-08-18. All three were
+ // reports from looking at the built window and none was wrong - they were
+ // choices, and this is the current one. What decided it this time was the
+ // two column body arriving: the middle of a bar has no relationship to the
+ // column somebody just finished typing in, and the right edge is the end of
+ // the reading path on every desktop this ships to.
//
- // A spacer at each end rather than one, because a single greedy spacer only
- // pushes: it can put the group at one end or the other and never in the
- // middle.
+ // The comment above this function has said "RIGHT edge" since 2026-08-18
+ // and was false for three weeks, because the reversal changed the code and
+ // left the prose. Nothing guards prose. It is true again now.
+ //
+ // One greedy spacer rather than two. A spacer pushes, so one in front puts
+ // the whole group at the far end - which is the thing wanted here, and the
+ // pair was only ever needed to hold the group in the middle.
//
// Everything that is not one of these buttons went to the bar's rail on
// 2026-08-19 - see parts.ActionBar. It used to be laid over this row, which
// kept it inside the form's column and so a margin away from the edge.
- return container.NewHBox(
- layout.NewSpacer(), r.previewBtn, r.generateBtn, r.cancelBtn, r.openBtn, layout.NewSpacer())
+ row := []fyne.CanvasObject{
+ layout.NewSpacer(),
+ r.previewBtn, parts.ShortcutHint(text.ShortcutPreview()),
+ r.generateBtn, parts.ShortcutHint(text.ShortcutGenerate()),
+ }
+ row = append(row, r.cancelBtn.Objects()...)
+ return container.NewHBox(append(row, r.openBtn)...)
}
// offerTheFolder shows the way to the files, once there are some.
diff --git a/internal/gui/window/runfacts.go b/internal/gui/window/runfacts.go
new file mode 100644
index 0000000..b1ea859
--- /dev/null
+++ b/internal/gui/window/runfacts.go
@@ -0,0 +1,188 @@
+package window
+
+import (
+ "fyne.io/fyne/v2"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
+ "github.com/donislawdev/TestingFilesGenerator/internal/engine"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
+)
+
+// runFacts is what the panel beside the form says about the run the settings
+// add up to.
+//
+// It exists because that panel had nothing in it. Measured on 2026-09-08: at
+// 1120x760 it drew 414 px of panel around one sentence, and the sentence only
+// named the output directory. The window could not answer "what am I about to
+// do" until somebody pressed a button, so the answer to G6 - say what a run
+// costs before it starts - was behind a press that a person may never make.
+//
+// Every number here is READ rather than worked out, which is G1. A count is the
+// length of the list of sizes the engine built, a format is the one the target
+// carries, and a size is what core.ParseSize already turned the box into. The
+// two that cannot be had without planning - the total, and the room left on the
+// disk - arrive from a preview and are absent until one happens. That is the
+// owner's decision of 2026-09-08, taken against the alternative of planning on
+// every keystroke, which at 25 000 files is a plan per keypress.
+type runFacts struct {
+ batches *parts.Fact
+ files *parts.Fact
+ format *parts.Fact
+ each *parts.SizeFact
+ total *parts.SizeFact
+ free *parts.SizeFact
+ // rule is the hairline under the group. It travels with the lines rather
+ // than standing in the panel on its own, because a rule above nothing is a
+ // rule separating one thing from itself.
+ rule fyne.CanvasObject
+}
+
+// newRunFacts builds the lines, all of them off the screen until there is
+// something to put on them.
+func newRunFacts() *runFacts {
+ return &runFacts{
+ batches: parts.NewFact(text.FactBatches()),
+ files: parts.NewFact(text.FactFiles()),
+ format: parts.NewFact(text.FactFormat()),
+ each: parts.NewSizeFact(text.FactEachFile()),
+ total: parts.NewSizeFact(text.FactTotal()),
+ free: parts.NewSizeFact(text.FactFreeOnDisk()),
+ rule: hiddenRule(),
+ }
+}
+
+// hiddenRule is the separator, off the screen until there is something above it.
+func hiddenRule() fyne.CanvasObject {
+ rule := parts.Rule()
+ rule.Hide()
+ return rule
+}
+
+// objects are the lines in the order they are read, for the panel to hold.
+func (f *runFacts) objects() []fyne.CanvasObject {
+ return []fyne.CanvasObject{
+ f.batches.Object(), f.files.Object(), f.format.Object(),
+ f.each.Object(), f.total.Object(), f.free.Object(), f.rule,
+ }
+}
+
+// From says what a settled set of targets comes to.
+//
+// Called on every keystroke through recheck, so it does no work a plan would
+// do. What it walks is the targets themselves, once.
+func (f *runFacts) From(targets []engine.Target) {
+ if len(targets) == 0 {
+ f.Nothing()
+ return
+ }
+ // Only when there is more than one. A line reading "Batches 1" on a screen
+ // that draws exactly one batch is a line that answers a question nobody
+ // asked, and the panel is short enough to notice.
+ f.rule.Show()
+ f.batches.Say(countOf(len(targets) > 1, len(targets)))
+ f.files.Say(core.Grouped(int64(filesIn(targets))))
+ f.format.Say(oneFormat(targets))
+
+ if size, same := oneSize(targets); same {
+ f.each.Say(size)
+ } else {
+ // Batches of different sizes, a size range, or a container working its
+ // own size out. Each of those has a per file size and none of them has
+ // ONE, so the honest answer is not to draw the line - the same rule
+ // ByteCount follows for a box holding something that is not a size.
+ f.each.Nothing()
+ }
+ // A preview's answers describe the plan that was previewed. The moment a
+ // field changes they describe a run nobody asked for any more, so they go.
+ f.total.Nothing()
+ f.free.Nothing()
+}
+
+// Previewed adds what only a plan can say.
+//
+// Free space is separate from the total because a disk that cannot be measured
+// has to say nothing rather than invent a number - the same distinction
+// PreviewFreeSpace draws in the sentence under these lines.
+func (f *runFacts) Previewed(total int64, free int64, measured bool) {
+ f.total.Say(total)
+ if !measured {
+ f.free.Nothing()
+ return
+ }
+ f.free.Say(free)
+}
+
+// Nothing takes every line off the screen, for a form that does not settle.
+//
+// A form holding a half typed size does not describe a run, so a panel that
+// kept the last numbers would be reporting a run that is no longer the one on
+// the screen. Silence is the only honest state.
+func (f *runFacts) Nothing() {
+ f.rule.Hide()
+ f.batches.Say("")
+ f.files.Say("")
+ f.format.Say("")
+ f.each.Nothing()
+ f.total.Nothing()
+ f.free.Nothing()
+}
+
+// countOf spells a number, or nothing at all when the line is not wanted.
+func countOf(wanted bool, n int) string {
+ if !wanted {
+ return ""
+ }
+ return core.Grouped(int64(n))
+}
+
+// filesIn is how many files these targets describe.
+//
+// The length of a list rather than a sum of counts, because a target carries
+// one size per file - engine.Target.Sizes - so the engine has already said how
+// many there are and this only reads it off.
+func filesIn(targets []engine.Target) int {
+ files := 0
+ for _, t := range targets {
+ files += len(t.Sizes)
+ }
+ return files
+}
+
+// oneFormat is the format these targets share, or nothing when they differ.
+//
+// A panel that named the first batch's format while three batches wrote three
+// kinds of file would be stating something false about two of them.
+func oneFormat(targets []engine.Target) string {
+ shared := targets[0].Format
+ for _, t := range targets[1:] {
+ if t.Format != shared {
+ return ""
+ }
+ }
+ return shared
+}
+
+// oneSize is the size every file in these targets has, and whether they all
+// have one.
+//
+// False for a range and for a container that works its own size out, because
+// both of those carry only a count until planning happens - see
+// engine.Target.SizeIsRange and SizeFromContents. False as well for a boundary
+// set, where three consecutive sizes sit under one id on purpose.
+func oneSize(targets []engine.Target) (int64, bool) {
+ for _, t := range targets {
+ if t.SizeIsRange || t.SizeFromContents || len(t.Sizes) == 0 {
+ return 0, false
+ }
+ }
+ shared := targets[0].Sizes[0]
+ for _, t := range targets {
+ for _, size := range t.Sizes {
+ if size != shared {
+ return 0, false
+ }
+ }
+ }
+ return shared, true
+}
diff --git a/internal/legal/assets.go b/internal/legal/assets.go
index c574f05..d9b694e 100644
--- a/internal/legal/assets.go
+++ b/internal/legal/assets.go
@@ -34,6 +34,15 @@ var assets = []Asset{
Copyright: "(c) 2020 The Inter Project Authors",
Note: "Symbols only. Inter is a trademark of Rasmus Andersson. Neither licence file declares a Reserved Font Name, so the identifier is OFL-1.1 rather than OFL-1.1-RFN - checked in the files, not assumed.",
},
+ {
+ Name: "Inter, text faces",
+ Package: "github.com/donislawdev/TestingFilesGenerator/internal/gui/typeface",
+ Module: "github.com/donislawdev/TestingFilesGenerator",
+ Files: []string{"Inter-Regular.ttf", "Inter-SemiBold.ttf", "LICENCE-Inter.txt"},
+ SPDX: "OFL-1.1",
+ Copyright: "Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)",
+ Note: "The words on every screen, in two weights, embedded by this repository rather than arriving inside another module - the first asset here of which that is true. Static Regular and SemiBold from release v4.1, unmodified and not subset, so the bytes can be compared against the upstream release and no Reserved Font Name question arises. The copyright line was read from LICENCE-Inter.txt at that tag. The licence text travels beside the faces as well as in the notices, because the OFL asks for it and a file somebody unzips without is an obligation unmet by accident.",
+ },
{
Name: "DejaVu Sans Mono for Powerline",
Package: "fyne.io/fyne/v2/theme",
diff --git a/internal/legal/carried.go b/internal/legal/carried.go
index 7be2c76..b2b6cf0 100644
--- a/internal/legal/carried.go
+++ b/internal/legal/carried.go
@@ -180,13 +180,20 @@ func moduleItem(path, version string, reviewed map[string]Module) Item {
return Item{Name: displayName(path), Version: version, SPDX: m.SPDX, Copyright: m.Copyright}
}
-// embeddedItems are the registry entries whose module is in this build. A font
-// travels with the package that embeds it, so the question of whether it ships
-// is the question of whether its module was linked.
+// embeddedItems are the registry entries this build actually links.
+//
+// By module for anything arriving inside somebody else's code: a font travels
+// with the package that embeds it, and that package travels with its module.
+//
+// By PACKAGE as well, since 2026-09-08, for the assets this repository embeds
+// itself. Both binaries link this module and only one of them links the package
+// holding the text faces, so a module sized answer would document the window's
+// font as shipping in the command line - which is exactly what
+// TestTheSBOMKeepsTheTwoBinariesApart exists to catch.
func embeddedItems(linked map[string]bool) []Item {
var items []Item
for _, asset := range assets {
- if !linked[asset.Module] {
+ if !linked[asset.Module] && !linked[asset.Package] {
continue
}
items = append(items, Item{
diff --git a/internal/legal/cmd/sbom/main.go b/internal/legal/cmd/sbom/main.go
index 705d3f8..a4b8e0a 100644
--- a/internal/legal/cmd/sbom/main.go
+++ b/internal/legal/cmd/sbom/main.go
@@ -137,7 +137,7 @@ func linkedOn(target, goos string) (map[string]string, error) {
//nolint:gosec // the command is the go tool and the target is one of the
// two literals in describeBinaries - nothing here comes from outside.
cmd := exec.Command("go", "list", "-deps", "-f",
- "{{if .Module}}{{.Module.Path}}@{{.Module.Version}}{{end}}", target)
+ "{{if .Module}}{{.Module.Path}}@{{.Module.Version}}|{{.ImportPath}}{{end}}", target)
cmd.Env = append(os.Environ(), "CGO_ENABLED=1", "GOOS="+goos)
cmd.Stderr = os.Stderr
out, err := cmd.Output()
@@ -146,11 +146,24 @@ func linkedOn(target, goos string) (map[string]string, error) {
}
versions := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
- path, moduleVersion, found := strings.Cut(strings.TrimSpace(line), "@")
- if !found || strings.Contains(path, "donislawdev") {
+ module, rest, found := strings.Cut(strings.TrimSpace(line), "@")
+ if !found {
continue
}
- versions[path] = moduleVersion
+ moduleVersion, importPath, _ := strings.Cut(rest, "|")
+ if strings.Contains(module, "donislawdev") {
+ // Our own module is not a component and is never listed as one -
+ // carriedBy drops any path the registry does not know. What goes in
+ // is the PACKAGE path, and it goes in because module is too coarse
+ // a question for an asset this repository embeds itself: both
+ // binaries link this module and only one of them links the package
+ // holding the faces. Keyed by module, a font that ships in the
+ // window would be documented as shipping in the command line too,
+ // and there is a guard whose whole job is that not happening.
+ versions[importPath] = ""
+ continue
+ }
+ versions[module] = moduleVersion
}
return versions, nil
}