diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb01485..8b97d914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,110 @@ because it turns other people's test suites red. ## [Unreleased] +### Changed + +- **The window draws its own buttons, switches and choosers.** A button now + has one filled face for the action that does the work and an outline for the + ones beside it, lightens under the pointer, darkens when pressed, and shows a + clear ring in the accent colour when the keyboard is on it - where the + toolkit's own button blended that ring into the fill so it could not be seen + on the filled one, and had no pressed look at all. Enter presses a button as + well as the space bar. The three ways of stating a size are one segmented + switch instead of three circles: one keyboard stop the arrow keys step + through. A tick-box switch stands under its name in the column like every + other field rather than carrying its own words, and a caption under a field + now recedes to the same quiet grey as a hint rather than sitting a shade + brighter. + +- **The window lays every form out as a grid.** A field is one row now: its + name in a column of names, its box beside it, and every box on a screen + starting on the same edge. Until now the name stood over the box, so each + field cost two rows, and the small button that opens a field's explanation + was the tallest thing on the name's line - the name of a box floated 13 px + above the box's middle. Names are regular weight, the one bold thing on a + screen is the title of a section, and the count of bytes a size comes to + stands right after the size box instead of at the far end of the line. + + Underneath it, every distance in the window is one of six steps and every + number about the look lives in one file, so the gaps between things are the + ones somebody chose. The settings a format declares stand one to a row - + two of them no longer share a line - and the three ways of stating a size + on the batch screen keep their switch above one box, in the column of + controls. + +- **The line under the buttons says what the run comes to, before anything + is pressed.** How many files, how many bytes, what kinds and where, on one + line, worked out from the form as it is typed - with no disk read and no + planning. A range says "between" its two ends until Preview draws the + sizes, a container sized by its contents says so, and a form that cannot + be added up yet names only the destination. After Preview the line is exact + and the destination carries the room left on its disk. A change of the + form puts the summary back over whatever a press said, where until now the + line named the destination until the first press and never again. + + The refusal about a run as a whole now scrolls inside the same room as the + run's other messages, so the bar no longer grows when a run is refused, and + choosing from a menu reaches the live check the way typing in a box does. + +- **The tabs across the top stand on the same edge as the screen under them, + and a screen has one name.** The strip is drawn by the tool now rather than + by the toolkit: its words start where the title and every field name start, + the chosen one carries a 2 px mark in the accent colour, and a word lights up + under the pointer. The title of a work screen is the word on its tab - + Single batch, Presets, Several batches - and under it a quiet sentence says + what the screen is for, where until now each screen had one name on the tab + and another over the form. + +- **An open list is as tall as half the window, not eight rows.** The list a + menu drops down stopped at eight rows in every window - 224 px at 800x600 + and at 1100x1300 alike - so a third of the twenty-four formats showed + however tall the window was. It now covers up to half the window's height, + in whole rows: ten rows in a window 600 px tall, eighteen in one 1025 px + tall, and the rest under a scroll. It still opens upward when there is more + room above the box than below it, and is still cut to the room on whichever + side it lands. + +- **The window is set in Inter.** The window was drawn in Noto Sans, the + toolkit's own face. It is set in Inter 4.1 now, Regular and Bold, embedded + in the window binary under the Open Font License - the licence notice and + `tfg-gui`'s About screen name it beside the other bundled work. The window + binary is about 800 kB larger for it. The command line binary carries no + font and is unchanged. + +### Added + +- **Every control shows where the keyboard is and answers the pointer.** Every + place the keyboard can land - a box, a menu, a switch, a button, the + segmented switch, a word on the tab strip - draws the same 2 px ring when a + key put it there and not when a press did, and every control you can click + lights up under the pointer. Buttons gain a pressed look. This closes the + gap where some stops drew a focus mark too faint to see and one drew none at + all. + +- **The tabs can be worked from the keyboard.** Tab reaches each word on the + strip, Enter or Space opens its screen and puts the keyboard on the first + field with its mark showing, and the arrow keys move along the strip without + opening anything. The toolkit's tabs answered the mouse only. + +- **`tfg-gui --catalogue` opens a catalogue of every part of the window in + every state it has.** A hidden screen for anybody changing the window's + look: each button, box, menu, switch, list, tab and rank of text, at rest, + under the pointer, holding the keyboard, refused, disabled and given a + sentence too long for it - sixteen entries and ninety-three states, drawn + at the widths a form gives them. `--catalog` is accepted too. Any other + argument is ignored, as every argument was until now. + ### Fixed +- **A refused preset no longer carries the note of the preset before it.** A + limit of 512 B was refused with "no limit was given" under it - a note left + over from the last expansion that worked. + +- **A preview asks the disk how much room it has from the worker, not from + the interface thread.** The rest of a preview moved off that thread on + 2026-08-26 and this one read stayed behind, so a directory on a slow share + could still stop the window from drawing for as long as the share took. + - **Asking for damaged files and declaring they will be accepted is now refused on the command line too.** A damaged file is one a reader was measured to refuse, so `--expected accept` beside `--damage` asks for something nothing @@ -41,6 +143,11 @@ because it turns other people's test suites red. and the site has a section explaining how to produce a file that is broken on purpose. +- **The window spells a byte count the way the command line does.** The count + under a size box said `10485760 B` while `tfg` said `10 485 760 B` about the + same number - the window had a spelling of its own that the grouping of + digits in 0.3.0 never reached. There is one spelling now. + ## [0.3.0] - 2026-09-09 ### Breaking diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 1f6af9e6..48352e45 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -593,8 +593,11 @@ THE SOFTWARE. The toolkit compiles fonts and drawings into the binary. They are not modules, so a list of modules never mentions them - which is how they shipped unnamed until this section was written on 2026-08-28. Seven fonts and ninety-seven -images arrive this way, and their licences ask for their notices to travel with -them. +images arrive this way from the toolkit, and their licences ask for their +notices to travel with them. Two more font files arrive from a package of this +project itself: Inter, the face the window has been set in since 2026-09-15, +is embedded by `internal/gui/font` rather than by the toolkit, and is listed +here under the same rule. What is listed here was measured by asking the compiler which files each package embeds, not by reading its source. The copyright lines were read out of @@ -603,7 +606,8 @@ the font files themselves. | what | comes from | licence | copyright | |---|---|---|---| | 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 Symbols, the toolkit's symbol glyphs only | `fyne.io/fyne/v2/theme` | OFL-1.1 | (c) 2020 The Inter Project Authors | +| Inter, Regular and Bold, the text of the window - static instances from the v4.1 release of github.com/rsms/inter | `internal/gui/font` in this project | OFL-1.1 | Copyright 2016 The Inter Project Authors | | 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) | @@ -612,8 +616,10 @@ the font files themselves. `tfg`, the command line binary, embeds none of this. It has no toolkit in it. -Neither font licence declares a Reserved Font Name, so the identifier above is -OFL-1.1 rather than OFL-1.1-RFN. Inter is a trademark of Rasmus Andersson. +None of the three fonts under the Open Font License declares a Reserved Font +Name - checked in the licence file of each and in the name table of each font +file - so the identifier above is OFL-1.1 rather than OFL-1.1-RFN. Inter is a +trademark of Rasmus Andersson. **EmojiOne Color says two different things about itself.** The toolkit ships it with an MIT licence naming Adobe Systems Incorporated. The font's own metadata @@ -633,7 +639,7 @@ 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 Inter Symbols and to Inter. ``` ------------------------------ diff --git a/cmd/tfg-gui/main.go b/cmd/tfg-gui/main.go index 3d35a431..5fcf1f82 100644 --- a/cmd/tfg-gui/main.go +++ b/cmd/tfg-gui/main.go @@ -12,5 +12,5 @@ import ( ) func main() { - os.Exit(gui.Run(os.Stderr)) + os.Exit(gui.Run(os.Args[1:], os.Stderr)) } diff --git a/internal/guard/actionbar_test.go b/internal/guard/actionbar_test.go index 7022b368..360d0756 100644 --- a/internal/guard/actionbar_test.go +++ b/internal/guard/actionbar_test.go @@ -4,8 +4,8 @@ import ( "testing" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/widget" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) @@ -68,7 +68,7 @@ func TestTheButtonsThatRunSomethingSitInTheMiddle(t *testing.T) { } // rowHolding is the container one control is directly inside. -func rowHolding(o fyne.CanvasObject, want *widget.Button) *fyne.Container { +func rowHolding(o fyne.CanvasObject, want *parts.Button) *fyne.Container { var found *fyne.Container walk(o, func(obj fyne.CanvasObject) { box, ok := obj.(*fyne.Container) diff --git a/internal/guard/actionbarheight_test.go b/internal/guard/actionbarheight_test.go index c4e5903f..adbb5fa4 100644 --- a/internal/guard/actionbarheight_test.go +++ b/internal/guard/actionbarheight_test.go @@ -55,7 +55,6 @@ func TestTheFormDoesNotMoveWhenARunStarts(t *testing.T) { restingStates := []struct { name string clearTheBox bool - wantResting bool whyItIsWorthA string }{ { @@ -63,11 +62,10 @@ func TestTheFormDoesNotMoveWhenARunStarts(t *testing.T) { whyItIsWorthA: "the ordinary path, where the status line already carries the output folder", }, { - name: "with nothing to say", + name: "with the destination cleared", clearTheBox: true, - wantResting: true, - whyItIsWorthA: "the state the reserve exists for - no destination, so the line is hidden " + - "and costs nothing until a run speaks", + whyItIsWorthA: "the line still says what the form comes to, without a destination - " + + "a different length of line in the same reserve", }, } @@ -94,14 +92,17 @@ func TestTheFormDoesNotMoveWhenARunStarts(t *testing.T) { settle(content, w) } - // Whether the line is actually hidden is asserted rather than - // assumed. If a later change keeps something on it at rest, - // this state stops being the state the reserve is for, and - // this guard has to say so instead of quietly measuring the - // other one twice - which is exactly how it went blind before. - if state.wantResting && status.Visible() { - t.Fatalf("the status line still says %q with no destination, so this guard is not in "+ - "the state it means to check (%s)", status.Text, state.whyItIsWorthA) + // Whether the line is in the state this case names is asserted + // rather than assumed. If a later change keeps naming a + // destination that was cleared, this guard has to say so + // instead of quietly measuring the other case twice - which is + // exactly how it went blind before. A hidden line is a legal + // state here rather than a wrong one: a form that does not + // settle and has no destination has nothing to say, which is + // the batch screen as it opens. + if state.clearTheBox && status.Visible() && strings.Contains(status.Text, text.WillGoTo("")) { + t.Fatalf("the status line still names a destination after the box was cleared: %q (%s)", + status.Text, state.whyItIsWorthA) } atRest := scroll.Size().Height @@ -185,13 +186,11 @@ func TestWhatARunSaysComesBeforeWhatSettlingSaid(t *testing.T) { t.Fatalf("the preview said %q, which is one line - this guard needs a run that also carries "+ "a note, or it is checking the order of a list with one thing in it", status.Text) } - // Matched on the tail of the preview's own sentence rather than on a word - // like "file", because the note talks about files too - an earlier version - // of this checked for that and stayed green with the order reversed, which - // is a guard that reads like one and is not. - marker := text.PreviewCost(1, nil, "1 B") - tail := marker[strings.LastIndex(marker, " ")+1:] - if !strings.Contains(lines[0], tail) { + // Matched on the tail the preview puts on its own line rather than on a + // word like "file", because the note talks about files too - an earlier + // version of this checked for that and stayed green with the order + // reversed, which is a guard that reads like one and is not. + if !strings.HasSuffix(lines[0], text.AndNothingWrittenYet()) { t.Errorf("the first line of the status is %q, and the preview's own sentence is not it.\n"+ "That sentence has to come first, because the room for these messages is a ceiling and "+ "the message scrolls inside it - so the first line is the only one certain to be read. "+ @@ -305,17 +304,23 @@ func runMessages(o fyne.CanvasObject) (*parts.Progress, *widget.Label) { 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 - } + // The track is looked for underneath the child rather than as the + // child itself: 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. The label likewise + // since 2026-09-14, when the line went under the same override + // every other word on the form stands in (parts.Flush). + // + // The first child holding one label and nothing to press or type + // in is the line a run talks on. The pairing has to stay that + // tight, because the box at the root of a screen also holds a + // track somewhere beneath it and a form full of labels beside it. if it := progressUnder(child); it != nil { foundBar = it + continue + } + if foundLabel == nil { + foundLabel = soleLabelUnder(child) } } if foundBar != nil && foundLabel != nil { @@ -325,6 +330,33 @@ func runMessages(o fyne.CanvasObject) (*parts.Progress, *widget.Label) { return bar, status } +// soleLabelUnder is the one label beneath an object that holds nothing else +// a person could act on, or nil when the object is anything more than a line +// of words. +func soleLabelUnder(o fyne.CanvasObject) *widget.Label { + var found *widget.Label + labels, controls := 0, 0 + walk(o, func(obj fyne.CanvasObject) { + switch it := obj.(type) { + case *widget.Label: + labels++ + found = it + case *parts.Progress: + controls++ + default: + // Anything that can be disabled is a control - a button, a box, a + // menu, a switch, and every wrapper of ours round one of those. + if _, can := obj.(fyne.Disableable); can { + controls++ + } + } + }) + if labels != 1 || controls > 0 { + return nil + } + return found +} + // 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 diff --git a/internal/guard/actionbarpadding_test.go b/internal/guard/actionbarpadding_test.go index 910b7e4b..d2bd8087 100644 --- a/internal/guard/actionbarpadding_test.go +++ b/internal/guard/actionbarpadding_test.go @@ -5,7 +5,6 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/test" - "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" @@ -33,7 +32,7 @@ func TestTheActionBarCostsItsContentPlusOneInset(t *testing.T) { inside := widget.NewLabel("what a run has to say") bar := parts.ActionBar(nil, inside) - want := inside.MinSize().Height + theme.Padding()*2 + want := inside.MinSize().Height + parts.InsetBar*2 got := bar.MinSize().Height // Half a pixel, because a rounded corner and a stroke are drawn on the diff --git a/internal/guard/boxwidth_test.go b/internal/guard/boxwidth_test.go index c36b8a0f..a36b29d3 100644 --- a/internal/guard/boxwidth_test.go +++ b/internal/guard/boxwidth_test.go @@ -273,17 +273,24 @@ func TestABoxForANumberIsNotAsWideAsTheFormOnTheBatchScreen(t *testing.T) { // settingLabelOf is what the label above a declared setting reads. func settingLabelOf(p format.Property) string { return text.SettingLabel(p.Name) } -// Two narrow settings share a row, on both screens that draw them. +// A declared setting stands its box on the same edge as every field above +// it, on both screens that draw them. // -// Two boxes for a number stacked one above the other cost a row of height each -// and leave two thirds of the panel empty beside them. Measured on 2026-08-20: -// pairing them took the batch screen from 1259 px to 1173. +// Until 2026-09-14 this guard asked for two narrow settings to SHARE a row, +// because a name standing over its box left two thirds of the panel empty +// beside it and pairing took the batch screen from 1259 px to 1173. A form +// laid out as rows has no such width to fill - a row is as tall as one box - +// and the property that replaces it is GUI rule 13: the box of a setting a +// format declares begins where the box of "Format" begins, however long the +// setting's name. That is what a column of names worked out from EVERY name +// buys, and what a column measured from the fields on the screen would +// break the moment a format with a longer setting name was chosen. // // Both screens, because the width went into one of them first and the other // kept drawing full width boxes until the next commit - and then the pairing // went into one of them first as well. It is the same declaration drawn by two // pieces of code, so it is the same defect waiting twice. -func TestTwoNarrowSettingsShareARowOnEveryScreenThatDrawsThem(t *testing.T) { +func TestADeclaredSettingStandsOnTheSameEdgeAsTheFieldsAboveIt(t *testing.T) { ourTheme(t) content, _ := laidOutWindow(t) @@ -295,27 +302,29 @@ func TestTwoNarrowSettingsShareARowOnEveryScreenThatDrawsThem(t *testing.T) { if !ok { t.Fatal("this screen has no format list, so this guard read the wrong tree") } - // A format declaring two narrow settings and nothing else between - // them, so "same row" is a question this can ask at all. + // A format declaring settings with names of two lengths. picker.SetSelected("bmp") // What a format declares arrives folded away since 2026-08-25, and - // a box that is not on the screen has no position to measure. This - // guard is about how two of them sit beside each other once they - // are, so it opens the section rather than asking about a shape - // nobody is looking at. + // a box that is not on the screen has no position to measure. openFold(t, screen, "", text.SettingsFor("bmp")) - width, ok := labelBox(screen, text.SettingLabel("width")) + format, ok := objectBox(screen, controlUnder(screen, text.FieldFormat())) if !ok { - t.Fatal("bmp declares width and no label on this screen says so") + t.Fatal("the format list is not laid out") } - height, ok := labelBox(screen, text.SettingLabel("height")) - if !ok { - t.Fatal("bmp declares height and no label on this screen says so") - } - if off := width.Y - height.Y; off > 1 || off < -1 { - t.Errorf("width sits at %.0f px and height at %.0f px, so two boxes holding four digits each "+ - "take a row of the form apiece", width.Y, height.Y) + for _, name := range []string{"width", "height"} { + control := controlUnder(screen, text.SettingLabel(name)) + if control == nil { + t.Fatalf("bmp declares %s and no field on this screen holds it", name) + } + box, ok := objectBox(screen, control) + if !ok { + t.Fatalf("the box for %s is not laid out", name) + } + if off := box.X - format.X; off > 1 || off < -1 { + t.Errorf("the box for %s begins at x=%.0f and the format list at x=%.0f, so the settings a "+ + "format declares do not stand in the column of controls", name, box.X, format.X) + } } }) } diff --git a/internal/guard/bytecount_test.go b/internal/guard/bytecount_test.go index 55b8fbc9..0997f5fb 100644 --- a/internal/guard/bytecount_test.go +++ b/internal/guard/bytecount_test.go @@ -5,6 +5,7 @@ import ( "fyne.io/fyne/v2" + "github.com/donislawdev/TestingFilesGenerator/internal/core" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" @@ -33,7 +34,7 @@ func TestABoxHoldingASizeSaysWhatItComesTo(t *testing.T) { } // The size the box opens with, in the units this tool uses. - if want := text.ExactBytes(10 * 1024 * 1024); count.Text != want { + if want := core.ExactBytes(10 * 1024 * 1024); count.Text != want { t.Errorf("the size box holds 10mb and the count beside it reads %q, not %q.\n"+ "Reason: a megabyte is 1024 kilobytes here, and the count is the only place the screen says so", count.Text, want) @@ -43,7 +44,7 @@ func TestABoxHoldingASizeSaysWhatItComesTo(t *testing.T) { // the opening value would be worse than none: it would be a wrong number // beside a right one. fill(t, content, text.FieldSize(), "1kb") - if want := text.ExactBytes(1024); count.Text != want { + if want := core.ExactBytes(1024); count.Text != want { t.Errorf("1kb was typed and the count beside the box reads %q, not %q", count.Text, want) } @@ -129,7 +130,7 @@ func TestADeclaredSizeSaysWhatItComesToOnEveryScreenThatDrawsOne(t *testing.T) { label := text.SettingLabel("entry_size") count := byteCountBeside(t, content, label) fill(t, content, label, "4kb") - if want := text.ExactBytes(4 * 1024); count.Text != want { + if want := core.ExactBytes(4 * 1024); count.Text != want { t.Errorf("%q holds 4kb and the count beside it reads %q, not %q", label, count.Text, want) } }) @@ -149,7 +150,7 @@ func TestADeclaredSizeSaysWhatItComesToOnEveryScreenThatDrawsOne(t *testing.T) { } fill(t, content, label, "2mb") - if want := text.ExactBytes(2 * 1024 * 1024); count.Text != want { + if want := core.ExactBytes(2 * 1024 * 1024); count.Text != want { t.Errorf("%q holds 2mb and the count beside it reads %q, not %q", label, count.Text, want) } }) diff --git a/internal/guard/cataloguecoverage_test.go b/internal/guard/cataloguecoverage_test.go index 1a2d188b..96865b6f 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/controlstates_test.go b/internal/guard/controlstates_test.go new file mode 100644 index 00000000..809b15b7 --- /dev/null +++ b/internal/guard/controlstates_test.go @@ -0,0 +1,213 @@ +package guard + +import ( + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver/desktop" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// The buttons, the switch and the segmented control this window draws itself +// arrived on 2026-09-15, and these guards are for the behaviour that came with +// them - the states the toolkit's own controls either drew wrong or did not +// draw at all. The look of each state is held by the stored screens. What is +// here is the behaviour a picture cannot show. + +// A button draws its focus ring for the keyboard and not for a press. +// +// This is O207 turned into a test. The toolkit blended the focus colour into +// the fill, which on the filled primary button was blue on blue at 1.11 - a +// mark drawn and not seen. Ours is a ring, and it is drawn only when the +// keyboard put the focus here: a press does not focus a button (the driver +// unfocuses on a tap), so a mouse user never lights the ring, and a keyboard +// user always does. +func TestAButtonDrawsItsRingForTheKeyboardNotThePointer(t *testing.T) { + b := parts.NewButton(parts.Primary, "Generate", func() {}) + if b.Marked() { + t.Fatal("a fresh button is already drawing its ring") + } + + b.MouseIn(&desktop.MouseEvent{}) + if b.Marked() { + t.Error("the pointer entered the button and it drew the keyboard ring, which says the keyboard is here when it is not") + } + + b.FocusGained() + if !b.Marked() { + t.Error("the keyboard moved onto the button and nothing on it says so") + } + b.FocusLost() + if b.Marked() { + t.Error("the keyboard left the button and the ring stayed") + } +} + +// A button forgets a press when the pointer leaves it. +// +// The driver sends the release to whatever is under the pointer when the button +// is let go, so pressing a button, sliding off it and releasing never delivers +// MouseUp back - and a button that only cleared the press in MouseUp would +// stay drawn as pressed for the rest of its life. Measured in the toolkit +// source (the Fyne guide, section 3.27). +func TestAButtonForgetsAPressWhenThePointerLeaves(t *testing.T) { + b := parts.NewButton(parts.Secondary, "Preview", func() {}) + b.MouseDown(&desktop.MouseEvent{}) + if !b.Pressed() { + t.Fatal("pressing the button did not draw the pressed face") + } + b.MouseOut() + if b.Pressed() { + t.Error("the pointer left mid press and the button stayed drawn as pressed, with no way back") + } +} + +// A button is pressed by Enter as well as by the space bar. +// +// The toolkit answers the space bar alone, so Enter on a focused button did +// nothing - and silence in place of an answer is the one thing this window +// refuses everywhere else. Both names of the return key, because a keyboard has +// two. +func TestAButtonIsPressedByEnterAsWellAsSpace(t *testing.T) { + for _, key := range []fyne.KeyName{fyne.KeyReturn, fyne.KeyEnter, fyne.KeySpace} { + pressed := false + b := parts.NewButton(parts.Primary, "Generate", func() { pressed = true }) + b.TypedKey(&fyne.KeyEvent{Name: key}) + if !pressed { + t.Errorf("%s did not press the button", key) + } + } + // And a disabled button answers no key, which is the half that keeps a + // shortcut from starting a run the screen has turned off. + pressed := false + b := parts.NewButton(parts.Primary, "Generate", func() { pressed = true }) + b.Disable() + b.TypedKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) + if pressed { + t.Error("Enter pressed a disabled button") + } +} + +// One press of the space bar presses a button ONCE. +// +// The desktop driver delivers a press of space to a focused control twice: +// as the key, ending in TypedKey, and as the character, ending in TypedRune +// (internal/driver/glfw/window.go, processKeyPressed and processCharInput, +// read in the pinned module on 2026-09-16 after an outside review said so). +// The test driver delivers them as two separate calls, which is why a button +// answering both was green here and pressed twice in the window - one press +// of space on Add batch added two batches. The toolkit's own button leaves +// TypedRune empty for this reason. Delivered here the way the driver does it, +// both calls for one press. +func TestOnePressOfSpacePressesAButtonOnce(t *testing.T) { + presses := 0 + b := parts.NewButton(parts.Secondary, "Add batch", func() { presses++ }) + b.TypedKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + b.TypedRune(' ') + if presses != 1 { + t.Errorf("one press of the space bar, delivered as the key and the character the way the "+ + "desktop driver delivers it, pressed the button %d times", presses) + } +} + +// And flips a switch once - the same two deliveries, and a switch that +// answered both flipped twice, back to where it started, which reads as a +// switch that ignores the space bar. Found by reading the switch beside the +// button on 2026-09-16, not by the review that named the button. +func TestOnePressOfSpaceFlipsASwitchOnce(t *testing.T) { + flips := 0 + s := parts.NewToggle(func(bool) { flips++ }) + s.TypedKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + s.TypedRune(' ') + if flips != 1 || !s.Checked { + t.Errorf("one press of the space bar flipped the switch %d times and left it %v", flips, s.Checked) + } +} + +// A frozen control answers no key. +// +// A form is frozen for the length of a run (Fields.Freeze disables every +// control), and a control that had the keyboard keeps it: the focus manager +// asks Disabled only when it MOVES the focus (internal/app/focus_manager.go) +// and the driver hands every key to whatever is focused. So a disabled +// switch went on moving its choice under a form drawn as frozen, and a +// disabled menu went on changing its value on Left and Right through the +// toolkit's own Select.TypedKey, which asks nothing either. An outside review +// of the pull request named the switch on 2026-09-16. The menu came out of +// reading what the switch's fix had to cover. +func TestAFrozenControlAnswersNoKey(t *testing.T) { + changed := 0 + segments := parts.NewSegments([]string{"one", "two", "three"}, func(string) { changed++ }) + segments.Disable() + segments.TypedKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + if segments.Selected != "one" || changed != 0 { + t.Errorf("a frozen segmented switch moved to %q on an arrow and reported %d change(s)", segments.Selected, changed) + } + + menu := parts.NewChooser([]string{"one", "two", "three"}, func(string) { changed++ }) + menu.SetSelected("one") + changed = 0 + menu.Disable() + menu.TypedKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + if menu.Selected != "one" || changed != 0 { + t.Errorf("a frozen menu moved to %q on an arrow and reported %d change(s)", menu.Selected, changed) + } + + toggle := parts.NewToggle(func(bool) { changed++ }) + changed = 0 + toggle.Disable() + toggle.TypedKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + if toggle.Checked || changed != 0 { + t.Errorf("a frozen switch flipped on the space bar and reported %d change(s)", changed) + } +} + +// A segmented switch ignores a value it does not hold. +// +// A switch of fixed choices is not a box: handed a word it does not offer it +// changes nothing and stays on what it had, so a copy of a batch that reads one +// switch and writes another cannot land it on a value that is not there. +func TestASegmentedSwitchIgnoresAValueItDoesNotHold(t *testing.T) { + changed := 0 + s := parts.NewSegments([]string{"one", "two", "three"}, func(string) { changed++ }) + if s.Selected != "one" { + t.Fatalf("a fresh switch opened on %q, not the first value", s.Selected) + } + s.SetSelected("four") + if s.Selected != "one" { + t.Errorf("the switch moved to %q, a value it does not offer", s.Selected) + } + if changed != 0 { + t.Error("the switch called OnChanged for a value it did not move to") + } + s.SetSelected("two") + if s.Selected != "two" || changed != 1 { + t.Errorf("the switch did not move to a value it does offer: selected %q, changed %d times", s.Selected, changed) + } +} + +// A segmented switch moves the choice with the arrows. +// +// One control with the arrows walking the choice, rather than three circles Tab +// stops at one by one - and the arrows MOVE the choice rather than only the +// focus, because a row of exclusive options is chosen by arrowing through it. +func TestASegmentedSwitchMovesTheChoiceWithTheArrows(t *testing.T) { + s := parts.NewSegments([]string{"one", "two", "three"}, nil) + s.TypedKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + if s.Selected != "two" { + t.Errorf("right arrow left the switch on %q", s.Selected) + } + s.TypedKey(&fyne.KeyEvent{Name: fyne.KeyEnd}) + if s.Selected != "three" { + t.Errorf("End left the switch on %q", s.Selected) + } + s.TypedKey(&fyne.KeyEvent{Name: fyne.KeyRight}) + if s.Selected != "three" { + t.Errorf("right arrow past the last value moved off it to %q", s.Selected) + } + s.TypedKey(&fyne.KeyEvent{Name: fyne.KeyHome}) + if s.Selected != "one" { + t.Errorf("Home left the switch on %q", s.Selected) + } +} diff --git a/internal/guard/destination_test.go b/internal/guard/destination_test.go index 45dd2c71..6ee9ea9e 100644 --- a/internal/guard/destination_test.go +++ b/internal/guard/destination_test.go @@ -3,6 +3,7 @@ package guard import ( "os" "path/filepath" + "strings" "testing" "fyne.io/fyne/v2" @@ -65,7 +66,9 @@ func TestTheWindowOffersAFolderOfItsOwnToWriteInto(t *testing.T) { // decides where somebody else's disk gets written to was the one field nobody // saw before pressing Generate (O102). The bar at the foot never scrolls away // and it keeps a line clear for a run whether or not there is one, so saying it -// there costs no room at all. +// there costs no room at all. Since 2026-09-14 that line says what the whole +// form comes to, and the destination is one fact on it - or the only one, +// on a form that does not settle yet. // // The test is that the line is OUTSIDE the scrolling area rather than that it // exists. A label saying the right thing in a part of the screen you have to @@ -79,12 +82,15 @@ func TestWhereTheFilesGoIsSaidOutsideTheScrollingPart(t *testing.T) { if box == nil { t.Fatalf("the %s screen has no output directory box, so this guard read the wrong tree", tab) } - want := text.WritingTo(box.Text) + if box.Text == "" { + t.Fatalf("the %s screen's output directory box is empty, so this guard has nothing to look for", tab) + } + want := text.WillGoTo(box.Text) - said := labelSaying(content, want) + said := labelContaining(content, want) if said == nil { t.Fatalf("nothing on the %s screen says %q.\n"+ - "The status line carries it while a run has said nothing - see runner.sayDestination.", + "The status line carries it while a run has said nothing - see runner.refreshLine.", tab, want) } @@ -100,12 +106,12 @@ func TestWhereTheFilesGoIsSaidOutsideTheScrollingPart(t *testing.T) { } } -// labelSaying is the label carrying one exact sentence. -func labelSaying(o fyne.CanvasObject, want string) *widget.Label { +// labelContaining is the label carrying a sentence somewhere in its text. +func labelContaining(o fyne.CanvasObject, want string) *widget.Label { var found *widget.Label walk(o, func(obj fyne.CanvasObject) { label, ok := obj.(*widget.Label) - if ok && found == nil && label.Text == want { + if ok && found == nil && strings.Contains(label.Text, want) { found = label } }) diff --git a/internal/guard/detailpopup_test.go b/internal/guard/detailpopup_test.go index 101b4b25..bb159905 100644 --- a/internal/guard/detailpopup_test.go +++ b/internal/guard/detailpopup_test.go @@ -7,7 +7,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/test" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" @@ -201,14 +200,12 @@ func detailButtonBeside(o fyne.CanvasObject, label string) *parts.DetailButton { return found } -// namedOnScreen is the words a heading shows, whether it is a label above a -// control or a switch carrying its own name. +// namedOnScreen is the words a heading shows. A switch's name is a heading in +// the column like every other field's since 2026-09-15, so there is no special +// case for it here any more - it carries no words of its own. func namedOnScreen(o fyne.CanvasObject) string { - switch v := unringed(o).(type) { - case *widget.Label: - return v.Text - case *parts.Toggle: - return v.Text + if words, ok := wordsOf(unringed(o)); ok { + return words } return "" } diff --git a/internal/guard/dropdown_test.go b/internal/guard/dropdown_test.go index a0ef096d..c5c64272 100644 --- a/internal/guard/dropdown_test.go +++ b/internal/guard/dropdown_test.go @@ -1,6 +1,7 @@ package guard import ( + "math" "testing" "fyne.io/fyne/v2" @@ -106,44 +107,72 @@ func TestOpeningAListWithAPressDrawsNoKeyboardBar(t *testing.T) { } } -// The list has a ceiling and scrolls under it. +// The list has a ceiling, the ceiling is half the window, and it scrolls under it. // // Measured off the stored tree before this control existed: thirteen formats // made a list 476 px tall with no limit of any kind, covering the whole form. -// At the twenty-five formats T1 is heading for that is about 925 px, which does -// not fit in the window - so this is a defect that gets worse with every format -// added and would have arrived without a line of code changing. +// The first ceiling was a count, eight rows, and a count is the same in every +// window: measured with guirender on 2026-09-15, the list of twenty-four +// formats was 224 px tall at 800x600, at 1100x1300 and at 1101x1025 alike, so +// a third of the values showed however tall the window was (O203). The +// ceiling is a share of the window now, and that takes two canvases to ask +// about. On each the list covers no more than the share, in whole rows, and no +// less than the share less a row - the last half is what rules out a count +// small enough to fit inside the share, which is exactly what eight was. // -// Asked as a count of rows rather than a number of pixels, because the pixels -// follow the text size and the rule does not. -func TestTheOpenListStopsAtEightRowsHoweverManyValuesThereAre(t *testing.T) { - _, content := screenOnACanvas(t) - menu := chooserUnder(t, content, text.FieldFormat()) - - values := len(format.IDs()) - if values <= 8 { - t.Skipf("this build has %d formats, so the ceiling cannot be reached from this screen", values) - } - menu.Tapped(&fyne.PointEvent{}) - list := menu.Opened() - if list == nil { - t.Fatal("the press opened no list") +// The height read is the popup on the canvas, the way the edge guards read it, +// rather than what the list says its minimum is: the promise is about what is +// drawn over the form. +func TestTheOpenListCoversHalfTheWindowAndGrowsWithIt(t *testing.T) { + const tall, short = referenceHeight, referenceHeight / 2 + onTall := formatListRowsShownIn(t, tall) + onShort := formatListRowsShownIn(t, short) + if onTall <= onShort { + t.Errorf("the open list shows %.0f rows in a window %d px tall and %.0f in one %d px tall, so it does not grow "+ + "with the window - a ceiling that is the same in every window is a count of rows under another name", + onTall, int(tall), onShort, int(short)) } +} +// formatListRowsShownIn opens the format menu on the generate screen in a +// window this tall, checks the open list against the share rule there and +// answers how many rows it shows. +func formatListRowsShownIn(t *testing.T, height float32) float32 { + t.Helper() + canvas, content := screenOnACanvasOfHeight(t, height) + // The row is measured once the window exists rather than up front: it + // follows the theme, and the theme is the window's only from here. Read + // before it, the row was 31 px against a list drawn in rows of 28. row := parts.ListRowHeight() if row <= 0 { - t.Fatal("a row measures nothing, so the height below says nothing either") - } - shown := list.MinSize().Height / row - if shown > 8.5 { - t.Errorf("the list shows %.1f of %d values at once, and eight is the ceiling.\n"+ - "Reason: an open list that covers the form takes the context away from the person reading it,\n"+ - "and the number of formats only goes up.\n"+ - "What to do: keep visibleRows in parts/openlist.go.", shown, values) + t.Fatal("a row measures nothing, so the heights below say nothing either") } - if shown < 7.5 { - t.Errorf("the list shows only %.1f rows of %d, which is fewer than the eight decided on", shown, values) + if values := len(format.IDs()); float32(values)*row <= height/2 { + t.Skipf("this build has %d formats, which fit inside half a %.0f px window, so the ceiling is never reached", values, height) } + + menu := chooserUnder(t, content, text.FieldFormat()) + menu.Tapped(&fyne.PointEvent{}) + pop := popUpIn(canvas.Overlays().Top()) + if pop == nil { + t.Fatalf("the press opened no list on the canvas %.0f px tall", height) + } + tall := pop.Size().Height + shown := tall / row + if tall > height/2+0.5 { + t.Errorf("the open list is %.0f px tall in a window %.0f px tall, which is more than half of it.\n"+ + "Reason: an open list that covers the form takes the context away from the person reading it.\n"+ + "What to do: parts.roomForList cuts the list to parts.ListCeiling, a share of the window.", + tall, height) + } + if tall < height/2-row { + t.Errorf("the open list is %.0f px tall in a window %.0f px tall - %.1f rows - which leaves more "+ + "than a row of its half unused, so the ceiling is not following the window.", tall, height, shown) + } + if whole := math.Round(float64(shown)); math.Abs(float64(shown)-whole) > 0.05 { + t.Errorf("the open list shows %.2f rows in a window %.0f px tall, and a list at its ceiling ends on a row's edge", shown, height) + } + return shown } // Escape closes the list and gives the keyboard back to the box. diff --git a/internal/guard/embeddedassets_test.go b/internal/guard/embeddedassets_test.go index 5885ce99..adbae7ec 100644 --- a/internal/guard/embeddedassets_test.go +++ b/internal/guard/embeddedassets_test.go @@ -31,10 +31,18 @@ func TestEveryFileEmbeddedFromSomebodyElseIsAccountedFor(t *testing.T) { seen := map[string]bool{} matched := map[string]bool{} total := 0 + // Every key of ownWork has to be spent on a file some build embeds, or it + // is an exemption that outlived its file - and an exemption nobody uses + // today is the one that exempts somebody else's bytes tomorrow, at the + // same package and path, without a word. Asked the same way the registry + // is asked below, since an unused entry and an unused exemption are the + // same drift facing two ways. An outside review of the pull request + // pointed at the missing half on 2026-09-16. + usedExemptions := map[string]bool{} for _, target := range []string{"../../cmd/tfg-gui", "../../cmd/tfg"} { for _, goos := range []string{"windows", "linux", "darwin"} { - total += accountForBuild(t, target, goos, seen, matched) + total += accountForBuild(t, target, goos, seen, matched, usedExemptions) } } @@ -59,7 +67,19 @@ func TestEveryFileEmbeddedFromSomebodyElseIsAccountedFor(t *testing.T) { "An entry for bytes that no longer ship is a notice nobody needs, and it hides the day "+ "the real thing was replaced by something else.", len(stale), strings.Join(stale, "\n ")) } - t.Logf("%d embedded file(s) from other modules, all accounted for by %d registry entr(y/ies)", + var unspent []string + for key := range ownWork { + if !usedExemptions[key] { + unspent = append(unspent, key) + } + } + if len(unspent) > 0 { + sort.Strings(unspent) + t.Errorf("%d ownWork exemption(s) name a file no build embeds:\n %s\n"+ + "An exemption without a file is a licence check switched off for whatever lands at that "+ + "path next. Take it off the list.", len(unspent), strings.Join(unspent, "\n ")) + } + t.Logf("%d embedded file(s) across every package, all accounted for by %d registry entr(y/ies) or named as our own work", total, len(legal.Assets())) } @@ -67,11 +87,11 @@ func TestEveryFileEmbeddedFromSomebodyElseIsAccountedFor(t *testing.T) { // added. Split out rather than nested inside the test because the depth ceiling // said so, and the ceiling is a measurement rather than a preference - see // docs/QUALITY.md. -func accountForBuild(t *testing.T, target, goos string, seen, matched map[string]bool) int { +func accountForBuild(t *testing.T, target, goos string, seen, matched, usedExemptions map[string]bool) int { t.Helper() added := 0 for pkg, files := range embeddedFiles(t, target, goos) { - added += accountForPackage(t, pkg, files, seen, matched) + added += accountForPackage(t, pkg, files, seen, matched, usedExemptions) } return added } @@ -79,7 +99,7 @@ func accountForBuild(t *testing.T, target, goos string, seen, matched map[string // accountForPackage does one package, skipping what another platform already // answered for. Three systems are asked and they agree about most of the tree, // so without seen the same font would be reported six times. -func accountForPackage(t *testing.T, pkg string, files []string, seen, matched map[string]bool) int { +func accountForPackage(t *testing.T, pkg string, files []string, seen, matched, usedExemptions map[string]bool) int { t.Helper() added := 0 for _, file := range files { @@ -88,11 +108,36 @@ func accountForPackage(t *testing.T, pkg string, files []string, seen, matched m } seen[pkg+" "+file] = true added++ + if ownWork[pkg+" "+file] { + usedExemptions[pkg+" "+file] = true + continue + } accountFor(t, pkg, file, matched) } return added } +// ownWork is every file a package of THIS module embeds that this project +// drew or wrote itself, so that no licence but our own applies to it. +// +// Until 2026-09-15 the walk skipped our module altogether, on the reasoning +// that what we embed is our own work. That day the window took a font of +// somebody else's (Inter, internal/gui/font) and the reasoning stopped being +// true - and a skip would have let those bytes ship with a registry entry +// that matched nothing, which the stale check below would have reported as +// the ENTRY being wrong. So the module is walked like any other, and the +// exceptions are named here, one by one, with the reason each is ours. +// +// A file added to any of our packages that is on neither list makes this +// guard red, which is the direction it should fail in: the person adding it +// says whose it is, rather than a guard assuming. +var ownWork = map[string]bool{ + // Drawn from shapes by tools/appicon.py. docs/LICENSING.md. + "github.com/donislawdev/TestingFilesGenerator/internal/gui/icon chickpea.png": true, + // The window's own words. + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text locale/en.json": true, +} + // accountFor requires exactly one registry entry to claim a file. None means // bytes ship unnamed. Two means the notices could say two different licences // for one file and nothing would notice which one a reader believed. @@ -148,8 +193,8 @@ func TestEveryEmbeddedAssetIsNamedInTheNotices(t *testing.T) { // 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 walked with the rest since 2026-09-15 - see ownWork +// for what changed and why. func embeddedFiles(t *testing.T, target, goos string) map[string][]string { t.Helper() cmd := exec.Command("go", "list", "-deps", "-f", @@ -163,7 +208,7 @@ 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]) diff --git a/internal/guard/everyfield_test.go b/internal/guard/everyfield_test.go index e97efb32..c7ffbcfe 100644 --- a/internal/guard/everyfield_test.go +++ b/internal/guard/everyfield_test.go @@ -190,7 +190,7 @@ func controlName(o fyne.CanvasObject) string { case *parts.Chooser: return fmt.Sprintf("the menu showing %q", v.Selected) case *parts.Toggle: - return fmt.Sprintf("the switch %q", v.Text) + return "the switch" } return fmt.Sprintf("a %T", o) } diff --git a/internal/guard/filekind_test.go b/internal/guard/filekind_test.go index f5d3bf26..ca87537b 100644 --- a/internal/guard/filekind_test.go +++ b/internal/guard/filekind_test.go @@ -142,14 +142,18 @@ func TestChoosingTheFormatGivesTheSameSetOnBothSurfaces(t *testing.T) { // targets, so before this the window could tell somebody it was about to write // seven files and 70 MiB without saying what they were. G6 makes the preview // the thing somebody presses instead of finding out by writing gigabytes. +// +// Read off the line under the buttons, which is the one somebody reads +// after pressing - not off the screen as a whole, which has a menu holding +// the word anyway. func TestThePreviewSaysWhatKindOfFilesItWouldWrite(t *testing.T) { generateHost, generate := screen(t) choose(t, generate, text.FieldFormat(), "png") fill(t, generate, text.FieldOutputDir(), t.TempDir()) press(t, generate, "Preview") join(generateHost) - if shown := allText(generate); !strings.Contains(shown, "png") { - t.Errorf("the preview does not say what it would write. It says:\n%s", shown) + if got := statusLine(t, generate); !strings.Contains(got, text.Formats([]string{"png"})) { + t.Errorf("the preview does not say what it would write. It says:\n%s", got) } presetHost, presets := presetScreen(t) @@ -157,14 +161,14 @@ func TestThePreviewSaysWhatKindOfFilesItWouldWrite(t *testing.T) { choose(t, presets, text.SettingLabel("format"), "wav") press(t, presets, "Preview") join(presetHost) - shown := allText(presets) - if !strings.Contains(shown, "wav") { - t.Errorf("the preset preview does not say what it would write. It says:\n%s", shown) + got := statusLine(t, presets) + if !strings.Contains(got, text.Formats([]string{"wav"})) { + t.Errorf("the preset preview does not say what it would write. It says:\n%s", got) } - // Not merely the word somewhere on a screen that has a menu holding it. - // The line under the buttons is the one somebody reads after pressing. - if !strings.Contains(shown, "7 files · wav") { - t.Errorf("the preview line does not name the kind beside the count. It says:\n%s", shown) + // The count and the kind on one line, so somebody reads "7" and "wav" + // together rather than finding one in a menu and the other in a sentence. + if !strings.HasPrefix(got, "7 files") { + t.Errorf("the preview line does not begin with the count. It says:\n%s", got) } } diff --git a/internal/guard/foldedbatch_test.go b/internal/guard/foldedbatch_test.go index 4af78083..8bebcaf8 100644 --- a/internal/guard/foldedbatch_test.go +++ b/internal/guard/foldedbatch_test.go @@ -6,8 +6,8 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" "github.com/donislawdev/TestingFilesGenerator/internal/recipe" @@ -17,7 +17,7 @@ import ( // button that puts it away. type foldRow struct { title string - toggle *widget.Button + toggle *parts.Button } // foldRows is every fold on a screen, in the order the tree holds them. @@ -47,18 +47,17 @@ func foldRows(o fyne.CanvasObject) []foldRow { if !ok { return } - var toggle *widget.Button + var toggle *parts.Button title := "" for _, item := range row.Objects { - switch found := item.(type) { - case *widget.Button: + if found, ok := item.(*parts.Button); ok { if found.Text == "" && found.Icon != nil { toggle = found } - case *widget.Label: - if title == "" { - title = found.Text - } + continue + } + if words, ok := wordsOf(item); ok && title == "" { + title = words } } if toggle != nil && title != "" { @@ -71,7 +70,7 @@ func foldRows(o fyne.CanvasObject) []foldRow { // foldTitled is the fold with these words in its head, counting from the one // named. Titles repeat - every batch has a section called "Settings for bmp" - // so a section is asked for as the first one after the batch it belongs to. -func foldTitled(t *testing.T, o fyne.CanvasObject, after, title string) *widget.Button { +func foldTitled(t *testing.T, o fyne.CanvasObject, after, title string) *parts.Button { t.Helper() rows := foldRows(o) from := 0 diff --git a/internal/guard/formwidth_test.go b/internal/guard/formwidth_test.go index cc334a68..90132bd0 100644 --- a/internal/guard/formwidth_test.go +++ b/internal/guard/formwidth_test.go @@ -5,7 +5,6 @@ import ( "testing" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/test" "fyne.io/fyne/v2/widget" @@ -219,7 +218,7 @@ func TestTheRunSpeaksInsideTheSameColumnAsTheForm(t *testing.T) { // The line the run wrote, told from the form's own labels by what it // says. Counting every visible label instead is what let this guard // pass while the bar was empty. - if strings.Contains(label.Text, "nothing written yet") { + if strings.Contains(label.Text, text.AndNothingWrittenYet()) { spoke = true } if got := label.Size().Width; got > parts.ColumnWidth { @@ -234,77 +233,25 @@ func TestTheRunSpeaksInsideTheSameColumnAsTheForm(t *testing.T) { } } -// A switch says what it is on the part you click. +// A switch stands under its name in the column of names. // -// The other half of O72. Given a heading above it like every other field, a -// switch arrives as a bare square: the name is above it, the sentence below, -// and there is nothing to read on the thing itself - nor anything but the -// square to aim at. -func TestASwitchCarriesItsOwnName(t *testing.T) { - _, content := screen(t) - - found := 0 - walk(content, func(obj fyne.CanvasObject) { - check, ok := obj.(*parts.Toggle) - if !ok { - return - } - found++ - if check.Text == "" { - t.Errorf("a switch on the generate screen carries no words, so there is nothing to read on it and only the square to click") - } - }) - if found == 0 { - t.Fatal("no switch was found, so this guard read the wrong tree") - } -} - -// The words of a switch stand clear of its square. +// This is O72 turned the other way up, on 2026-09-15. The switch used to carry +// its own words, because a heading ABOVE it left a bare square with nothing to +// read - but in a grid the name stands BESIDE the square, in the column every +// other name is in, so the words to read are there without the switch being +// the one control that breaks the grid (GUI rule 13). What the two guards here +// used to protect - that a switch is not a nameless square, and that its words +// are clear of it - is now that the switch is a field found by the name beside +// it, like every other. TestTheWordsOfASwitchStandClearOfItsSquare (O95) went +// with the words: there are none to stand clear of. // -// Seen on the render on 2026-08-18, after the focus disc stopped being drawn -// for a press: the disc had been filling that space, so taking it off the -// pointer's path uncovered a defect that was always there. Measured off the -// stored tree - the square spanned x=4 to x=24 and the words started at x=28, -// which is four pixels between a 20 px box and a sentence, and they read as -// touching. O95. -// -// The number is asked as "at least as much as a list row uses", so there is one -// answer in this window to "how much room goes beside a glyph" rather than two -// numbers drifting apart. -func TestTheWordsOfASwitchStandClearOfItsSquare(t *testing.T) { - _, content := screenOnACanvas(t) - - box := checkNamed(content, text.FieldLabel()) - if box == nil { - t.Fatalf("there is no switch labelled %q, so this guard read the wrong tree", text.FieldLabel()) - } - - // Asked of the RENDERER rather than of a tree walk. What a switch draws - // lives inside checkRenderer and a walk cannot get in - it stops at the - // widget and reports a switch that draws nothing, which is not what a - // person sees. - var square *canvas.Image - var words *canvas.Text - for _, drawn := range test.WidgetRenderer(box).Objects() { - switch v := drawn.(type) { - case *canvas.Image: - if square == nil { - square = v - } - case *canvas.Text: - if words == nil { - words = v - } - } - } - if square == nil || words == nil { - t.Fatal("the switch draws no square or no words, so this guard read the wrong tree") - } +// Found through checkNamed, which is controlUnder - so this passes only while +// the name is a real heading in the column, level with the square. +func TestTheLabelSwitchStandsUnderItsName(t *testing.T) { + _, content := screen(t) - gap := words.Position().X - (square.Position().X + square.Size().Width) - if least := float32(6); gap < least { - t.Errorf("%.0f px separate the switch from its words, and %.0f is the least that reads as a gap.\n"+ - "Reason: they touched until 2026-08-18, hidden until then by the focus disc that a press no longer draws.\n"+ - "What to do: keep parts.WithRoomForItsName round the switch.", gap, least) + if checkNamed(content, text.FieldLabel()) == nil { + t.Errorf("no switch stands under the name %q in the column of names, so either the switch "+ + "carries its own words again or its name is not beside it", text.FieldLabel()) } } diff --git a/internal/guard/generatewindow_test.go b/internal/guard/generatewindow_test.go index 4edf2dc3..05064a68 100644 --- a/internal/guard/generatewindow_test.go +++ b/internal/guard/generatewindow_test.go @@ -387,10 +387,14 @@ func TestPreviewSaysTheCostAndWritesNothing(t *testing.T) { t.Errorf("Preview wrote %d thing(s) into the output directory", len(entries)) } - shown := textIn(content) - for _, want := range []string{"3 files", "12.0 KB", "free"} { - if !strings.Contains(shown, want) { - t.Errorf("the preview does not say %q. The screen says:\n%s", want, shown) + // The cost, exact, on the line under the buttons - and that none of it + // exists yet. Read off that line rather than off the whole screen, so the + // words have to be where somebody reads them after pressing. + got := statusLine(t, content) + want := text.SizeAndBytes(core.HumanBytes(3*4096), core.ExactBytes(3*4096)) + for _, piece := range []string{"3 files", want, dir + " (", "free)", text.AndNothingWrittenYet()} { + if !strings.Contains(got, piece) { + t.Errorf("the preview does not say %q. The line says:\n%s", piece, got) } } } diff --git a/internal/guard/guitext_test.go b/internal/guard/guitext_test.go index 455a4cdf..319f5d6a 100644 --- a/internal/guard/guitext_test.go +++ b/internal/guard/guitext_test.go @@ -54,12 +54,19 @@ import ( // is not a number, so two sentences a person reads were built outside the text // package and no carrier list would ever have named them. var notWords = map[string]string{ - `"10mb"`: "the size a fresh screen starts at, a value rather than prose", - `"1"`: "how many files a fresh screen starts at", - `"0"`: "the seed a fresh screen starts at", - `"files"`: "the group name a fresh screen starts at, and a recipe value", - `"tfg-gui"`: "recorded in the manifest as the command that ran, a contract value", - `"chickpea.png"`: "the name the toolkit files the icon resource under, never shown", + `"10mb"`: "the size a fresh screen starts at, a value rather than prose", + `"1"`: "how many files a fresh screen starts at", + `"0"`: "the seed a fresh screen starts at", + `"files"`: "the group name a fresh screen starts at, and a recipe value", + `"tfg-gui"`: "recorded in the manifest as the command that ran, a contract value", + `"chickpea.png"`: "the name the toolkit files the icon resource under, never shown", + `"Inter-Regular.ttf"`: "the name the painter files the regular face under, the key of its cache of shaped faces, never shown", + `"Inter-Bold.ttf"`: "the name the painter files the bold face under, never shown", + `"github.com/donislawdev/TestingFilesGenerator/internal/gui/font"`: "an import path, spelled the way go list spells it, which the font package " + + "announces itself to the licence registry under - a key, not a sentence", + `"--catalogue"`: "a flag on the launch line, which is English by D9 like every flag the command line takes, " + + "and is never translated or shown", + `"--catalog"`: "the same flag in its other spelling", `"GetSystemDirectoryW"`: "the Windows entry point that says where the system keeps its own " + "libraries, asked for by name because that is how the loader takes it", `"preset"`: "the key the preset field is registered under, not a label", @@ -88,6 +95,8 @@ var notWords = map[string]string{ `"%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", + `"lift"`: "our name for what the pointer does to the filled button, a colour the palette holds", + `"shade"`: "our name for what a press does to the filled button, a colour the palette holds", `"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 @@ -124,6 +133,13 @@ func TestTheWindowSaysNothingItDoesNotSayFromTheTextPackage(t *testing.T) { if strings.Contains(filepath.ToSlash(path), "/gui/text/") { return nil } + // And the catalogue of parts, whose captions are English for whoever + // builds the window and reach nobody else: it opens with --catalogue + // and is on no tab. Decision of the owner, 2026-09-15, written at the + // top of that package. + if strings.Contains(filepath.ToSlash(path), "/gui/catalogue/") { + return nil + } files++ fset := token.NewFileSet() diff --git a/internal/guard/keyboard_test.go b/internal/guard/keyboard_test.go index 5a850221..7fb9c91a 100644 --- a/internal/guard/keyboard_test.go +++ b/internal/guard/keyboard_test.go @@ -8,7 +8,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/test" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" @@ -287,7 +286,7 @@ func TestTheFolderOfferGoesAwayWhenTheNextRunStarts(t *testing.T) { // shownButton is a button somebody can actually see, which is not the same as // one that is in the tree - this window builds several and hides them until // they mean something. -func shownButton(o fyne.CanvasObject, name string) *widget.Button { +func shownButton(o fyne.CanvasObject, name string) *parts.Button { button := buttonNamed(o, name) if button == nil || !button.Visible() { return nil diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go index 2232cf5a..33bee3ff 100644 --- a/internal/guard/layers_test.go +++ b/internal/guard/layers_test.go @@ -84,12 +84,14 @@ 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/font": 4, + "internal/gui/catalogue": 4, + "internal/gui/text": 4, + "internal/gui/window": 4, "cmd/tfg": 5, "cmd/tfg-gui": 5, @@ -175,7 +177,9 @@ var sameLayerAllowed = map[string][]string{ // 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"}, + // And the typeface, since 2026-09-15: bytes of a font and nothing else, + // which the theme in parts hands to the toolkit. + "internal/gui/parts": {"internal/gui/text", "internal/gui/font"}, // 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. @@ -187,7 +191,11 @@ var sameLayerAllowed = map[string][]string{ // - the one that watches for words outside the text package worked from a // list of the calls that show text, and nobody had put the toolkit's // NewWindow on it. The rule is the other way round now. - "internal/gui": {"internal/gui/window", "internal/gui/parts", "internal/gui/text", "internal/gui/icon"}, + "internal/gui": {"internal/gui/window", "internal/gui/parts", "internal/gui/text", "internal/gui/icon", "internal/gui/catalogue"}, + // The catalogue draws the parts and nothing else of ours: it is a screen + // for whoever builds the window, and a screen that reached the engine + // would be a fifth work screen. + "internal/gui/catalogue": {"internal/gui/parts"}, } // Edges that a plain layer number would allow but that must never exist. diff --git a/internal/guard/leftedge_test.go b/internal/guard/leftedge_test.go index 9060f0a3..5ef87983 100644 --- a/internal/guard/leftedge_test.go +++ b/internal/guard/leftedge_test.go @@ -61,9 +61,12 @@ func TestEverythingAPersonReadsStartsOnOneLeftEdge(t *testing.T) { // The deepest rank used to be the line under a field. That line moved // behind the button beside the field's name on 2026-08-24, so what is // left standing on this edge is the heading a format's settings get. - // Four ranks either way, which is what this is about. + // The title is the word on the tab since 2026-09-15 and the sentence + // under it is what the title used to say - five ranks now, which is + // what this is about. {text.TabOneTarget(), []string{ - text.HeadingGenerate(), + text.TabOneTarget(), + text.SubtitleGenerate(), text.SectionConfiguration(), text.FieldFormat(), text.SettingsFor(firstFormat()), @@ -73,12 +76,14 @@ func TestEverythingAPersonReadsStartsOnOneLeftEdge(t *testing.T) { // about the section, which is the rank the heading above it has to // line up with. {text.TabPresets(), []string{ - text.HeadingPreset(), + text.TabPresets(), + text.SubtitlePreset(), text.SectionPreset(), text.PresetCatchesHeading(), }}, {text.TabRecipe(), []string{ - text.HeadingRecipe(), + text.TabRecipe(), + text.SubtitleRecipe(), text.FieldFormat(), }}, } @@ -124,14 +129,19 @@ func TestTheActionBarSpeaksOnTheSameEdgeAsTheForm(t *testing.T) { if !ok { t.Fatal("the generate screen has no Format field") } - // What the bar 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") + // What the bar says at rest, before anything has been pressed: the line + // naming, among other things, the destination. + status := labelContaining(generate, text.WillGoTo(destinationShownAtRest(t, generate))) + if status == nil { + t.Fatal("the bar is not naming the destination at rest, so this guard read the wrong tree") + } + at, found := absoluteOf(generate, status) + if !found { + t.Fatal("the status line is not on the screen it was found in") } - if off := status.X - field.X; off > 1 || off < -1 { + if off := at.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) + field.X, at.X, off) } } diff --git a/internal/guard/listedge_test.go b/internal/guard/listedge_test.go index 0ecff1a0..6663f76e 100644 --- a/internal/guard/listedge_test.go +++ b/internal/guard/listedge_test.go @@ -85,10 +85,11 @@ func TestAnOpenListStaysInsideTheWindow(t *testing.T) { // An open list is cut to the room there is, when there is not much anywhere. // -// Opening upward is only half of it. In a window with fewer than eight rows of -// room on either side, a list at its row ceiling goes past an edge whichever -// way it opens - so it has to be shorter than its ceiling, and the shortening -// has to reach the LIST rather than only the popup around it. Measured on +// Opening upward is only half of it. In a window with less room on either side +// of the box than the list's ceiling - a share of the window, so a few rows in +// a window this short - the list goes past an edge whichever way it opens. So +// it has to be shorter than its ceiling, and the shortening has to reach the +// LIST rather than only the popup around it. Measured on // 2026-08-19: a popup is never laid out smaller than its content's minimum, so // resizing it alone left the list its full height and did nothing at all // (O113). @@ -103,8 +104,9 @@ func TestAnOpenListIsCutToTheRoomThereIs(t *testing.T) { } menu := parts.NewChooser(values, nil) - // Shorter than eight rows either side of the box, so no whole list fits - // whichever way it opens. + // Too short for the list either side of the box - its ceiling here is two + // rows and the room on the roomier side is under three - so no whole list + // fits whichever way it opens. const height = 120 w := test.NewWindow(container.NewBorder(nil, menu, nil, nil, container.NewVBox())) t.Cleanup(w.Close) diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index bb43fa37..b2742428 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -161,7 +161,11 @@ var provenByProbe = map[string]string{ "That break is the real failure mode rather than an invented one. Visible() on a child answers for the CHILD, so a control inside a hidden container still reports itself visible " + "while the focus chain, which walks the visible tree, steps straight past it. A section quietly hidden is exactly how a screen stops being operable from the keyboard without looking any different in a screenshot. " + "Not a mutation entry because no substitution of one piece of text for another produces that state today: the failure lives in the disagreement between two ways of asking what is visible, " + - "and nothing in the tree says Hide for a substitution to aim at.", + "and nothing in the tree says Hide for a substitution to aim at. " + + "The clause about the strip, added 2026-09-15 when the strip became ours and its words learnt to hold the keyboard, was broken by hand the same way: " + + "hiding every word in parts.Tabbed made it red naming all four words on every screen, and green again when the lines came out. " + + "Taking the words' FocusGained away is not a break this guard can be shown - the product itself refuses to compile, because the strip focuses its own words in two places, " + + "so that half is held by the compiler rather than by a test.", "TestTheTaskbarIconIsTheSameDrawingAsTheWindowIcon": "checked 2026-08-13 with tools/probes/exe-icon.ps1, run on both binaries. " + "It reported 7 of 7 icon images inside tfg-gui.exe and 0 of 7 inside tfg.exe, which is the right answer for a command line with no window. " + diff --git a/internal/guard/namescolumn_test.go b/internal/guard/namescolumn_test.go new file mode 100644 index 00000000..12598622 --- /dev/null +++ b/internal/guard/namescolumn_test.go @@ -0,0 +1,104 @@ +package guard + +import ( + "testing" + + "fyne.io/fyne/v2" + + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/preset" +) + +// Every name a screen can show is in the list the column of names is worked +// out from. +// +// The column is one width for the whole window, taken from every name the +// window can ever draw - window.EveryFieldName - so that choosing a format +// with a long setting name does not move every box on the screen. That list +// is written by hand, and a list written by hand is missing something the day +// after it is written: a name that is not in it draws at its own width, wider +// than the column, and its box starts to the right of every other box. +// +// So the names are read off the screens, with every format, every damage and +// every preset chosen in turn, and each one is asked whether the list has it. +// The screens are the source and the list is what is checked, which is the +// direction that finds the missing entry rather than the one that confirms +// the entries there are. +func TestEveryNameOnAScreenIsInTheListTheColumnIsWorkedOutFrom(t *testing.T) { + ourTheme(t) + content, _ := laidOutWindow(t) + + listed := map[string]bool{} + for _, name := range window.EveryFieldName() { + listed[name] = true + } + if len(listed) == 0 { + t.Fatal("the window lists no names at all, so there is nothing to compare") + } + + seen := map[string]bool{} + note := func(screen fyne.CanvasObject) { + for _, name := range fieldNamesOn(screen) { + seen[name] = true + } + } + + generate := tabContent(t, content, text.TabOneTarget()) + formats := menuUnder(t, generate, text.FieldFormat()) + for _, id := range format.IDs() { + formats.SetSelected(id) + note(generate) + } + damages := menuUnder(t, generate, text.FieldDamage()) + for _, id := range damage.Names() { + damages.SetSelected(id) + note(generate) + } + + presets := tabContent(t, content, text.TabPresets()) + picker := menuUnder(t, presets, text.FieldPreset()) + for _, id := range preset.IDs() { + picker.SetSelected(id) + note(presets) + } + + recipe := tabContent(t, content, text.TabRecipe()) + batches := menuUnder(t, recipe, text.FieldFormat()) + for _, id := range format.IDs() { + batches.SetSelected(id) + note(recipe) + } + + if len(seen) < len(listed)/2 { + t.Fatalf("only %d names were read off the screens against %d listed, so this guard did not reach the screens", + len(seen), len(listed)) + } + for name := range seen { + if !listed[name] { + t.Errorf("%q is the name of a field on a screen and is not in the list the column of names is worked out from,"+ + " so its box may start to the right of every other box", name) + } + } +} + +// fieldNamesOn is the name of every field on a screen, read off the rows the +// form is made of. A switch carries its own name and stands in the column of +// controls, so its row has no name here, which is right - the column of names +// is not for it. +func fieldNamesOn(screen fyne.CanvasObject) []string { + var out []string + walk(screen, func(obj fyne.CanvasObject) { + box, ok := obj.(*fyne.Container) + if !ok || !parts.IsFieldRow(box) || len(box.Objects) < 2 { + return + } + if name, named := headingOf(box.Objects[0]); named && name != "" { + out = append(out, name) + } + }) + return out +} diff --git a/internal/guard/navigation_test.go b/internal/guard/navigation_test.go index aa7cd9a4..079a36ca 100644 --- a/internal/guard/navigation_test.go +++ b/internal/guard/navigation_test.go @@ -4,8 +4,8 @@ import ( "testing" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/widget" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" ) @@ -89,8 +89,8 @@ func TestCancelIsDrawnAsAButton(t *testing.T) { if cancel == nil { t.Fatal("there is no Cancel button at all, so this guard read the wrong tree") } - if cancel.Importance == widget.LowImportance { - t.Error("Cancel is drawn at the lowest importance, which paints no surface - so it arrives as bare words beside two filled buttons and reads as disabled") + if cancel.Look() == parts.Quiet { + t.Error("Cancel is drawn as the quiet look, which paints no surface at rest - so it arrives as bare words beside two filled buttons and reads as disabled") } } @@ -129,7 +129,7 @@ func TestMovingBetweenScreensIsTabsAndNotButtons(t *testing.T) { for _, name := range want { screen := tabNamed(t, host.content, name) walk(screen, func(obj fyne.CanvasObject) { - button, ok := obj.(*widget.Button) + button, ok := obj.(*parts.Button) if !ok { return } diff --git a/internal/guard/nowindow_test.go b/internal/guard/nowindow_test.go index ac4a7d00..73a83769 100644 --- a/internal/guard/nowindow_test.go +++ b/internal/guard/nowindow_test.go @@ -75,7 +75,17 @@ func TestABuildWithNoWindowInItSaysSoAndKeepsStandardOutputEmpty(t *testing.T) { // windows subsystem so it opens without a console, and that is guarded // elsewhere. Here the question is what the code SAYS, so it is built plain // and its streams can be read. - run := exec.Command(built) + // Asked twice: for the window, and for the catalogue of its parts, which + // the binary opens on --catalogue since 2026-09-15 and which is a window + // like any other. A build with no window in it has one answer. + for _, args := range [][]string{{}, {"--catalogue"}} { + saysSoAndKeepsStandardOutputEmpty(t, built, args) + } +} + +func saysSoAndKeepsStandardOutputEmpty(t *testing.T, built string, args []string) { + t.Helper() + run := exec.Command(built, args...) var stdout, stderr bytes.Buffer run.Stdout = &stdout run.Stderr = &stderr @@ -86,7 +96,7 @@ func TestABuildWithNoWindowInItSaysSoAndKeepsStandardOutputEmpty(t *testing.T) { if errors.As(err, &exit) { code = exit.ExitCode() } else if err != nil { - t.Fatalf("running the window binary built without cgo: %v", err) + t.Fatalf("running the window binary built without cgo (%v): %v", args, err) } if code != 1 { diff --git a/internal/guard/ownassets_test.go b/internal/guard/ownassets_test.go new file mode 100644 index 00000000..64ae21f2 --- /dev/null +++ b/internal/guard/ownassets_test.go @@ -0,0 +1,159 @@ +package guard + +import ( + "os" + "os/exec" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/legal" +) + +// Bytes that a package of THIS project embeds and somebody else wrote. +// +// Until 2026-09-15 every entry in the licence registry came from inside some +// other module, so every question about what a binary carries was a question +// about modules: a font travels with the package that embeds it, and a build's +// own record names the modules it links. That day the window was set in Inter +// (internal/gui/font), and the question stopped having an answer for that +// entry - both binaries are one module, so "is the module linked" is yes for +// the command line binary too, and it carries no font at all. +// +// So an entry of ours is answered by PACKAGE instead: go list when a document +// is rendered, and the package's own init (legal.Carrying) when a running +// binary is asked. The guards here hold the three halves of that to the build: +// the spelling the registry uses is the spelling go list uses, the command +// line links none of these packages, and every such package does announce +// itself. + +// The registry spells our module and our packages by hand, and the whole +// mechanism compares those spellings with what go list reports. A typo would +// not be an error anywhere - it would be a font quietly dropping off every +// list, which is the failure this registry exists to prevent. +func TestEveryAssetOfOurOwnIsSpelledAsGoListSpellsIt(t *testing.T) { + out, err := exec.Command("go", "list", "-m").Output() + if err != nil { + t.Skipf("go list is not available here: %v", err) + } + if got := strings.TrimSpace(string(out)); got != legal.OurModule() { + t.Errorf("the registry calls this module %q and go list calls it %q", legal.OurModule(), got) + } + for _, asset := range ours(t) { + out, err := exec.Command("go", "list", "-f", "{{.ImportPath}}", asset.Package).Output() + if err != nil { + t.Errorf("%s names the package %s, which go list cannot find: %v", asset.Name, asset.Package, err) + continue + } + if got := strings.TrimSpace(string(out)); got != asset.Package { + t.Errorf("%s names the package %q and go list spells it %q", asset.Name, asset.Package, got) + } + } +} + +// The command line binary has no toolkit and no font, and the notices say so +// in as many words. Nothing structural keeps it that way: a package of ours +// that embeds a font is a package any other package may import, and the day +// one on the command line's side does, "tfg license" would go on saying +// nothing about it - the runtime answer comes from the package's own init, +// which would then run there too, but the sentence in the notices would be +// wrong and this is the guard that would say so first. +func TestTheCommandLineLinksNoBytesOfSomebodyElsesThatWeEmbed(t *testing.T) { + linked := ourPackagesLinkedBy(t, "../../cmd/tfg", false) + for _, asset := range ours(t) { + if linked[asset.Package] { + t.Errorf("the command line binary links %s, which embeds %s - the notices say tfg carries "+ + "none of this, and tfg license would have to start saying otherwise", asset.Package, asset.Name) + } + } + + // And the built binary, asked the way a user asks it. The line for an + // embedded entry starts with its name and two spaces (legal.Item.Line). + binary := buildCommandLine(t) + out, err := exec.Command(binary, "license").Output() + if err != nil { + t.Fatalf("running the licence command: %v", err) + } + for _, line := range strings.Split(string(out), "\n") { + for _, asset := range ours(t) { + if strings.HasPrefix(strings.TrimSpace(line), asset.Name+" ") { + t.Errorf("tfg license names %q, and the command line binary carries no such bytes", asset.Name) + } + } + } +} + +// A running binary learns which of our packages it holds from the packages +// themselves, at start-up. A package that forgot to say so would ship its +// bytes and drop off the licence command without a word - the one failure a +// registry cannot notice about itself. +// +// Asked in this binary, which links the window's parts and therefore every +// package the window embeds through. A package this binary does not link +// cannot be measured here and is reported rather than skipped: a blank import +// in this file is the remedy, and a skip is how a guard passes on nothing. +func TestEveryPackageOfOursThatEmbedsSomebodyElsesBytesAnnouncesItself(t *testing.T) { + linked := ourPackagesLinkedBy(t, ".", true) + for _, asset := range ours(t) { + if !linked[asset.Package] { + t.Errorf("this guard binary does not link %s, so it cannot ask whether the package "+ + "announces %s - import it here so it can", asset.Package, asset.Name) + continue + } + if !legal.LinkedHere(asset.Package) { + t.Errorf("%s is linked into this binary and never called legal.Carrying - %s would ship "+ + "and be named on no list a running binary prints", asset.Package, asset.Name) + } + } +} + +// ours is every registry entry embedded by a package of this project. At +// least one, or the guards above are proving things about an empty set. +func ours(t *testing.T) []legal.Asset { + t.Helper() + var out []legal.Asset + for _, asset := range legal.Assets() { + if asset.Ours() { + out = append(out, asset) + } + } + if len(out) == 0 { + t.Fatal("the registry holds no entry embedded by a package of ours, and these guards exist for those") + } + return out +} + +// ourPackagesLinkedBy asks the compiler which packages of this module one +// target links, on every system a release is built for. CGO is on for the +// reason written beside the notices guard: with it off the window's toolkit +// hides its dependencies and the answer is a stub. +// +// withTests asks about the target's TEST binary rather than the package: the +// guard package itself is one doc.go, and everything it links it links from +// its test files. +func ourPackagesLinkedBy(t *testing.T, target string, withTests bool) map[string]bool { + t.Helper() + args := []string{"list", "-deps"} + if withTests { + args = append(args, "-test") + } + args = append(args, "-f", "{{if .Module}}{{.Module.Path}}|{{.ImportPath}}{{end}}", target) + linked := map[string]bool{} + for _, goos := range []string{"windows", "linux", "darwin"} { + cmd := exec.Command("go", args...) + cmd.Env = append(os.Environ(), "GOOS="+goos, "CGO_ENABLED=1") + out, err := cmd.Output() + if err != nil { + t.Skipf("go list for %s is not available here: %v", goos, err) + } + for _, line := range strings.Split(string(out), "\n") { + module, pkg, found := strings.Cut(strings.TrimSpace(line), "|") + if found && module == legal.OurModule() { + linked[pkg] = true + } + } + } + if len(linked) == 0 { + t.Fatalf("%s links no package of this module at all, which cannot be right", target) + } + return linked +} diff --git a/internal/guard/partscatalogue_test.go b/internal/guard/partscatalogue_test.go new file mode 100644 index 00000000..88b77490 --- /dev/null +++ b/internal/guard/partscatalogue_test.go @@ -0,0 +1,253 @@ +package guard + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/catalogue" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// The catalogue of the window's parts, held to the package it draws. +// +// GUI rule 4 asks for a screen closed in both directions: nothing on it that +// the application does not have, nothing of the application missing from it. +// The first catalogue was a probe outside the repository and stopped +// compiling at step 5 of the rework, and nothing said so for two steps. These +// guards are why the second one lives in the tree: the surface of parts is +// read with go/ast, so a constructor or a type added tomorrow is on the list +// the day it lands, and the registry has to say where every one of them is +// drawn - or why it is not. + +// What the registry has to account for: every exported type, every +// constructor (its name without New), and every exported function that +// returns something drawable. A function returning a number or a yes is not +// a component. Every name the registry uses has to exist - a catalogue that +// covers a part nobody has is the other way of being wrong. +func TestTheCatalogueCoversEveryPartAndNothingElse(t *testing.T) { + surface, exported := partsSurface(t) + if len(surface) < 30 { + t.Fatalf("only %d names were found on the surface of parts, too few to be the real package", len(surface)) + } + + placed := map[string][]string{} + place := func(name, where string) { placed[name] = append(placed[name], where) } + for _, e := range catalogue.Entries() { + place(e.Name, "the entry "+e.Name) + for _, c := range e.Covers { + place(c, "covered by "+e.Name) + } + if len(e.States) == 0 { + t.Errorf("the entry %s has no states, so it draws nothing and is a name on a list", e.Name) + } + } + for _, r := range catalogue.NotDrawn() { + place(r.Name, "not drawn") + if r.Why == "" { + t.Errorf("%s is not drawn and no reason is written", r.Name) + } + } + for _, r := range catalogue.LayoutOnly() { + place(r.Name, "layout only") + if r.Why == "" { + t.Errorf("%s is layout only and no reason is written", r.Name) + } + } + + names := make([]string, 0, len(surface)) + for name := range surface { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + switch len(placed[name]) { + case 1: + case 0: + t.Errorf("parts exports %s (%s) and the catalogue neither draws it nor says why not.\n"+ + "Add it as an entry, name it under the entry that draws it as a state, or put it in "+ + "NotDrawn or LayoutOnly with the reason.", name, surface[name]) + default: + t.Errorf("%s is in the catalogue %d times: %s. One part, one place to look at it.", + name, len(placed[name]), strings.Join(placed[name], ", ")) + } + } + for name, where := range placed { + if !exported[name] { + t.Errorf("the catalogue names %s (%s) and parts exports no such thing - a stale name is a "+ + "part that was renamed or removed and is still being vouched for", name, strings.Join(where, ", ")) + } + } + t.Logf("%d names on the surface of parts, every one placed once", len(surface)) +} + +// Every state of an entry draws differently from every other state of the +// same entry. A state that draws the same as another is not a state - it is +// a caption over the same picture, and a catalogue of those looks complete +// while showing nothing. Measured on the rendered tree rather than asserted +// from the flag the builder set, which is GUI rule 10. +func TestEveryCatalogueStateDrawsDifferently(t *testing.T) { + app := test.NewApp() + app.Settings().SetTheme(parts.Theme()) + defer test.NewApp() + + drawn := 0 + for _, e := range catalogue.Entries() { + seen := map[string]string{} + for _, s := range e.States { + markup := renderedMarkup(t, s.Build()) + drawn++ + if other, dup := seen[markup]; dup { + t.Errorf("%s: the state %q draws exactly as %q does, so one of them is a caption over "+ + "the other's picture", e.Name, s.Caption, other) + continue + } + seen[markup] = s.Caption + } + } + if drawn < 40 { + t.Fatalf("only %d states were drawn, too few to be the real catalogue", drawn) + } + t.Logf("%d states drawn, every one different from its neighbours", drawn) +} + +// renderedMarkup is one object laid out in a window of the reference width +// and drawn to words, so two states can be compared without a picture. +func renderedMarkup(t *testing.T, o fyne.CanvasObject) string { + t.Helper() + w := test.NewWindow(o) + defer w.Close() + w.Resize(fyne.NewSize(referenceWidth, o.MinSize().Height+referenceHeight/10)) + return test.RenderToMarkup(w.Canvas()) +} + +// The screen shows the whole registry: every entry by name, every state by +// its caption, and every name the catalogue declines to draw, with its reason +// - so that a person reading the screen and a guard reading the registry are +// reading the same thing. +func TestTheCatalogueScreenShowsTheWholeRegistry(t *testing.T) { + shown := textIn(catalogue.Page()) + for _, e := range catalogue.Entries() { + if !strings.Contains(shown, e.Name) { + t.Errorf("the catalogue screen never names the entry %s", e.Name) + } + for _, s := range e.States { + if !strings.Contains(shown, s.Caption) { + t.Errorf("the catalogue screen never shows the state %q of %s", s.Caption, e.Name) + } + } + } + for _, r := range append(catalogue.NotDrawn(), catalogue.LayoutOnly()...) { + if !strings.Contains(shown, r.Name) || !strings.Contains(shown, r.Why) { + t.Errorf("the catalogue screen does not say that %s is left out, or not why", r.Name) + } + } +} + +// partsSurface reads internal/gui/parts and returns the names the catalogue +// has to account for, each with what it is, and every exported name at all, +// which is what a registry name has to be one of. +func partsSurface(t *testing.T) (surface map[string]string, exported map[string]bool) { + t.Helper() + dir := filepath.Join(repoRoot(t), "internal", "gui", "parts") + files, err := filepath.Glob(filepath.Join(dir, "*.go")) + if err != nil || len(files) == 0 { + t.Fatalf("listing %s: %v", dir, err) + } + surface = map[string]string{} + exported = map[string]bool{} + fset := token.NewFileSet() + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + parsed, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("parsing %s: %v", file, err) + } + for _, decl := range parsed.Decls { + collectSurface(decl, surface, exported) + } + } + return surface, exported +} + +// collectSurface files one declaration. A method is not a component and is +// left alone, which is also what keeps the receiver types out of "exported": +// they arrive through their own type declarations. +func collectSurface(decl ast.Decl, surface map[string]string, exported map[string]bool) { + switch d := decl.(type) { + case *ast.GenDecl: + for _, spec := range d.Specs { + if ts, ok := spec.(*ast.TypeSpec); ok && ts.Name.IsExported() { + surface[ts.Name.Name] = "an exported type" + exported[ts.Name.Name] = true + } + } + case *ast.FuncDecl: + if d.Recv != nil || !d.Name.IsExported() { + return + } + name := d.Name.Name + if strings.HasPrefix(name, "New") && len(name) > 3 { + exported[name[3:]] = true + surface[name[3:]] = "the constructor " + name + return + } + exported[name] = true + if returnsDrawable(d.Type.Results) { + surface[name] = "a function returning something drawable" + } + } +} + +// returnsDrawable says whether a result list carries a thing a screen can +// draw: a canvas object, a container, or a resource - alone, in a slice, or +// beside other results. +func returnsDrawable(results *ast.FieldList) bool { + if results == nil { + return false + } + for _, field := range results.List { + var b strings.Builder + ast.Fprint(&b, nil, field.Type, nil) + text := b.String() + if strings.Contains(text, "CanvasObject") || strings.Contains(text, "Container") || + strings.Contains(text, "Resource") { + return true + } + } + return false +} + +// The catalogue is one of the stored scenes, drawn whole. It is the scene +// where a change of a token shows on every control at once, so a catalogue +// that dropped off that list would take the before-and-after of every later +// step with it - quietly, because the other scenes would still pass. +func TestTheCatalogueIsAStoredScene(t *testing.T) { + var found *screenScene + for i := range screenScenes() { + if screenScenes()[i].name == "catalogue" { + found = &screenScenes()[i] + } + } + if found == nil { + t.Fatal("no scene called catalogue is on the list the pixel guard draws") + } + if found.page == nil { + t.Fatal("the catalogue scene is not built as a page, so it would be drawn one screenful tall") + } + for _, ext := range []string{".png", ".xml"} { + if _, err := os.Stat(filepath.Join("testdata", "screens", "catalogue"+ext)); err != nil { + t.Errorf("no stored %s of the catalogue: %v - regenerate with TFG_WRITE_SCREEN_REFERENCE=1", ext, err) + } + } +} diff --git a/internal/guard/planmemory_test.go b/internal/guard/planmemory_test.go index 9e6315c9..2834fce5 100644 --- a/internal/guard/planmemory_test.go +++ b/internal/guard/planmemory_test.go @@ -78,23 +78,35 @@ func TestAPlanTooBigToHoldIsRefusedWhileItIsBuilt(t *testing.T) { // Without this the test above passes on a build that refuses everything, which // is the shape this project has thrown away six pieces of defensive code for - // a check nothing can turn red is not a check. +// +// Ten thousand files rather than four thousand, and the number is the +// ceiling's own. When the cheap counter says the plan may be over the +// ceiling, the swept heap is read, and a heap no larger than at the start is +// taken as nothing to refuse - with the next reading planCheckEvery (4096) +// files on. A ceiling too low for any run therefore refuses nothing in a run +// shorter than that: the counter trips at the first files, the collector has +// just handed back what the test process left lying about, and the plan is +// never read again. The full mutation run of 2026-09-16 found exactly that - +// a 2 KB ceiling green under four thousand files. At ten thousand the second +// reading lands on four thousand planned files that nothing can collect. func TestAnOrdinaryRunIsNotRefusedByThePlanCeiling(t *testing.T) { + const files = 10000 targets := []engine.Target{{ ID: "files", Format: "txt", - Sizes: engine.Uniform(4000, 4096), + Sizes: engine.Uniform(files, 4096), }} - files, err := engine.Plan(targets, engine.Options{ + planned, err := engine.Plan(targets, engine.Options{ OutDir: "out", ManifestName: "manifest.json", // Zero means the real ceiling, which is what every caller passes. }) if err != nil { - t.Fatalf("four thousand text files were refused under the real ceiling: %v", err) + t.Fatalf("ten thousand text files were refused under the real ceiling: %v", err) } - if len(files) != 4000 { - t.Fatalf("planned %d files, expected 4000", len(files)) + if len(planned) != files { + t.Fatalf("planned %d files, expected %d", len(planned), files) } } diff --git a/internal/guard/pointerfocus_test.go b/internal/guard/pointerfocus_test.go index 027c7999..f9db904d 100644 --- a/internal/guard/pointerfocus_test.go +++ b/internal/guard/pointerfocus_test.go @@ -43,6 +43,16 @@ func TestAPressMovesTheKeyboardWithoutDrawingItsMark(t *testing.T) { t.Error("the format menu was pressed with the pointer and drew the keyboard mark, " + "so the value chosen sits on a blue box until something else is clicked") } + // And the keyboard IS there. The name of this guard promised that from + // the start and until 2026-09-16 only the second half was asked, so a + // control that took the keyboard nowhere at all passed it - which is what + // the switch below did: the driver unfocuses whatever had the keyboard on + // a press and leaves focusing to the widget, and the switch left it. + // On the list the press opened, which is where the arrows have to work. + // Closing it hands the keyboard back to the menu (see Chooser.giveBack). + if list := menu.Opened(); list == nil || c.Focused() != fyne.Focusable(list) { + t.Errorf("the format menu was pressed and the keyboard is on %T, not on the list it opened", c.Focused()) + } // The list the press opened is taken away first. A real press respects what // covers it - which is the point of pressing for real - so leaving an open @@ -71,6 +81,46 @@ func TestAPressMovesTheKeyboardWithoutDrawingItsMark(t *testing.T) { t.Error("the switch was pressed with the pointer and drew the keyboard mark, " + "which is the blue disc behind the square that was reported on 2026-08-18") } + if c.Focused() != toggle { + t.Errorf("the switch was pressed and the keyboard is on %T, not on the switch - so the next "+ + "Space flips nothing and the next Tab starts from the top of the screen", c.Focused()) + } +} + +// A press on a segmented switch puts the keyboard on it quietly, and the +// first arrow says so. +// +// The same rule as the two above, on the third control that can hold the +// keyboard. Asked on a bare canvas rather than on a screen, because a press +// aimed at one segment needs a position, and the segment widths are the +// control's own to know. +func TestAPressOnASegmentedSwitchPutsTheKeyboardOnItQuietly(t *testing.T) { + app := test.NewApp() + app.Settings().SetTheme(parts.Theme()) + t.Cleanup(func() { test.NewApp() }) + + s := parts.NewSegments([]string{"one", "two", "three"}, nil) + w := test.NewWindow(s) + t.Cleanup(w.Close) + w.Resize(s.MinSize()) + + test.TapAt(s, fyne.NewPos(s.Size().Width-1, s.Size().Height/2)) + if s.Selected != "three" { + t.Fatalf("the press on the last segment chose %q, so this guard never reached the state it is about", s.Selected) + } + if w.Canvas().Focused() != s { + t.Errorf("the switch was pressed and the keyboard is on %T, not on the switch", w.Canvas().Focused()) + } + if s.Marked() { + t.Error("the switch was pressed with the pointer and drew the keyboard mark") + } + s.TypedKey(&fyne.KeyEvent{Name: fyne.KeyLeft}) + if !s.Marked() { + t.Error("an arrow was pressed on the switch and the mark that says the keyboard is here did not come on") + } + if s.Selected != "two" { + t.Errorf("the arrow after the press moved to %q, not on from the segment that was pressed", s.Selected) + } } // The other half, and it is the half that makes the first one safe. @@ -144,7 +194,7 @@ func TestEverySwitchAndMenuOnScreenKnowsWhoFocusedIt(t *testing.T) { case *parts.Toggle, *parts.Chooser: seen++ case *widget.Check: - raw = append(raw, fmt.Sprintf("the switch %q", control.Text)) + raw = append(raw, "the switch") case *widget.Select: raw = append(raw, fmt.Sprintf("the menu showing %q", control.Selected)) } @@ -187,6 +237,14 @@ func TestEverySwitchAndMenuOnScreenKnowsWhoFocusedIt(t *testing.T) { // A canvas rather than the bare tree, because everything this file asks about // needs one: focus belongs to a canvas, and a press has to land somewhere. func screenOnACanvas(t *testing.T) (fyne.Canvas, fyne.CanvasObject) { + t.Helper() + return screenOnACanvasOfHeight(t, referenceHeight) +} + +// screenOnACanvasOfHeight is the same screen in a window this tall, for a +// guard asking about something that follows the window - an open list is as +// tall as a share of it, so one height proves nothing about the rule. +func screenOnACanvasOfHeight(t *testing.T, height float32) (fyne.Canvas, fyne.CanvasObject) { t.Helper() // A fresh application and our own theme, the same two lines renderScene // opens with. Without them the sizes are the test driver's rather than the @@ -210,7 +268,7 @@ func screenOnACanvas(t *testing.T) (fyne.Canvas, fyne.CanvasObject) { // that is not the width it ends up with. A press aimed at a control that // has not settled lands somewhere else, which reads as a control that // cannot be pressed. - size := fyne.NewSize(referenceWidth, referenceHeight) + size := fyne.NewSize(referenceWidth, height) w.Resize(size) w.Resize(size) host.content.Refresh() diff --git a/internal/guard/presetwindow_test.go b/internal/guard/presetwindow_test.go index 509063d4..bb7b6733 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,8 +391,8 @@ 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 { - labels[l.Text] = true + if words, ok := wordsOf(obj); ok { + labels[words] = true } }) for _, catch := range p.Catches { diff --git a/internal/guard/progress_test.go b/internal/guard/progress_test.go index a34d2795..907213ff 100644 --- a/internal/guard/progress_test.go +++ b/internal/guard/progress_test.go @@ -126,14 +126,21 @@ func TestProgressStaysOffWhenNothingIsWatching(t *testing.T) { t.Fatalf("creating the log: %v", err) } - // Enough bytes that the run outlasts the interval between redraws. With a - // smaller run the bar would stay silent because there was no time to draw + // Enough work that the run outlasts the interval between redraws. With a + // shorter run the bar would stay silent because there was no time to draw // it, and the guard would pass without ever reaching the question it asks. // That is not hypothetical - it is what the first version of this test did, - // and the mutation runner is what said so. + // and the mutation runner is what said so. And it said so a second time: + // eight files of 8 MB were enough until the files of a run were written + // over several threads, after which the whole test took 40 ms on + // 2026-09-16 - under the 100 ms before the bar draws for the first time - + // and the mutant that draws to a log file drew nothing. Many small files + // instead, because their cost is one system call sequence per file, which + // no faster disk makes cheap: 2000 of 4 kB took about 1.2 s when measured + // for docs/PERFORMANCE-REVIEW-2026-09-05.md, twelve times the interval. var out bytes.Buffer code := cli.Run(context.Background(), []string{ - "generate", "--format", "txt", "--size", "8mb", "--count", "8", + "generate", "--format", "txt", "--size", "4kb", "--count", "2000", "--out", filepath.Join(dir, "files"), }, &out, logFile) logFile.Close() diff --git a/internal/guard/reachability_test.go b/internal/guard/reachability_test.go index b4fa040c..5db019b9 100644 --- a/internal/guard/reachability_test.go +++ b/internal/guard/reachability_test.go @@ -9,7 +9,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/test" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" @@ -95,7 +94,7 @@ func TestEveryButtonAPersonCanSeeIsReallyPressable(t *testing.T) { checked, unreachable := 0, 0 walk(screen, func(o fyne.CanvasObject) { - button, ok := o.(*widget.Button) + button, ok := o.(*parts.Button) if !ok || !button.Visible() { return } @@ -176,26 +175,11 @@ func TestTabbingReachesTheControlsAndSaysInWhatOrder(t *testing.T) { } onScreen[f] = true }) - // A switch keeps its options in its renderer rather than in the - // tree, so walk cannot see them and the focus manager can - it uses - // the toolkit's own visible tree, which goes through renderers. - // Without this the batch screen reported three controls that Tab - // reaches and the screen does not have, which is a guard describing - // its own blind spot as a defect. Measured 2026-08-25, when the - // three ways of stating a size became a switch. - walk(screen, func(o fyne.CanvasObject) { - group, ok := o.(*widget.RadioGroup) - if !ok || !group.Visible() || buried[o] { - return - } - for _, part := range test.WidgetRenderer(group).Objects() { - walk(part, func(inner fyne.CanvasObject) { - if f, ok := inner.(fyne.Focusable); ok { - onScreen[f] = true - } - }) - } - }) + // The three ways of stating a size are one control now, not three + // radio circles, so there is nothing hidden in a renderer to reach + // for - the segmented switch is one focusable the walk above + // already finds. The block that dug into widget.RadioGroup's + // renderer went with the radio on 2026-09-15. // The chain, walked until it repeats. The ceiling exists because a // screen with nothing focusable would otherwise be walked forever, @@ -266,12 +250,25 @@ func TestTabbingReachesTheControlsAndSaysInWhatOrder(t *testing.T) { // fine" would have turned this assertion off, and the thing it // catches - focus wandering into the OTHER tabs, which are in the // canvas and laid out - is exactly what it was written for. + // + // The words on the strip are chrome as well since 2026-09-15, when + // the strip became ours and its words learnt to hold the keyboard - + // the toolkit's never could. Exactly the words the window lists, + // asked of the window rather than written here, so a fifth screen + // is covered the day it arrives and a stray word is not. chrome := map[string]bool{text.ButtonDonate(): true} + strip := map[string]bool{} + for _, name := range tabNames(content) { + strip[name] = true + } for _, f := range order { if onScreen[f] { continue } - if button, ok := f.(*widget.Button); ok && chrome[button.Text] { + if button, ok := f.(*parts.Button); ok && chrome[button.Text] { + continue + } + if word, ok := f.(*parts.TabWord); ok && strip[word.Text()] { continue } t.Errorf("Tab reaches %s, which is not on the %q screen. Focus is leaving "+ @@ -280,17 +277,28 @@ func TestTabbingReachesTheControlsAndSaysInWhatOrder(t *testing.T) { // And the window chrome has to be reachable at all, from every // screen, or the button nobody can Tab to is the one asking for - // money. + // money - and the strip nobody can Tab to is the one that leads to + // every other screen. reachedChrome := false + reachedWords := map[string]bool{} for _, f := range order { - if button, ok := f.(*widget.Button); ok && chrome[button.Text] { + if button, ok := f.(*parts.Button); ok && chrome[button.Text] { reachedChrome = true } + if word, ok := f.(*parts.TabWord); ok { + reachedWords[word.Text()] = true + } } if !reachedChrome { t.Errorf("the %q button cannot be reached with Tab from the %q screen (UX9)", text.ButtonDonate(), tab) } + for name := range strip { + if !reachedWords[name] { + t.Errorf("the %q word on the strip cannot be reached with Tab from the %q screen, "+ + "so the keyboard has no way to another screen (UX9)", name, tab) + } + } }) } } @@ -305,9 +313,11 @@ func describeFocusable(f fyne.Focusable) string { } return fmt.Sprintf("a box holding %q", control.Text) case *parts.Toggle: - return fmt.Sprintf("the toggle %q", control.Text) - case *widget.Button: + return "the toggle" + case *parts.Button: return fmt.Sprintf("the %q button", control.Text) + case *parts.TabWord: + return fmt.Sprintf("the %q word on the strip", control.Text()) } return fmt.Sprintf("%T", f) } diff --git a/internal/guard/recipescreen_test.go b/internal/guard/recipescreen_test.go index c4f60f6b..a7c99e8b 100644 --- a/internal/guard/recipescreen_test.go +++ b/internal/guard/recipescreen_test.go @@ -10,7 +10,6 @@ import ( "testing" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/widget" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" @@ -288,12 +287,12 @@ func allSaid(fields *parts.Fields) string { // buttonsNamed is every button carrying one label, in the order they are drawn. // Several rather than one, because a repeating block has a button per repetition // and which one was pressed is the whole question. -func buttonsNamed(o fyne.CanvasObject, name string) []*widget.Button { - var out []*widget.Button +func buttonsNamed(o fyne.CanvasObject, name string) []*parts.Button { + var out []*parts.Button // walk from window_test.go, which knows to step inside a Card - every field // on these screens sits in one, and nothing below it is reachable otherwise. walk(o, func(child fyne.CanvasObject) { - if b, ok := child.(*widget.Button); ok && b.Text == name { + if b, ok := child.(*parts.Button); ok && b.Text == name { out = append(out, b) } }) diff --git a/internal/guard/refusalplacement_test.go b/internal/guard/refusalplacement_test.go index 956d70fa..deb30be7 100644 --- a/internal/guard/refusalplacement_test.go +++ b/internal/guard/refusalplacement_test.go @@ -6,7 +6,6 @@ import ( "fyne.io/fyne/v2" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) @@ -153,7 +152,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 words, is := wordsOf(obj); is && strings.Contains(words, refusal) { seen++ } }) @@ -176,7 +175,7 @@ func fieldBox(o fyne.CanvasObject, label string) *fyne.Container { if !ok || len(box.Objects) < 2 || isHeadingExtra(box.Objects[1]) { return } - if head := headingOf(box.Objects[0]); head != nil && head.Text == label { + if head, named := headingOf(box.Objects[0]); named && head == label { found = box } }) @@ -215,8 +214,8 @@ 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 words, ok := wordsOf(obj); ok && words != "" { + out = append(out, words) } }) return strings.Join(out, "\n") diff --git a/internal/guard/refusalwidth_test.go b/internal/guard/refusalwidth_test.go index a26820df..781c6835 100644 --- a/internal/guard/refusalwidth_test.go +++ b/internal/guard/refusalwidth_test.go @@ -5,52 +5,71 @@ import ( "testing" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" ) // A refusal gets the width of the form to say what it has to say. // // A refusal in this tool has four parts - what happened, why, what is allowed, -// what to do instead - which is a sentence and not a word. Two fields share a -// row, so a message about one of them used to be laid out in half the form. -// Measured off a render on 2026-08-20: a size below what BMP can make wrapped -// onto four lines in the left column while the right half of the panel was -// empty, and those four lines pushed everything under them down by three. +// what to do instead - which is a sentence and not a word. Where fields share +// a row, a message about one of them used to be laid out in a fraction of the +// form. Measured off a render on 2026-08-20: a size below what BMP can make +// wrapped onto four lines in the left column while the right half of the +// panel was empty, and those four lines pushed everything under them down by +// three. // // The controls share the row and the messages do not, now. Asserted against // the row rather than against a number of pixels: what has to hold is that a // message is not confined to the column its field is in. +// +// Asked on the batch screen, because that is where fields still share a row - +// the format, count and size of a file inside an archive stand in cells of +// one row (Fields.Row). The generate screen has had one field to a row since +// the form became a grid on 2026-09-14, and this guard read that screen until +// 2026-09-16: the full mutation run found it green while Fields.Row put every +// message back into its cell, because nothing it measured went through +// Fields.Row any more. func TestARefusalIsAsWideAsTheFormRatherThanItsColumn(t *testing.T) { - content, w, host := screenInAWindowWithHost(t, text.TabOneTarget()) + screen := window.NewRecipe(newFakeHost(t)) + body := screen.Object() + w := test.NewWindow(body) + t.Cleanup(w.Close) + fields := screen.Fields() - // A size no format can produce, so the refusal lands on the size box - and - // the size box shares its row with how many. - fill(t, content, text.FieldSize(), "1") - press(t, content, text.ButtonGenerate()) - // The refusal comes back from a worker since 2026-08-26, so it is waited - // for before the tree is laid out and read. - join(host) - settle(content, w) + // A batch that is fine on its own, holding one file with a count and no + // size, so the refusal from the reader lands on the size cell of the + // contents row - one of its four columns, 185 px of the 788 the row has. + setBox(t, fields, recipe.TargetAddress(1, recipe.KeyID), "filled") + setBox(t, fields, recipe.TargetAddress(1, recipe.KeySize), "10kb") + chooserIn(t, fields, recipe.TargetAddress(1, recipe.KeyFormat)).SetSelected("zip") + pressNamed(t, body, text.ButtonAddContents()) + setBox(t, fields, recipe.ContentAddress(1, 1, recipe.KeyCount), "1") + pressNamed(t, body, text.ButtonPreview()) + settle(body, w) - said := refusalLabelSaying(content, strings.ToUpper(firstFormat())) - if said == nil { - t.Fatalf("nothing on the screen is complaining about the size. It says:\n%s", textIn(content)) + at := recipe.ContentAddress(1, 1, recipe.KeySize) + saying := saidBy(t, fields, at) + if saying == "" { + t.Fatalf("nothing is marked at %q, so there is no refusal to measure.\n%s", at, allSaid(fields)) } - box := controlUnder(content, text.FieldSize()) - if box == nil { - t.Fatal("the screen has no size box, so this guard read the wrong tree") + said := refusalLabelSaying(body, saying) + if said == nil { + t.Fatalf("the size cell says %q and no red label on the screen carries it", saying) } - // Halfway between one column and the whole form, so the assertion holds + // Halfway between one cell and the whole form, so the assertion holds // whatever the padding does and fails the moment the message goes back - // into a column. + // into a cell. half := float32(parts.ColumnWidth) / 2 if said.Size().Width <= half { - t.Errorf("the refusal about the size is %.0f px wide, which is no more than the %.0f px column it sits in."+ - " A message with four parts in half a form is a message that wraps four times", + t.Errorf("the refusal about the size is %.0f px wide, which is no more than the %.0f px cell it sits in."+ + " A message with four parts in a quarter of a form is a message that wraps four times", said.Size().Width, half) } } diff --git a/internal/guard/regressiontable_test.go b/internal/guard/regressiontable_test.go index f68cb685..de9f8180 100644 --- a/internal/guard/regressiontable_test.go +++ b/internal/guard/regressiontable_test.go @@ -53,7 +53,6 @@ var notYetJustified = []string{ "keyboard_test.go", "livecheck_test.go", "openlist_test.go", - "pointerfocus_test.go", "recipescreen_test.go", "refusaladdress_test.go", } diff --git a/internal/guard/requiredmark_test.go b/internal/guard/requiredmark_test.go index 7a2ed108..fecdc846 100644 --- a/internal/guard/requiredmark_test.go +++ b/internal/guard/requiredmark_test.go @@ -4,7 +4,6 @@ import ( "testing" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/widget" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" @@ -128,13 +127,13 @@ func starsOnScreen(o fyne.CanvasObject) map[string]bool { if !ok || len(row.Objects) < 2 { return } - head, isLabel := row.Objects[0].(*widget.Label) - if !isLabel { + head, named := wordsOf(row.Objects[0]) + if !named { return } for _, item := range row.Objects[1:] { if _, star := item.(*parts.RequiredMark); star { - stars[head.Text] = true + stars[head] = true } } }) diff --git a/internal/guard/runline_test.go b/internal/guard/runline_test.go new file mode 100644 index 00000000..68a66691 --- /dev/null +++ b/internal/guard/runline_test.go @@ -0,0 +1,192 @@ +package guard + +import ( + "strings" + "testing" + + "fyne.io/fyne/v2" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" +) + +// The line at the foot of a form says what the form comes to, and says it +// LIVE. +// +// G6: the window says what a run will cost before anything is pressed. Until +// 2026-09-14 the bar said one thing at rest - the directory - and gave it up +// at the first press for good, so after a preview the line carried a cost +// worked out for values somebody had typed over since. The line is worked +// out from the form on every change, which is what these guards hold it to: +// the count and the total follow the boxes, the kind follows the MENU, a +// form that does not settle falls back to naming the destination, and a +// change of the form puts the summary back over whatever a press said. +// +// Read off the status line - the label that shares a box with the progress +// track - rather than searched for anywhere on the screen, so words in the +// wrong place would not pass. + +// The line's count and total follow what is typed, to the byte. +func TestTheLineSaysWhatTheFormComesTo(t *testing.T) { + _, content := screen(t) + fill(t, content, text.FieldSize(), "4kb") + fill(t, content, text.FieldCount(), "3") + + format := chooserUnder(t, content, text.FieldFormat()).Selected + dir := entryUnder(t, content, text.FieldOutputDir()).Text + if format == "" || dir == "" { + t.Fatal("the format menu or the output directory box is empty, so this guard has nothing to hold the line to") + } + // 3 x 4096, said the way the line says a size: readable, then exact. + want := text.RunLine(3, text.SizeAndBytes(core.HumanBytes(12288), core.ExactBytes(12288)), + []string{format}, dir) + if got := statusLine(t, content); got != want { + t.Errorf("three files of 4kb make the line say\n %q\nand they add up to\n %q", got, want) + } +} + +// The kind follows the menu, not only the boxes. +// +// Boxes reported every keystroke to the runner from the start, and menus +// reported nothing - the only listener was the live check, and a menu has +// no value the check could refuse. A line fed by that mechanism named the +// format chosen when the screen was built, whatever was chosen since. +func TestTheLineFollowsTheMenu(t *testing.T) { + _, content := screen(t) + choose(t, content, text.FieldFormat(), "png") + if got := statusLine(t, content); !strings.Contains(got, text.Formats([]string{"png"})) { + t.Fatalf("png was chosen and the line says %q, so this guard cannot tell whether the next choice reaches it", got) + } + choose(t, content, text.FieldFormat(), "txt") + got := statusLine(t, content) + if !strings.Contains(got, text.Formats([]string{"txt"})) || strings.Contains(got, "png") { + t.Errorf("txt was chosen from the menu and the line still says %q", got) + } +} + +// A form that does not settle falls back to naming the destination, rather +// than keeping the summary of the last form that did. The destination is +// read off its own box, and that box is fine. +func TestTheLineFallsBackToTheDestinationWhenTheFormDoesNotSettle(t *testing.T) { + _, content := screen(t) + fill(t, content, text.FieldCount(), "3") + if got := statusLine(t, content); !strings.HasPrefix(got, "3 files") { + t.Fatalf("the line says %q before the box is spoiled, so this guard is not in the state it means to check", got) + } + + fill(t, content, text.FieldCount(), "three") + + dir := entryUnder(t, content, text.FieldOutputDir()).Text + if got, want := statusLine(t, content), text.WritingTo(dir); got != want { + t.Errorf("the count box says \"three\" and the line says %q - the honest line is %q", got, want) + } +} + +// A form drawing its sizes from a range says so, with both ends, instead of +// adding up numbers that have not been drawn yet. +func TestTheLineSaysBetweenForARange(t *testing.T) { + batches := window.NewRecipe(newFakeHost(t)).Object() + // A batch settles only once it has a name - the name anchors its seed - + // so the screen opens naming the destination alone and this fills it in. + entryUnder(t, batches, text.FieldTargetID()).SetText("spread") + entryUnder(t, batches, text.FieldCount()).SetText("2") + chooseSizeWay(t, batches, text.SizeWayRange()) + entryUnder(t, batches, text.FieldSizeRange()).SetText("1kb-8kb") + + want := text.SizeBetween(core.HumanBytes(2*1024), core.HumanBytes(2*8192)) + got := statusLine(t, batches) + if !strings.Contains(got, want) { + t.Errorf("two files of 1kb-8kb make the line say %q, and the honest total is %q", got, want) + } + if !strings.HasPrefix(got, "2 files") { + t.Errorf("two files were asked for and the line says %q", got) + } +} + +// After a preview the range is drawn, so the line says one number - and the +// disk has been asked, so the destination carries the room left on it - and +// the line says that nothing was written. +func TestAPreviewMakesTheLineExact(t *testing.T) { + host := newFakeHost(t) + batches := window.NewRecipe(host).Object() + t.Cleanup(func() { join(host) }) + dir := t.TempDir() + entryUnder(t, batches, text.FieldOutputDir()).SetText(dir) + entryUnder(t, batches, text.FieldTargetID()).SetText("spread") + entryUnder(t, batches, text.FieldCount()).SetText("2") + chooseSizeWay(t, batches, text.SizeWayRange()) + entryUnder(t, batches, text.FieldSizeRange()).SetText("1kb-8kb") + if got := statusLine(t, batches); !strings.Contains(got, "between") { + t.Fatalf("before the preview the line says %q, so this guard is not in the state it means to check", got) + } + + press(t, batches, text.ButtonPreview()) + join(host) + + got := statusLine(t, batches) + if strings.Contains(got, "between") { + t.Errorf("the preview drew every size and the line still says %q", got) + } + if !strings.Contains(got, text.AndNothingWrittenYet()) { + t.Errorf("after the preview the line says %q, which does not say that nothing was written", got) + } + // The room on the disk, beside the destination. + if !strings.Contains(got, dir+" (") || !strings.Contains(got, "free)") { + t.Errorf("after the preview the line says %q, which does not carry the room left in %q", got, dir) + } +} + +// A change of the form puts the summary back over what a press said. +// +// The line used to be one way: the destination until the first press, then +// whatever the press said, for good - so after a preview, editing a box left +// a cost on the line worked out for values that were no longer on the form. +// Now the form owns the line at rest, and a press borrows it. +func TestAChangeOfTheFormPutsTheLineBackOverWhatAPressSaid(t *testing.T) { + host, content := screen(t) + fill(t, content, text.FieldOutputDir(), t.TempDir()) + fill(t, content, text.FieldSize(), "4kb") + fill(t, content, text.FieldCount(), "3") + press(t, content, text.ButtonPreview()) + join(host) + if got := statusLine(t, content); !strings.Contains(got, text.AndNothingWrittenYet()) { + t.Fatalf("the preview left the line saying %q, so this guard is not in the state it means to check", got) + } + + fill(t, content, text.FieldCount(), "4") + + got := statusLine(t, content) + if strings.Contains(got, text.AndNothingWrittenYet()) { + t.Errorf("the count was changed after the preview and the line still says %q - a cost worked out for a form that no longer exists", got) + } + if !strings.HasPrefix(got, "4 files") { + t.Errorf("the count was changed to four and the line says %q", got) + } +} + +// statusLine is what the line under the buttons says. +func statusLine(t *testing.T, screen fyne.CanvasObject) string { + t.Helper() + _, status := runMessages(screen) + if status == nil { + t.Fatal("this screen has no status line, so this guard read the wrong tree") + } + if !status.Visible() { + return "" + } + return status.Text +} + +// absoluteOf is where an object stands on the screen, from the screen's +// origin rather than its parent's. +func absoluteOf(root, target fyne.CanvasObject) (fyne.Position, bool) { + var at fyne.Position + found := false + atAbsolute(root, func(o fyne.CanvasObject, pos fyne.Position) { + if o == target && !found { + at, found = pos, true + } + }) + return at, found +} diff --git a/internal/guard/samename_test.go b/internal/guard/samename_test.go index b8d3b0f5..705ea85b 100644 --- a/internal/guard/samename_test.go +++ b/internal/guard/samename_test.go @@ -4,9 +4,8 @@ import ( "testing" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) @@ -26,28 +25,40 @@ func TestNoSectionIsNamedAfterAFieldInsideIt(t *testing.T) { ourTheme(t) content, _ := laidOutWindow(t) + // Counted so that the comparison cannot quietly happen nowhere: a screen + // with sections and no readable field names is skipped, and if every + // screen were, this guard would be green over nothing. + compared := 0 for _, tab := range allTabs() { t.Run(tab, func(t *testing.T) { screen := tabContent(t, content, tab) sections := map[string]bool{} - fields := map[string]bool{} atAbsolute(screen, func(o fyne.CanvasObject, _ fyne.Position) { - label, is := o.(*widget.Label) - if !is || label.Text == "" || !label.TextStyle.Bold { - return - } - switch label.SizeName { - case theme.SizeNameHeadingText: - sections[label.Text] = true - case "", theme.SizeNameText: - fields[label.Text] = true + words, bold, size, is := boldWordsAt(o) + if is && words != "" && bold && size == parts.TextHeading { + sections[words] = true } }) + // Read off the rows of the form rather than by weight. Until + // 2026-09-16 a field was "bold words at the body size", which + // every field's name was until the form became a grid on + // 2026-09-14 and names went to regular weight - from that day + // this map was empty on every screen, the comparison below was + // over nothing, and the full mutation run found the guard green + // with the preset card named after its field again. + fields := map[string]bool{} + for _, name := range fieldNamesOn(screen) { + fields[name] = true + } if len(sections) == 0 { t.Skipf("%s draws no sections, so there is nothing to compare", tab) } + if len(fields) == 0 { + t.Skipf("%s draws no field with a name, so there is nothing to compare", tab) + } + compared++ for name := range sections { if fields[name] { t.Errorf("%q is the name of a section and the name of a field on the same screen."+ @@ -56,6 +67,9 @@ func TestNoSectionIsNamedAfterAFieldInsideIt(t *testing.T) { } }) } + if compared == 0 { + t.Fatal("no screen had both sections and named fields, so nothing was compared") + } } // And the preset card still says what it is for. diff --git a/internal/guard/sbom_test.go b/internal/guard/sbom_test.go index ad8ebe2e..11f0767f 100644 --- a/internal/guard/sbom_test.go +++ b/internal/guard/sbom_test.go @@ -162,7 +162,9 @@ func TestTheSBOMIsTheSameBytesForTheSameInputs(t *testing.T) { } // sbomInput is the document as a release would ask for it, with the versions -// read from the build on every system - the Linux only module is why. +// read from the build on every system - the Linux only module is why - and, +// since 2026-09-15, with the packages of our own module each binary links, +// which is what tells the window's font from the command line binary. func sbomInput(t *testing.T) legal.Document { t.Helper() window, _ := shipped(t, "../../cmd/tfg-gui") @@ -177,8 +179,8 @@ func sbomInput(t *testing.T) legal.Document { Created: "2026-08-28T00:00:00Z", Seed: "guard", Binaries: []legal.Binary{ - {Name: "tfg", Modules: command, GoVersion: runtime.Version()}, - {Name: "tfg-gui", Modules: window, GoVersion: runtime.Version()}, + {Name: "tfg", Modules: command, Packages: ourPackagesLinkedBy(t, "../../cmd/tfg", false), GoVersion: runtime.Version()}, + {Name: "tfg-gui", Modules: window, Packages: ourPackagesLinkedBy(t, "../../cmd/tfg-gui", false), GoVersion: runtime.Version()}, }, } } diff --git a/internal/guard/screenpixels_test.go b/internal/guard/screenpixels_test.go index 19936fcf..7d22a222 100644 --- a/internal/guard/screenpixels_test.go +++ b/internal/guard/screenpixels_test.go @@ -16,9 +16,9 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/test" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/catalogue" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" @@ -298,10 +298,19 @@ type screenScene struct { tab string set func(t *testing.T, s scene) after func(t *testing.T, s scene) + // page is a screen that is not a tab of the window, built on its own and + // drawn at its whole height rather than a screenful: the catalogue of + // parts, since 2026-09-15. Nil for the four screens the window opens on. + page func() fyne.CanvasObject } func screenScenes() []screenScene { return []screenScene{ + // The whole catalogue, every part in every state, as one picture. + // This is the one scene where a change of a token shows on every + // control at once, which is what makes it the before-and-after of + // every later step - and why it is the first in the list. + {name: "catalogue", page: catalogue.Page}, {name: "about", tab: text.TabAbout()}, {name: "generate", tab: text.TabOneTarget()}, {name: "generate-empty", tab: text.TabOneTarget(), set: func(t *testing.T, s scene) { @@ -748,11 +757,17 @@ func renderScene(t *testing.T, sc screenScene) (image.Image, string) { defer test.NewApp() host := newFakeHost(t) - window.Open(host) - if host.content == nil { - t.Fatal("opening the window put no screen in it") + var tab fyne.CanvasObject + if sc.page != nil { + host.SetContent(sc.page()) + tab = host.content + } else { + window.Open(host) + if host.content == nil { + t.Fatal("opening the window put no screen in it") + } + tab = selectTab(t, host.content, sc.tab) } - tab := selectTab(t, host.content, sc.tab) // The window comes before the state rather than after it, so a state that // needs a canvas - focus, an open menu - has one to act on. @@ -784,6 +799,15 @@ func renderScene(t *testing.T, sc screenScene) (image.Image, string) { w.Resize(size) host.content.Refresh() + // A page is drawn whole: as tall as it says it is once it knows its width, + // which it only knows after the two passes above. + if sc.page != nil { + whole := fyne.NewSize(referenceWidth, host.content.MinSize().Height) + w.Resize(whole) + w.Resize(whole) + host.content.Refresh() + } + if sc.after != nil { sc.after(t, s) } @@ -921,8 +945,8 @@ func explanationBeside(t *testing.T, o fyne.CanvasObject, label string) *parts.D if !ok || len(row.Objects) < 2 || found != nil { return } - head, isLabel := row.Objects[0].(*widget.Label) - if !isLabel || head.Text != label { + head, named := wordsOf(row.Objects[0]) + if !named || head != label { return } // Searched, not indexed - see detailButtonIn. A field that has to be diff --git a/internal/guard/sectionsurface_test.go b/internal/guard/sectionsurface_test.go index 936ff1ce..8bf08255 100644 --- a/internal/guard/sectionsurface_test.go +++ b/internal/guard/sectionsurface_test.go @@ -41,7 +41,7 @@ func TestASectionDrawsItsOwnSurface(t *testing.T) { // it matters more there: the bar is pinned over a form that scrolls // underneath it, so a bar with no surface is a bar with text sliding // through its buttons. That is what it was built to stop. - {"the action bar", parts.ActionBar(widget.NewButton("Generate", nil))}, + {"the action bar", parts.ActionBar(parts.NewButton(parts.Primary, "Generate", nil))}, } { var found []*canvas.Rectangle walk(subject.tree, func(o fyne.CanvasObject) { diff --git a/internal/guard/settingslot_test.go b/internal/guard/settingslot_test.go index 68967c19..876b2808 100644 --- a/internal/guard/settingslot_test.go +++ b/internal/guard/settingslot_test.go @@ -194,7 +194,7 @@ func TestEveryNameARefusalCanBeGivenTakesTheArticleThisRuleGivesIt(t *testing.T) "Limit to test": "a", "One size": "a", "A range": "a", "Rule being tested": "a", "Manifest file name": "a", "Preset": "a", "Limit": "a", "Spread": "a", "Width": "a", "Height": "a", "Quality": "a", - "Write a label inside each file": "a", + "Label in each file": "a", } check := func(name, source string) { diff --git a/internal/guard/sizechoice_test.go b/internal/guard/sizechoice_test.go index 8ed04220..1e4a4359 100644 --- a/internal/guard/sizechoice_test.go +++ b/internal/guard/sizechoice_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" ) @@ -27,10 +28,10 @@ import ( // sizeWaySwitches are the controls that choose how each batch says how big, in // the order the batches are drawn in. -func sizeWaySwitches(o fyne.CanvasObject) []*widget.RadioGroup { - var found []*widget.RadioGroup +func sizeWaySwitches(o fyne.CanvasObject) []*parts.Segments { + var found []*parts.Segments walk(o, func(obj fyne.CanvasObject) { - if group, ok := obj.(*widget.RadioGroup); ok { + if group, ok := obj.(*parts.Segments); ok { found = append(found, group) } }) @@ -38,14 +39,14 @@ func sizeWaySwitches(o fyne.CanvasObject) []*widget.RadioGroup { } // sizeWaySwitch is the one belonging to the first batch. -func sizeWaySwitch(t *testing.T, o fyne.CanvasObject) *widget.RadioGroup { +func sizeWaySwitch(t *testing.T, o fyne.CanvasObject) *parts.Segments { t.Helper() return sizeWaySwitchIn(t, o, 1) } // sizeWaySwitchIn is the one belonging to a batch counted from one, the way // every address on this screen counts. -func sizeWaySwitchIn(t *testing.T, o fyne.CanvasObject, position int) *widget.RadioGroup { +func sizeWaySwitchIn(t *testing.T, o fyne.CanvasObject, position int) *parts.Segments { t.Helper() all := sizeWaySwitches(o) if position < 1 || position > len(all) { @@ -185,25 +186,25 @@ func TestOnlyTheChosenWayOfStatingASizeReachesTheRun(t *testing.T) { // everything, then everything under each hidden thing, and takes the second // away from the first. func shownText(o fyne.CanvasObject) string { - var all []*widget.Label + var all []fyne.CanvasObject var hidden []fyne.CanvasObject walk(o, func(obj fyne.CanvasObject) { - if l, ok := obj.(*widget.Label); ok && l.Text != "" { - all = append(all, l) + if words, ok := wordsOf(obj); ok && words != "" { + all = append(all, obj) } if obj != nil && !obj.Visible() { hidden = append(hidden, obj) } }) - out := map[*widget.Label]bool{} + out := map[fyne.CanvasObject]bool{} for _, l := range all { out[l] = true } for _, root := range hidden { walk(root, func(obj fyne.CanvasObject) { - if l, ok := obj.(*widget.Label); ok { - delete(out, l) + if _, ok := wordsOf(obj); ok { + delete(out, obj) } }) } @@ -211,7 +212,8 @@ func shownText(o fyne.CanvasObject) string { var said []string for _, l := range all { if out[l] { - said = append(said, l.Text) + words, _ := wordsOf(l) + said = append(said, words) } } return strings.Join(said, "\n") diff --git a/internal/guard/smallfixes_test.go b/internal/guard/smallfixes_test.go index 571a3d83..de2bec0b 100644 --- a/internal/guard/smallfixes_test.go +++ b/internal/guard/smallfixes_test.go @@ -100,15 +100,25 @@ func TestTheRecordedCommandCanBeRunAgain(t *testing.T) { // The name carries this process's id, so the test can build the exact one the // run is about to use. That is also why it is unlikely to be met by accident // and easy to leave behind: a run killed outright leaves exactly this shape. +// +// The name is the SECOND file's, and the run is refused before the first file +// is written. Two things defend the file itself - the check before the run +// and the single door every file is created through - and until 2026-09-16 +// this guard could not tell them apart: the full mutation run found it green +// with the check before the run gone, because the door still refused the +// name mid-run, the exit code was still not zero and the file was still +// whole. What the check before the run adds is that nothing is written at +// all, which is what a refusal about a collision the tool can see in advance +// owes the person - not a directory holding half a run and a refusal. func TestAFileUnderTheTemporaryNameIsNotWrittenOver(t *testing.T) { dir := t.TempDir() - name := fmt.Sprintf("files_0001.txt.tfg-partial-%d", os.Getpid()) + name := fmt.Sprintf("files_0002.txt.tfg-partial-%d", os.Getpid()) victim := filepath.Join(dir, name) if err := os.WriteFile(victim, []byte("somebody's own work\n"), 0o644); err != nil { t.Fatalf("writing: %v", err) } - code, _, errOut := run(t, "generate", "--format", "txt", "--size", "1kb", "--out", dir) + code, _, errOut := run(t, "generate", "--format", "txt", "--size", "1kb", "--count", "3", "--out", dir) if code == cli.ExitOK { t.Error("a run wrote through a name that was already taken") } @@ -120,6 +130,18 @@ func TestAFileUnderTheTemporaryNameIsNotWrittenOver(t *testing.T) { if string(body) != "somebody's own work\n" { t.Errorf("the file was written over:\n%q\n%s", body, errOut) } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("listing: %v", err) + } + for _, e := range entries { + if e.Name() != name { + t.Errorf("the run was refused for a name it could see was taken before it started, and it "+ + "wrote %q anyway - the refusal has to come before the first file, not in the middle of the run:\n%s", + e.Name(), errOut) + } + } } // failingWriter refuses everything, which is what a closed pipe looks like. diff --git a/internal/guard/spacingscale_test.go b/internal/guard/spacingscale_test.go index ce4faa1a..7bfef65a 100644 --- a/internal/guard/spacingscale_test.go +++ b/internal/guard/spacingscale_test.go @@ -13,43 +13,59 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) -// Space says what belongs together. +// A name stands level with its box, on one edge with every other box. // -// Measured off a render on 2026-08-20, before the scale existed: every gap in -// the form came out of the theme's one padding value, so the distance from a -// label to its own control was 20 px and the distance from the end of one -// field to the start of the next was 23 px. Sixteen consecutive gaps down one -// card, all of them between 20 and 27. The form gave the same distance to -// "these three things are one field" and "that group has ended", which is the -// whole of what spacing does, and a picture of it reads as a wall of text. +// GUI rule 13 in the owner's words: a form is a grid with a column of names, +// values in a second column, names lined up with each other. Until +// 2026-09-14 a name stood OVER its box, and this guard measured the gap +// between the two against the gap between two fields, because those were the +// two distances a stacked form has. A row has one: the space between two +// rows. What holds a field together in a row is that its name and its box +// share a line, and what lines the form up is that every box starts where +// every other box starts. // -// This asks the laid out screen rather than the constants. Reading the numbers -// back out of the package that declares them proves they were declared, which -// is not the question - what a person sees is where the widgets ended up, and -// a layout is free to add padding of its own on top of any gap it is given. -// The old code did exactly that, which is why a spacer could not make a gap -// smaller and the tight step needed a layout rather than a constant. +// Three things, all read off the laid out screen rather than off the +// constants, for the reason the section guards give below: a layout is free +// to put a name anywhere, and reading the numbers back out of the package that +// declares them proves they were declared, which is not the question. // -// The ratio is what is asserted rather than the values. Nothing here says a -// field's parts must be 15 px apart - that is a judgement to make with a -// picture. What has to hold is that the eye can tell the two apart without -// counting, and 1.5 is the weakest version of that claim: the pair this -// replaced was 1.15 apart. -func TestAFieldHoldsTogetherMoreTightlyThanTwoFieldsDo(t *testing.T) { +// Level means the middle of the name is the middle of the box, to a pixel. +// "Inside the box's height" was the first version and a mutation laying every +// name at the top of its row passed it: a name 19 px tall at the top of a 32 +// px row has its middle 6 px above the box's, still inside, and a form where +// every name floats above its box reads as the stacked form it replaced. One edge means every control of the screen begins +// at one X, whatever the length of the name beside it - the column of names +// is worked out from the widest name the window can show, so "Seed" and +// "Output directory" put their boxes on the same line. Apart means two rows +// have room between them, or the form is a list with no rhythm. +func TestANameStandsLevelWithItsBoxOnOneEdgeWithEveryOther(t *testing.T) { ourTheme(t) content, _ := laidOutWindow(t) generate := tabContent(t, content, text.TabOneTarget()) - inside := gapBelowLabel(t, generate, text.FieldFormat()) - between := gapBelowField(t, generate, text.FieldFormat(), text.FieldSize()) - - if inside <= 0 || between <= 0 { - t.Fatalf("measured %.1f px inside a field and %.1f px between two, and neither can be zero", inside, between) + names := []string{text.FieldFormat(), text.FieldSize(), text.FieldCount(), text.FieldTargetID(), + text.FieldNameTemplate(), text.FieldOutputDir(), text.FieldSeed()} + edges := map[float32][]string{} + var boxes []band + for _, label := range names { + name, box := nameAndBox(t, generate, label) + middle, boxMiddle := name.Y+name.Height/2, box.Y+box.Height/2 + if off := middle - boxMiddle; off > 1 || off < -1 { + t.Errorf("%q has its middle at y=%.1f and its box its middle at y=%.1f, so the name is not level with the box", + label, middle, boxMiddle) + } + edges[box.X] = append(edges[box.X], label) + boxes = append(boxes, box) } - if between < inside*1.5 { - t.Errorf("a field's own parts are %.1f px apart and two fields are %.1f px apart, which is a ratio of %.2f."+ - " Below 1.5 the two distances read as one and the form has no grouping left", - inside, between, between/inside) + if len(edges) != 1 { + t.Errorf("the boxes on the generate screen start on %d different edges, and a form is a grid only while they start on one: %v", + len(edges), edges) + } + for i := 1; i < len(boxes); i++ { + if boxes[i].Y <= boxes[i-1].Y+boxes[i-1].Height { + t.Errorf("%q and %q are not apart: one box ends at %.1f and the next begins at %.1f", + names[i-1], names[i], boxes[i-1].Y+boxes[i-1].Height, boxes[i].Y) + } } } @@ -181,8 +197,8 @@ func labelBox(screen fyne.CanvasObject, words string) (band, bool) { if ok { return } - label, is := o.(*widget.Label) - if !is || label.Text != words { + shown, is := wordsOf(o) + if !is || shown != words { return } found, ok = band{X: at.X, Y: at.Y, Width: o.Size().Width, Height: o.Size().Height}, true @@ -190,9 +206,8 @@ func labelBox(screen fyne.CanvasObject, words string) (band, bool) { return found, ok } -// gapBelowLabel is the space between a field's name and the control under it, -// which is the tightest step the scale has. -func gapBelowLabel(t *testing.T, screen fyne.CanvasObject, label string) float32 { +// nameAndBox is where a field's name and its control ended up on the screen. +func nameAndBox(t *testing.T, screen fyne.CanvasObject, label string) (name, box band) { t.Helper() name, ok := labelBox(screen, label) if !ok { @@ -202,11 +217,11 @@ func gapBelowLabel(t *testing.T, screen fyne.CanvasObject, label string) float32 if control == nil { t.Fatalf("no control under %q", label) } - box, ok := objectBox(screen, control) + box, ok = objectBox(screen, control) if !ok { t.Fatalf("the control under %q is not laid out", label) } - return box.Y - (name.Y + name.Height) + return name, box } // objectBox is where one object this test already holds ended up. @@ -299,10 +314,6 @@ func atAbsolute(root fyne.CanvasObject, visit func(fyne.CanvasObject, fyne.Posit step(v.Content, at) case *widget.Card: step(v.Content, at) - case *container.AppTabs: - for _, item := range v.Items { - step(item.Content, at) - } case *container.ThemeOverride: // Every screen is wrapped in one of these since 2026-08-20. A walk // that stops here reports a screen with nothing on it, and three diff --git a/internal/guard/tabs_test.go b/internal/guard/tabs_test.go new file mode 100644 index 00000000..90f9dc19 --- /dev/null +++ b/internal/guard/tabs_test.go @@ -0,0 +1,353 @@ +package guard + +import ( + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/version" +) + +// The strip across the top stands on the edge the screen reads from. +// +// Measured off a render on 2026-09-15, before this held: the toolkit's tabs +// stood on the edge of the window at x=10 while the title under them stood on +// the column at x=157, so at the width the window opens at the way between +// screens and the screens themselves had no line in common. The strip is ours +// now and goes through the same column and the same indent as the title, so +// the two stand on one edge by construction - and this asks the laid out +// window whether they do, on every screen, because "by construction" is a +// sentence about the code and this is a sentence about the canvas. +func TestTheStripStandsOnTheEdgeTheScreenReadsFrom(t *testing.T) { + ourTheme(t) + content, _ := laidOutWindow(t) + strip := tabsIn(content) + if strip == nil || len(strip.Words()) == 0 { + t.Fatal("the window has no strip, so this guard read the wrong tree") + } + first := fyne.CurrentApp().Driver().AbsolutePositionForObject(strip.Words()[0]) + + for _, tab := range allTabs() { + t.Run(tab, func(t *testing.T) { + screen := tabContent(t, content, tab) + title, ok := labelBox(screen, titleOf(tab)) + if !ok { + t.Fatalf("the %q screen has no title reading %q", tab, titleOf(tab)) + } + at, found := absoluteOf(content, screen) + if !found { + t.Fatal("the screen is not in the window it was found in") + } + // labelBox measures from the screen and the strip is outside it, + // so the title is put in the window's frame before comparing. + edge := at.X + title.X + if off := edge - first.X; off > 1 || off < -1 { + t.Errorf("the first word of the strip starts at %.1f px and the title of %q at %.1f px, %.1f px apart."+ + " The strip and the screen have to share one edge or the window reads as two things assembled", + first.X, tab, edge, off) + } + }) + } +} + +// A screen is titled with the word on its tab. +// +// Every work screen had two names until 2026-09-15 - "Single batch" on the +// tab and "Generate files" over the form - and a person moving between them +// had to hold both. One vocabulary now: the tab's word is the title, at the +// rank a title has, and what the title used to say stands under it as a quiet +// sentence. About keeps the product and its version as its title, because on +// an About screen the product IS the subject, and that exception is asserted +// here rather than tolerated so that it cannot widen. +// +// Both halves are asked. A guard that only looked for the tab's word on the +// screen would pass while the word stood there as a hint, and one that only +// checked the rank would pass for any title at all. +func TestAScreenIsTitledWithTheWordOnItsTab(t *testing.T) { + ourTheme(t) + content, _ := laidOutWindow(t) + + for _, tab := range allTabs() { + t.Run(tab, func(t *testing.T) { + screen := tabContent(t, content, tab) + titles := 0 + walk(screen, func(o fyne.CanvasObject) { + words, bold, size, ok := boldWordsAt(o) + if !ok || !bold || size != parts.TextTitle { + return + } + titles++ + if words != titleOf(tab) { + t.Errorf("the %q screen is titled %q, and its title has to be %q", tab, words, titleOf(tab)) + } + }) + if titles != 1 { + t.Errorf("the %q screen draws %d titles at the title's rank, and a screen has exactly one", tab, titles) + } + if sentence, has := subtitleOf(tab); has { + if _, ok := labelBox(screen, sentence); !ok { + t.Errorf("the %q screen does not say %q under its title, so what the old title said is gone rather than moved", + tab, sentence) + } + } + }) + } +} + +// titleOf is what a screen is titled: the word on its tab, except About, +// which is titled with the product and its version. +func titleOf(tab string) string { + if tab == text.TabAbout() { + return text.HeadingAbout(version.Version) + } + return tab +} + +// subtitleOf is the sentence under a screen's title, for the screens that +// have one. +func subtitleOf(tab string) (string, bool) { + switch tab { + case text.TabOneTarget(): + return text.SubtitleGenerate(), true + case text.TabPresets(): + return text.SubtitlePreset(), true + case text.TabRecipe(): + return text.SubtitleRecipe(), true + } + return "", false +} + +// Enter on a word of the strip chooses its screen and hands the keyboard on +// where it can be seen. +// +// The toolkit's strip answered the pointer and nothing else, so until +// 2026-09-15 the way between screens did not exist for the keyboard at all. +// Two things are asserted and the second is the one that would go wrong +// quietly: the screen changes, and the keyboard lands on the new screen's +// first field WITH its mark drawn - somebody who pressed Enter is using the +// keyboard, and a mark placed silently, the way a press places it, would leave +// them with no idea where the next key goes. +func TestEnterOnAWordChoosesItsScreenAndHandsTheKeyboardOnVisibly(t *testing.T) { + _, content, canvas := keyedWindow(t) + strip := tabsIn(content) + if strip == nil { + t.Fatal("the window has no strip") + } + presets := wordOn(t, strip, text.TabPresets()) + + canvas.Focus(holdsTheKeyboard(t, presets)) + if !presets.Marked() { + t.Fatal("the keyboard was put on a word and the word drew no mark, so this guard cannot tell a keyboard from a press") + } + presets.TypedKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) + + if chosen := strip.Selected(); chosen == nil || chosen.Text != text.TabPresets() { + t.Fatalf("Enter on %q did not choose its screen. Chosen: %v", text.TabPresets(), chosen) + } + screen := tabNamed(t, content, text.TabPresets()) + if !screen.Visible() { + t.Error("the chosen screen is not visible, so the strip named one screen and showed another") + } + if generate := tabNamed(t, content, text.TabOneTarget()); generate.Visible() { + t.Error("the screen somebody left is still visible under the one they chose") + } + + first := controlUnder(screen, text.FieldPreset()) + if first == nil { + t.Fatalf("the preset screen has no field called %q", text.FieldPreset()) + } + focused := canvas.Focused() + if any, ok := focused.(fyne.CanvasObject); !ok || any != first { + t.Fatalf("after Enter the keyboard is on %s, and it has to be on the first field of the chosen screen", + describeFocusable(focused)) + } + if menu, ok := first.(*parts.Chooser); !ok || !menu.Marked() { + t.Error("the first field got the keyboard from a KEY and drew no mark, so somebody using the keyboard " + + "cannot see where it went") + } +} + +// The arrows move the keyboard along the strip without choosing. +// +// Choosing hands the keyboard on to the screen's first field, so an arrow that +// chose would leave the strip on every press: somebody looking for the third +// word would find themselves on the second screen's form instead. Manual +// activation - the arrows move, Enter chooses - is the one pattern that fits a +// strip whose choice moves the keyboard. Both ends are asked as well, because +// wrapping round is the other thing an arrow could do and the strip is short +// enough to see the whole of. +func TestTheArrowsMoveTheKeyboardAlongTheStripWithoutChoosing(t *testing.T) { + _, content, canvas := keyedWindow(t) + strip := tabsIn(content) + if strip == nil { + t.Fatal("the window has no strip") + } + words := strip.Words() + if len(words) < 3 { + t.Fatalf("the strip has %d words, and this guard needs three to walk along", len(words)) + } + was := strip.Selected() + + canvas.Focus(holdsTheKeyboard(t, words[0])) + press := func(key fyne.KeyName, want *parts.TabWord) { + t.Helper() + if focused, ok := canvas.Focused().(*parts.TabWord); ok { + focused.TypedKey(&fyne.KeyEvent{Name: key}) + } else { + t.Fatalf("the keyboard left the strip before %s was pressed: it is on %s", key, describeFocusable(canvas.Focused())) + } + if got := canvas.Focused(); got != want { + t.Errorf("after %s the keyboard is on %s and %q was expected", key, describeFocusable(got), want.Text()) + } + if !want.Marked() { + t.Errorf("after %s the word %q holds the keyboard and draws no mark", key, want.Text()) + } + if strip.Selected() != was { + t.Errorf("%s chose a screen. The arrows move the keyboard and Enter chooses", key) + } + } + press(fyne.KeyRight, words[1]) + press(fyne.KeyRight, words[2]) + press(fyne.KeyLeft, words[1]) + press(fyne.KeyHome, words[0]) + press(fyne.KeyLeft, words[0]) + press(fyne.KeyEnd, words[len(words)-1]) + press(fyne.KeyRight, words[len(words)-1]) +} + +// A press on a word chooses its screen without drawing the keyboard mark. +// +// The same class as the menu and the switch (pointerfocus_test.go): the mark +// means "the keyboard is here and you are using it", and a press is not the +// keyboard. Asked of the word AND of the field the keyboard is handed on to, +// because after a press the window places the keyboard quietly on the new +// screen's first field, and a field marked by a press is the defect that was +// reported from the screen twice. +// +// The word pressed FIRST is the chosen one, and that order is what the first +// version of this guard got wrong. A press on another word hands the keyboard +// straight on to that screen's first field, so a mark drawn on the word for a +// frame is gone before anything reads it - broken by hand, a word that took the +// keyboard loudly passed. Pressing the chosen word moves nothing on, so the +// mark it draws is the mark that stays. +func TestAPressOnAWordChoosesWithoutDrawingTheKeyboardMark(t *testing.T) { + _, content, canvas := keyedWindow(t) + strip := tabsIn(content) + if strip == nil { + t.Fatal("the window has no strip") + } + chosen := wordOn(t, strip, text.TabOneTarget()) + if !chosen.Chosen() { + t.Fatalf("the window did not open on %q, so the press below is not on the chosen word", text.TabOneTarget()) + } + test.Tap(chosen) + if chosen.Marked() { + t.Error("the chosen word was pressed with the pointer and drew the keyboard mark - and since the press " + + "moved nothing, the mark stays until something else is pressed") + } + if focused, ok := canvas.Focused().(*parts.TabWord); !ok || focused != chosen { + t.Errorf("after a press on the chosen word the keyboard is on %s, and the press put it on the word", + describeFocusable(canvas.Focused())) + } + + presets := wordOn(t, strip, text.TabPresets()) + test.Tap(presets) + + if now := strip.Selected(); now == nil || now.Text != text.TabPresets() { + t.Fatalf("a press on %q did not choose its screen", text.TabPresets()) + } + if presets.Marked() { + t.Error("the word was pressed with the pointer and drew the keyboard mark") + } + screen := tabNamed(t, content, text.TabPresets()) + first := controlUnder(screen, text.FieldPreset()) + if any, ok := canvas.Focused().(fyne.CanvasObject); !ok || any != first { + t.Fatalf("after a press the keyboard is on %s rather than on the first field of the chosen screen", + describeFocusable(canvas.Focused())) + } + if menu, ok := first.(*parts.Chooser); !ok || menu.Marked() { + t.Error("the first field of the chosen screen drew the keyboard mark after a PRESS, which is the blue box " + + "reported from the screen on 2026-08-12 and 2026-08-18") + } +} + +// A word under the pointer is drawn at full strength, and goes quiet again +// when the pointer leaves. +// +// Rule 10 of the GUI rules: a look is checked by measuring its effect, never +// by the presence of code. So this reads the colour of the text the word +// DRAWS after the pointer arrives - the object in its renderer - rather than +// asking the word whether it believes it is hovered. A widget that took the +// event and drew the same picture was measured once already in the sister +// project: zero pixels different across the whole window (O205 here). +func TestAWordUnderThePointerIsDrawnAtFullStrength(t *testing.T) { + ourTheme(t) + content, _ := laidOutWindow(t) + strip := tabsIn(content) + if strip == nil { + t.Fatal("the window has no strip") + } + word := wordOn(t, strip, text.TabPresets()) + if word.Chosen() { + t.Fatal("the word this guard hovers is the chosen one, which is at full strength anyway") + } + quiet := parts.PaletteColour(theme.ColorNamePlaceHolder, theme.VariantDark) + loud := parts.PaletteColour(theme.ColorNameForeground, theme.VariantDark) + + if got := inkOf(t, word); got != quiet { + t.Fatalf("at rest the word is drawn in %v and the quiet colour is %v, so this guard cannot see it change", got, quiet) + } + word.MouseIn(&desktop.MouseEvent{}) + if got := inkOf(t, word); got != loud { + t.Errorf("under the pointer the word is drawn in %v and full strength is %v", got, loud) + } + word.MouseOut() + if got := inkOf(t, word); got != quiet { + t.Errorf("after the pointer left the word is still drawn in %v", got) + } +} + +// holdsTheKeyboard is a word as something the canvas can put the keyboard on. +// +// Asked at run time rather than written as the type, on purpose: whether the +// words can hold the keyboard at all is the thing this file exists for, and a +// guard that stated it as a type would stop compiling the day it was lost +// instead of going red. Compiling and red is what a guard is for. +func holdsTheKeyboard(t *testing.T, o fyne.CanvasObject) fyne.Focusable { + t.Helper() + f, ok := o.(fyne.Focusable) + if !ok { + t.Fatalf("%T cannot hold the keyboard, so the strip is back to answering the pointer alone", o) + } + return f +} + +// wordOn is the word on the strip that leads to the named screen. +func wordOn(t *testing.T, strip *parts.Tabs, name string) *parts.TabWord { + t.Helper() + for _, word := range strip.Words() { + if word.Text() == name { + return word + } + } + t.Fatalf("the strip has no word reading %q", name) + return nil +} + +// inkOf is the colour a word on the strip is drawing its letters in. +func inkOf(t *testing.T, word *parts.TabWord) any { + t.Helper() + for _, o := range test.WidgetRenderer(word).Objects() { + if ink, ok := o.(*canvas.Text); ok { + return ink.Color + } + } + t.Fatalf("the word %q draws no text", word.Text()) + return nil +} diff --git a/internal/guard/tabstrip_test.go b/internal/guard/tabstrip_test.go index 22dc5ab8..b05d2857 100644 --- a/internal/guard/tabstrip_test.go +++ b/internal/guard/tabstrip_test.go @@ -3,8 +3,10 @@ package guard import ( "image" "image/color" + "math" "testing" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/test" "fyne.io/fyne/v2/theme" @@ -14,7 +16,7 @@ import ( // The tab somebody is on is the one that stands out. // -// The toolkit draws the selected tab in the accent colour and every other tab +// The toolkit drew the selected tab in the accent colour and every other tab // in the ordinary foreground - tabs.go, either side of line 716. Measured off a // render on 2026-08-20: the SELECTED tab stood at 7.71 against the page and the // three nobody was on stood at 13.36. The chosen one was the dimmest label in @@ -26,11 +28,15 @@ import ( // first, which nothing but a picture can answer. // // It reads the pixels rather than the widget tree, and that is not a -// preference: the strip is built inside the toolkit's own renderer, so the -// walk this package uses does not reach it. A guard on the theme object would -// prove the theme was written and say nothing about which colour the strip -// took - that mistake was made twice in this run of changes and the mutation -// runner caught both. +// preference: what the strip DRAWS is the question, and a guard on the colour +// a word says it has would prove the field was written and say nothing about +// what reached the canvas - that mistake was made twice in this run of changes +// and the mutation runner caught both. The bands it reads are taken from where +// the words ended up rather than from numbers written here: the strip moved +// from the edge of the window to the column on 2026-09-15, and a band pinned +// to the old place would have measured the page and gone red for the wrong +// reason - or, worse, caught half of the chosen word in both bands and gone +// green for no reason, which is what it did that morning. func TestTheTabSomebodyIsOnIsTheOneThatStandsOut(t *testing.T) { app := test.NewApp() app.Settings().SetTheme(parts.Theme()) @@ -45,13 +51,32 @@ func TestTheTabSomebodyIsOnIsTheOneThatStandsOut(t *testing.T) { t.Cleanup(w.Close) w.Resize(window.OpenSize) + strip := tabsIn(host.content) + if strip == nil { + t.Fatal("the window has no strip") + } picture := w.Canvas().Capture() page := parts.PaletteColour(theme.ColorNameBackground, theme.VariantDark) - // The strip runs across the top. The first tab is the one selected when the - // window opens, and the rest follow it along the same band. - chosen := boldestIn(picture, image.Rect(0, 8, 130, 40), page) - rest := boldestIn(picture, image.Rect(140, 8, 560, 40), page) + // Each band is the word's own box on the canvas, so a word that moved is + // still the word that is measured. Which word is chosen is asked of the + // word, and a strip that marked none would put the chosen strength at + // zero, which is red rather than quietly green. + var chosen, rest float64 + for _, word := range strip.Words() { + at := fyne.CurrentApp().Driver().AbsolutePositionForObject(word) + size := word.Size() + band := image.Rect(int(at.X), int(at.Y), int(at.X+size.Width), int(at.Y+size.Height)) + if band.Empty() { + t.Fatalf("the %q word has no box on the canvas, so this guard is measuring nothing", word.Text()) + } + got := boldestIn(picture, band, page) + if word.Chosen() { + chosen = got + continue + } + rest = math.Max(rest, got) + } if chosen <= rest { t.Errorf("the tab somebody is on stands at %.2f against the page and the ones they are not on stand"+ diff --git a/internal/guard/testdata/screens/about.png b/internal/guard/testdata/screens/about.png index 18dcc7a8..3e81653e 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 c9346442..359810fb 100644 --- a/internal/guard/testdata/screens/about.xml +++ b/internal/guard/testdata/screens/about.xml @@ -1,212 +1,219 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - Testing Files Generator 0.3.0 + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + Testing Files Generator 0.3.0 + + + + + + + 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. + - - - Generate test files, and know how the system under test should react to them. + + + + + + + + + Support + + + + + Opens the support page in your browser. The tool is free and stays free - this pays for the time that goes into it. + - - - - - - - - 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. - - - - + + + + + + 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 + + - - - - - - - Donate - + + + + + + + + + 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 Copyright 2016 The Inter Project Authors + Inter Symbols OFL-1.1 (c) 2020 The Inter Project Authors + Noto Sans OFL-1.1 Copyright 2015 Google Inc. All Rights Reserved. - + - + + + + + + + + + + + + + + + + + + + + + + + + + Donate + + + + + + - + diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png new file mode 100644 index 00000000..c11ab56a Binary files /dev/null and b/internal/guard/testdata/screens/catalogue.png differ diff --git a/internal/guard/testdata/screens/catalogue.xml b/internal/guard/testdata/screens/catalogue.xml new file mode 100644 index 00000000..2b96dd7a --- /dev/null +++ b/internal/guard/testdata/screens/catalogue.xml @@ -0,0 +1,3265 @@ + + + + + + + Catalogue + + + + + Every part of the window, in every state it has. + + + + + + + + + + + Button + + + + + + primary at rest + + + + + + + + + Generate + + + + + + + + + primary under the pointer + + + + + + + + + Generate + + + + + + + + + primary pressed + + + + + + + + + Generate + + + + + + + + + primary holding the keyboard + + + + + + + + + Generate + + + + + + + + + secondary at rest + + + + + + + + + Generate + + + + + + + + + secondary under the pointer + + + + + + + + + Generate + + + + + + + + + secondary pressed + + + + + + + + + Generate + + + + + + + + + secondary holding the keyboard + + + + + + + + + Generate + + + + + + + + + secondary disabled + + + + + + + + + Generate + + + + + + + + + quiet at rest + + + + + + + + + Generate + + + + + + + + + quiet under the pointer + + + + + + + + + Generate + + + + + + + + + quiet pressed + + + + + + + + + Generate + + + + + + + + + quiet holding the keyboard + + + + + + + + + Generate + + + + + + + + + quiet disabled + + + + + + + + + Generate + + + + + + + + + long text + + + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + + + + + glyph at rest + + + + + + + + + + + + + + + + + + glyph under the pointer + + + + + + + + + + + + + + + + + + glyph holding the keyboard + + + + + + + + + + + + + + + + + + glyph disabled + + + + + + + + + + + + + + + + + + + + Chooser + + + + + + at rest + + + + + + + + + + + (Select one) + + + + + + + + + + + + + + chosen + + + + + + + + + + + avif + + + + + + + + + + + + + + holding the keyboard + + + + + + + + + + + + (Select one) + + + + + + + + + + + + + + + + refused + + + + + + + + + + + + (Select one) + + + + + + + + + + + + + + + + disabled + + + + + + + + + + + (Select one) + + + + + + + + + + + + + + long value + + + + + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + + + + + + + + + + + + Entry + + + + + + empty, with its hint + + + + + + + + + + + 10mb + + + + + + + + + + + + + + typed + + + + + + + + + + + 2048 + + + + + + + + + + + holding the keyboard + + + + + + + + + + + 2048 + + + + + + + + + + + + disabled + + + + + + + + + + + 2048 + + + + + + + + + + + long text + + + + + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + + + + + + + + + Toggle + + + + + + off + + + + + + + + + + + + + + + + + + on + + + + + + + + + + + + + + + + + + + under the pointer + + + + + + + + + + + + + + + + + + holding the keyboard + + + + + + + + + + + + + + + + + + + disabled + + + + + + + + + + + + + + + + + + + + Segments + + + + + + first chosen + + + + + + + + + + + + + Exact + Range + Boundary + + + + + + + + + + middle chosen + + + + + + + + + + + + + Exact + Range + Boundary + + + + + + + + + + holding the keyboard + + + + + + + + + + + + + Exact + Range + Boundary + + + + + + + + + + the last one under the pointer + + + + + + + + + + + + + Exact + Range + Boundary + + + + + + + + + + long words + + + + + + + + + + + + + Exactly this size + Somewhere in a range + On a boundary + + + + + + + + + + + + OpenList + + + + + + a few values, one chosen + + + + + + + + + + + + + + + + + + png + + + + + + + jpg + + + + + + avif + + + + + + + + + + + + + + + + + + + + + + + + + + + more values than a short window shows at once + + + + + + + + + + + + + + + + + + avif + + + + + + bmp + + + + + + csv + + + + + + docx + + + + + + gif + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + every value with the kind of thing it is + + + + + + + + + + + + + + + + + + + avif + + + + + + + bmp + + + + + + + csv + + + + + + + + docx + + + + + + + gif + + + + + + + html + + + + + + + ico + + + + + + + jpg + + + + + + + json + + + + + + + jxl + + + + + + + log + + + + + + + md + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + a long value + + + + + + + + + + + + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + + + png + + + + + + + + + + + + + + + + + + + + + + + + + + Tabs + + + + + + the strip, first word chosen + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + a word under the pointer + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + a word holding the keyboard + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + the strip with its screens under it + + + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + the first screen + + + + + + + + + + + + long words + + + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + Short + + + + + + + + + + + + Fields + + + + + + a row + + + + + + + Size + + + + + + + + + 10mb + + + + + + + + + + + + + + + + a row that has to be filled in + + + + + + + + Size + + * + + + + + + + + + + + 10mb + + + + + + + + + + + + + + + + a row the run refused + + + + + + + Size + + + + + + + + + 10mb + + + + + + + + + + + + + + + a size of 3 B is below the smallest png, which is 73 B + + + + + + + + + + + + + a row frozen while a run goes + + + + + + + Size + + + + + + + + + 10mb + + + + + + + + + + + + + + + + a switch under its name + + + + + + + Label in each file + + + + + + + + + + + + + + a cell of a table, the name over the control + + + + + + + Name + + + + + + + + report.txt + + + + + + + + + + + + + + + a row of arbitrary cells + + + + + + + + + + + Anything + + + + + + + + + + + + + + + + + + + + + + Choose... + + + + + + + + + + a long name + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + + + + + + + + + + + + + + + + + + + + + + + + PropertyField + + + + + + a number with a range + + + + + + + + + + Width + + + + + + + + + + + + + + + 800 + + + + + + + + + + + + + + + + + + + + + + + a choice from a closed set + + + + + + + + + + Compression + + + + + + + + + + + + + deflate + + + + + + + + + + + + + + + + + + + + + a yes or no + + + + + + + + + + Bom + + + + + + + + + + + + + + + + + + + + + + + + a size + + + + + + + + + + Member size + + + + + + + + + + + + + + + 1kb + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + free text + + + + + + + + + + Password + + + + + + + + + + + + + + worked out from the size + + + + + + + + + + + + + + + + + + + + + + + + ByteCount + + + + + + a size counted out + + + + + + + Size + + + + + + + + + 10mb + + + + + + + + + + + + 10 485 760 B + + + + + + + + + + + + + nothing to count yet + + + + + + + Size + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + a large size + + + + + + + Size + + + + + + + + + 2gb + + + + + + + + + + + + 2 147 483 648 B + + + + + + + + + + + + + + + Tips + + + + + + the button beside a name + + + + + + + + + Size + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + the button under the pointer + + + + + + + + + Size + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 10mb The exact size every file will have, to the byte. + Units count in 1024s, so 10mb is 10 485 760 B. + + + + + + + + + + + + + + + + + ErrorArea + + + + + + silent + + + + + + + + + + + one line + + + + + + + + + + size 3 B is below the smallest png, which is 73 B + + + + + + + + + + + + a long message + + + + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it. Write a label inside each + generated file, including the ones that are far too small to hold it + + + + + + + + + + + + + + Progress + + + + + + nothing done yet + + + + + + + + + + + + + + + half done + + + + + + + + + + + + + + + done + + + + + + + + + + + + + + + + + Folding + + + + + + open + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + + + + + + inside the fold + + + + + + + + + + + + + + + closed + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + + + + + + + + + + closed, saying what it holds + + + + + + + + + + Settings for png + + + + + + + + + + width 800, height 600 + + + + + + + + + + + + + + + + inner, open + + + + + + + Advanced + + + + + + + + + + + + + + + + + inside the inner fold + + + + + + + + + + + + + inner, closed + + + + + + + Advanced + + + + + + + + + + + + + + + + + + + a long title + + + + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + + + + + + + + + + + + + inside the fold + + + + + + + + + + + + + + + + + Title + + + + + + the title of a screen + + + + + Single batch + + + + + + + the sentence under a title + + + + + + + + + Files of one format and one size, as many as you need. + + + + + + + + + + + a title with its sentence + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. + + + + + + + + + + + + the name of a field + + + + + Output directory + + + + + + + the name of a block inside a section + + + + + Typically finds + + + + + + + a paragraph + + + + + + + + + Every file has exactly the size you ask for, to the byte, or the run refuses before writing anything. + + + + + + + + + + + a note that has to stay visible + + + + + + + + + The limit of 100 000 files is this tool's, not the disk's. + + + + + + + + + + + a list of short statements + + + + + + + + + + + Upload validators + + + + + + + + + + + + Size limits + + + + + + + + + + + + Archive handling + + + + + + + + + + + + + + a caption, the smallest rank + + + + + + + + + 10 485 760 B + + + + + + + + + + + a long line at every rank + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + Write a label inside each generated file, including the ones that are far too small to hold it + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + + + + + + + + + + Section + + + + + + a section of two rows + + + + + + + + + File configuration + + + Size + + + + + + + + + + + + + + + + + + + + + + How many files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + a section with a line across it + + + + + + + + + Output + + + Size + + + + + + + + + + + + + + + + + + + + + + + + How many files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + the bar a run starts from + + + + + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + + Donate + + + + + + + + + + + + + a section with a long title + + + + + + + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + Size + + + + + + + + + + + + + + + + + + + + + + How many files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Not drawn, and why + + + + + + + Choice - a value: one row of an open list, read back by a guard + + + + + + + + + + + + Detail - data: the longer explanation of a field and the sheet it opens on + + + + + + + + + + + + Field - the model of one row of a form, drawn by Fields + + + + + + + + + + + + Tab - data: one screen and the word that leads to it, drawn by Tabs + + + + + + + + + + + + Look - an enum: which face a Button wears, every value drawn under Button + + + + + + + + + + + + PointerFocus - a piece inside a control, knowing what put the keyboard there - no picture of its own + + + + + + + + + + + + Shortcuts - a keyboard map: nothing to draw, and saying so is the point of this row + + + + + + + + + + + + + + + Layout only, and why + + + + + + + Column - stacks children at one gap of the scale + + + + + + + + + + + + Row - puts fields side by side + + + + + + + + + + + + FieldColumn - the column a screen refills at run time, stacked like a section + + + + + + + + + + + + Indented - the same left edge as the fields inside a panel + + + + + + + + + + + + Sized - one width, given + + + + + + + + + + + + Numeric - the width a number needs + + + + + + + + + + + + Padded - one distance of the scale round its content + + + + + + + + + + + + Stacked - panels one under another + + + + + + + + + + + + Screen - the readable width round a head and its sections + + + + + + + + + + + + BesideFields - the room to the right of the column of names + + + + + + + + + + + + WithRoomForARun - the room under a form for the bar to speak into + + + + + + + + + + + + Flush - a label kept by a screen, on the edge every other word stands on + + + + + + + + + + + + Clear - a shape that takes room and draws nothing + + + + + + + + + + + + + + diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png index 2de30dd7..759c05cf 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 585d4e7d..bf259b12 100644 --- a/internal/guard/testdata/screens/generate-chosen-by-key.xml +++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml @@ -1,453 +1,409 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + png + + + - - - - - - Format - - - - - - - - - - - - - - - png - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for png - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for png + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · png · 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 afc30488..7cded58a 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 dd756519..1a31c8d3 100644 --- a/internal/guard/testdata/screens/generate-chosen.xml +++ b/internal/guard/testdata/screens/generate-chosen.xml @@ -1,453 +1,409 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + png + + + - - - - - - Format - - - - - - - - - - - - - - - png - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for png - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for png + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · png · 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 2a47fc5f..57270c2e 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 e2f5dda4..0b7756ed 100644 --- a/internal/guard/testdata/screens/generate-empty.xml +++ b/internal/guard/testdata/screens/generate-empty.xml @@ -1,461 +1,422 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 0 - - - - - - - - - - - - - - target "files" asks for 0 files. Ask for at least one + + + + + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + + + - - - - - - - - Output + + + + + 10 485 760 B - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + How many files + + * + + + + + + + + + + + 0 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + + + + + + + + + target "files" asks for 0 files. Ask for at least one + + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Nothing was written. Check the settings marked above. - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 0 files · 0 B · avif · will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png index 6a8801c8..2559cf1d 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 59527938..63c0a5fb 100644 --- a/internal/guard/testdata/screens/generate-focused.xml +++ b/internal/guard/testdata/screens/generate-focused.xml @@ -1,454 +1,410 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · avif · 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 0cec8e23..b8d94c2e 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 214fadf6..6ba5a8f1 100644 --- a/internal/guard/testdata/screens/generate-hovered.xml +++ b/internal/guard/testdata/screens/generate-hovered.xml @@ -1,464 +1,425 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - - - - - Exact size of every file. Units count in 1024s, so 10mb - is 10485760 bytes. A plain number is a count of bytes. + + + + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · avif · 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. + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index 472a3944..b0467d57 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 f53e725c..941af279 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -1,565 +1,671 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · avif · will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + - - - - - - - - - - - - - - - - avif + + + + + + + + + + + + + + + + avif + + + + + + + bmp - - - - - bmp + + + + + csv - - - - - csv + + + + + docx - - - - - docx + + + + + gif - - - - - gif + + + + + html - - - - - html + + + + + ico - - - - - ico + + + + + jpg - - - - - jpg + + + + + json - - - - - json + + + + + jxl + + + + + + + log + + + + + + + md + + + + + + + pdf + + + + + + + png + + + + + + + pptx + + + + + + + svg + + + + + + + targz + + + + + + + tiff + + + + + + + txt + + + + + + + wav + + + + + + + webp + + + + + + + xlsx + + + + + + + xml + + + + + + + zip - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index 9236c1de..2dfa5e65 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 eba6e8df..6f3b9b29 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -1,565 +1,671 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · avif · will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + - - - - - - - - - - - - - - - - avif + + + + + + + + + + + + + + + + avif + + + + + + + bmp - - - - - bmp + + + + + csv - - - - - csv + + + + + docx - - - - - docx + + + + + gif - - - - - gif + + + + + html - - - - - html + + + + + ico - - - - - ico + + + + + jpg - - - - - jpg + + + + + json - - - - - json + + + + + jxl + + + + + + + log + + + + + + + md + + + + + + + pdf + + + + + + + png + + + + + + + pptx + + + + + + + svg + + + + + + + targz + + + + + + + tiff + + + + + + + txt + + + + + + + wav + + + + + + + webp + + + + + + + xlsx + + + + + + + xml + + + + + + + zip - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index f5303b39..ad0bdb5f 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 c98df7a5..8adbede6 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -1,565 +1,671 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · avif · will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + - - - - - - - - - - - - - - - - avif + + + + + + + + + + + + + + + + avif + + + + + + + bmp - - - - - bmp + + + + + csv - - - - - csv + + + + + docx - - - - - docx + + + + + gif - - - - - gif + + + + + html - - - - - html + + + + + ico - - - - - ico + + + + + jpg - - - - - jpg + + + + + json - - - - - json + + + + + jxl + + + + + + + log + + + + + + + md + + + + + + + pdf + + + + + + + png + + + + + + + pptx + + + + + + + svg + + + + + + + targz + + + + + + + tiff + + + + + + + txt + + + + + + + wav + + + + + + + webp + + + + + + + xlsx + + + + + + + xml + + + + + + + zip - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png index f30ff933..170d7afd 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 ac83992d..e3e7d5db 100644 --- a/internal/guard/testdata/screens/generate-refused-both.xml +++ b/internal/guard/testdata/screens/generate-refused-both.xml @@ -1,468 +1,435 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - - - - - - - - - - - - - abc - - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - many - - - - - - - - - - - - - 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 + + + + + + + + + Size + + * + + + + + + + + + + + + + + + + abc + - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - Output + + + + + + + Size "abc" has no number: write something like 10mb or 1048576 - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + How many files + + * + + + + + + + + + + + many - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + + + + + + + + How many files is "many", which is not a whole number. Write the digits out, such as 1 or + 500 + + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Nothing was written. Check the settings marked above. - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + Files will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png index a1bf6997..37abc150 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 87e10d54..96018eb8 100644 --- a/internal/guard/testdata/screens/generate-refused-setting.xml +++ b/internal/guard/testdata/screens/generate-refused-setting.xml @@ -1,535 +1,492 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + png + + + - - - - - - Format - + + + + + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb - - - - + + + + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - png - - - - - - - - - + + + + + + + + + + + + Batch name + + * + + + + + + + + + + + + + + + files + + + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + + + + + + + + + + + + + Settings for png + + + + + + + - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - + + + + + + + + Width + + + + + - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - + + + + + + + + + 99999 + - - + + - - - - - - Settings for png + + + + + + + png: Width cannot be "99999" - it takes a whole number of pixels from 1 to 20000 - - - - - - - - - - - - - - Width - - - - - - - - - - - - - - - - - 99999 - - - - - - - - - - - - - - Height - - - - - - - - - - - - - - - - - worked out from the size - - - - - - - - - - - - - - - - png: Width cannot be "99999" - it takes a whole number of pixels from 1 to 20000 + + + + + + + Height + + + + + + + + + + + + + + + worked out from the size + + + - - - - - width times height cannot pass 40 megapixels, because the picture is held in memory while it is encoded + - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + + + + + width times height cannot pass 40 megapixels, because the picture is held in memory while it is encoded + + + + - - - - - - - Output + + + + + Damage + + + + + + + + + + + + + none + + + - - - - - - Output directory - - - - - * - - - - - - - - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - Preview - - - - - - - Generate + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out + - - - - - - - - Nothing was written. Check the settings marked above. - + + + + + Choose... + + + + + + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 - + - + + - - - - - - - Donate - + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · png · will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png index 58b8f023..b2e5d98c 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 1fb65365..87864304 100644 --- a/internal/guard/testdata/screens/generate-refused.xml +++ b/internal/guard/testdata/screens/generate-refused.xml @@ -1,463 +1,424 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 1 B - - - - - - - - - - - - 1 - - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - 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 + + + + + + + + + Size + + * + + + + + + + + + + + + + + + + 1 + - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + + + + + + + + 1 B + + + + - - - - - - - Output + + + + + + + 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 - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Nothing was written. Check the settings marked above. - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 1 B · avif · will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png index d61f9dc0..eb529788 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 fc026784..fd90de1d 100644 --- a/internal/guard/testdata/screens/generate-switch-by-key.xml +++ b/internal/guard/testdata/screens/generate-switch-by-key.xml @@ -1,453 +1,409 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · avif · 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 18918199..e77e53db 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 f8027e00..4f4d161d 100644 --- a/internal/guard/testdata/screens/generate-typed.xml +++ b/internal/guard/testdata/screens/generate-typed.xml @@ -1,460 +1,421 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - - - - - - - - - - - - - abc - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - Size "abc" has no number: write something like 10mb or 1048576 + + + + + + + + + Size + + * + + + + + + + + + + + + + + + + abc - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - Output + + + + + + + Size "abc" has no number: write something like 10mb or 1048576 - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 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 711f6516..97bb6c11 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 94d0e35b..48a49aa3 100644 --- a/internal/guard/testdata/screens/generate-unchecked.xml +++ b/internal/guard/testdata/screens/generate-unchecked.xml @@ -1,453 +1,408 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · avif · will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png index 2775e200..de6e0ed3 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 7720c77b..82709d95 100644 --- a/internal/guard/testdata/screens/generate.xml +++ b/internal/guard/testdata/screens/generate.xml @@ -1,453 +1,409 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Generate files - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Single batch + + + + + Files of one format and one size, as many as you need. - - - - - - - - File configuration + + + + + + + + + + File configuration + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Size - - - - - * - - - - - - - - - - 10485760 B - - - - - - - - - - - - 10mb - - - - - - - - - - - - - How many files - - - - - * - - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - files - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - - - Damage - - - - - - - - - - - - - - - none - - - - - - - - - - - - + - - - - - - - Output + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + - - - - - - Output directory - - - - - * - - - - - - + + + + + + + + 10 485 760 B + + + + + + + + + + How many files + + * + + + + + + + + + + + 1 - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - - Seed - - - - - * - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - Preview + + + + Batch name + + * + + + + + + + + + + + + + + + files - - - - - Generate + + + + + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + - + + + + + + + + + + Settings for avif + + + + + + + - - - - - - Files will go to /tfg/out - + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Label in each file + + + + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · avif · 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 ced64e87..6c806e42 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 d603adf7..fecff85f 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -1,489 +1,578 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Build a set for a question - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Presets + + + + + Ready-made sets of files, each built to answer one question about the system under test. - - - - - - - - The question + + + + + + + + + + The question + + + + Preset + + + + + + + + + + + + + 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 - - - - - 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 - - - - - - - - - - + + - - - - - - - Settings - + + + + + + + + + Settings + + + + + Limit + + + + - - - - - - - Limit - - - - - - - - - - - - - - - - - - - - - - 10mb - - - - - - - - - - - - - - - - - - Spread - - - - - - - - - - - - - - - - 1B,1kb,1mb - - - - - - - - - - - - - - - - - Format - + + + + + + + + + + 10mb - - - - + + - - - - - - - - pdf - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - Output - + + + + Spread + + + + - - - - - - Output directory - - - - - * - - - - - - - - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - Seed - - - - - * - + + + + + + + + + 1B,1kb,1mb - - - - + + - - - - - - - - - - 0 - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - Preview + + + + Format + + + + + + + + + + + + + pdf - - - - - - Generate + + - + - - - - - - Files will go to /tfg/out - + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate - + + + + + Seed + + * + + + + + + + + + + + + + + 0 + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 7 files · 70.0 MB (73 400 320 B) · pdf · will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + - - - - - - - - - - + + + + + + + + + + + + + + + avif + + + + + + + bmp + + + + + + + csv + + + + + + + docx + + + + + + + gif + + - - - html + + + html - - - ico + + + ico - - - jpg + + + jpg - - - json + + + json - - - jxl + + + jxl - - - log + + + log - - - md + + + md - - - - pdf + + + + pdf - - - png + + + png + + + + + + + pptx + + + + + + + svg + + + + + + + targz + + + + + + + tiff + + + + + + + txt + + + + + + + wav + + + + + + + webp + + + + + + + xlsx + + + + + + + xml + + + + + + + zip + + + + + + + + + + + + + + + @@ -508,16 +597,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + - - - + + + diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png index 5ba11515..ec515d31 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 62452394..db3cdfe3 100644 --- a/internal/guard/testdata/screens/preset-menu.xml +++ b/internal/guard/testdata/screens/preset-menu.xml @@ -1,427 +1,396 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Build a set for a question - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Presets + + + + + Ready-made sets of files, each built to answer one question about the system under test. - - - - - - - - The question + + + + + + + + + + The question + + + + Preset + + + + + + + + + + + + + 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 - - - - - 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 - - - - - - - - - - + + - - - - - - - Settings - + + + + + + + + + Settings + + + + + Limit + + + + - - - - - - - Limit - - - - - - - - - - - - - - - - - - - - - - 10mb - - - - - - - - - - - - - - - - - - Spread - - - - - - - - - - - - - - - - 1B,1kb,1mb - - - - - - - - - - - - - - - - - Format - + + + + + + + + + + 10mb - - - - + + - - - - - - - - pdf - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - Output - + + + + Spread + + + + - - - - - - Output directory - - - - - * - - - - - - - - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - Seed - - - - - * - + + + + + + + + + 1B,1kb,1mb - - - - + + - - - - - - - - - - 0 - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - Preview + + + + Format + + + + + + + + + + + + + pdf - - - - - - Generate + + - + - - - - - - Files will go to /tfg/out - + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate - + + + + + Seed + + * + + + + + + + + + + + + + + 0 + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 7 files · 70.0 MB (73 400 320 B) · pdf · 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 634aed63..a27a5388 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 9a9ce241..4154d88d 100644 --- a/internal/guard/testdata/screens/preset-refused.xml +++ b/internal/guard/testdata/screens/preset-refused.xml @@ -1,415 +1,389 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Build a set for a question - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Presets + + + + + Ready-made sets of files, each built to answer one question about the system under test. - - - - - - - - The question + + + + + + + + + + The question + + + + Preset + + + + + + + + + + + + + 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 - - - - - 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 - - - - - - - - - - + + - - - - - - - Settings - + + + + + + + + + Settings + + + + + Limit + + + + - - - - - - - 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. + + + + + + + + + + 512 + - - - - - - - - Spread - - - - - - - - - - - - - - - - 1B,1kb,1mb - - - - - - - - - - - - - - - - - Format - - - - - - - - - - - - - - - pdf - - - - - - - - - - + + + + + + + + 512 B + + + + - - - - - - - - Output + + + + + + + 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. + - - - - - - - Output directory - - - - - * - - - - - - - - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - Seed - - - - - * - + + + + + + + Spread + + + + + + + + + + + + + + 1B,1kb,1mb - - - - + + - - - - - - - - - - 0 - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - Preview + + + + Format + + + + + + + + + + + + + pdf - - - - - - Generate + + - + - - - - - - Nothing was written. Check the settings marked above. - + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate - + + + + + Seed + + * + + + + + + + + + + + + + + 0 + + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + Files will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/preset.png b/internal/guard/testdata/screens/preset.png index 9a21b160..1c2e36bf 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 2177962b..5a626a86 100644 --- a/internal/guard/testdata/screens/preset.xml +++ b/internal/guard/testdata/screens/preset.xml @@ -1,408 +1,377 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Build a set for a question - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Presets + + + + + Ready-made sets of files, each built to answer one question about the system under test. - - - - - - - - The question + + + + + + + + + + The question + + + + Preset + + + + + + + + + + + + + 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 - - - - - 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 - - - - - - - - - - + + - - - - - - - Settings - + + + + + + + + + Settings + + + + + Limit + + + + - - - - - - - Limit - - - - - - - - - - - - - - - - - - - - - - 10mb - - - - - - - - - - - - - - - - - - Spread - - - - - - - - - - - - - - - - 1B,1kb,1mb - - - - - - - - - - - - - - - - - Format - + + + + + + + + + + 10mb - - - - + + - - - - - - - - pdf - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - Output - + + + + Spread + + + + - - - - - - Output directory - - - - - * - - - - - - - - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - - - - - - - - Seed - - - - - * - + + + + + + + + + 1B,1kb,1mb - - - - + + - - - - - - - - - - 0 - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - Preview + + + + Format + + + + + + + + + + + + + pdf - - - - - - Generate + + - + - - - - - - Files will go to /tfg/out - + + + + + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out - + - + + + + + Choose... + + - - - - - - - Donate - + + + + + Seed + + * + + + + + + + + + + + + + + + 0 + + + + + + - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 7 files · 70.0 MB (73 400 320 B) · pdf · 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 3ebdf8ec..265f18b0 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 26736e9c..3d2a5104 100644 --- a/internal/guard/testdata/screens/recipe-contents.xml +++ b/internal/guard/testdata/screens/recipe-contents.xml @@ -1,611 +1,486 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Run several batches together - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Several batches + + + + + Batches of different formats and sizes, generated together in one run. - - - - - - - - - - - Batch 1 + + + + + + + + + + + + + Batch 1 + + + + + + + + + + + + + + Duplicate + + + + + + + Format + + + + + + + + + + + + + zip - - - - - - - - - - - - Duplicate + + + - - - - - - - Format - - - - - - - - - - - - - - - zip - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - How many files - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - - - - One size - - - - - - A range + + + + + + + Batch name + + * + + + + + + + + + + + + + + + - - - - - Around a limit + + - - - - - - - Size - - - - - * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - + + + + + + + + + How many files + + + + + + + + + 1 - - - - - - - - - Settings for zip + + - - - - - - - - - - - - - Notes for the manifest - - - - - - - - - - - - - - Files inside each archive - - - - - - - Format - - - - - - - - - (Select one) - - - - - - - - - - - - - How many files - + + + + + + + + + + + + + + + + + One size + A range + Around a limit + + + + + + + Size + + * + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - Size - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - Remove - - - - - - - - - - - Add files inside + + + + + + + + + + + - - - - - - - - - - Output + + + + File names + + + + - - - - - - - Output directory + + + + + + + + + files_0001 - - - - * + + - - - - - - - - - - - - - - - /tfg/out - - + + + + + + + + + Settings for zip + + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + + + Files inside each archive + + + + Format + + + + + + + (Select one) - - - - - - Choose... + + + - - - - - - - - - Manifest file name - - - - - - - - - - - - - - - - manifest.json + + How many files + + + + + + + + + - - + + - + - - - - - Seed - - - - - - - - - - - - - - - - - 0 - - - - + + + Size + + + + + + + + + + + + - - + + - - - - - - - - - - - Write a label inside each file - - - - - - - + + + + + + + Remove + + + + Add files inside + - - - - - - - - - - - - - - - Preview + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out + + - - - - - Generate - + + + + Choose... - - - - - - - Files will go to /tfg/out + + + + + + + + Manifest file name + + + + + + + + + + + + + + manifest.json + + + + + + + + + + + + + + + Seed + + + + + + + + + + + + + + + 0 + + + - + + - - - - - - - Donate + + + + Label in each file + + + + - - - - - - - Add a batch - + + + + @@ -613,13 +488,71 @@ - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 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 0e951748..24e20f68 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 6454f3fc..9408c6be 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 @@ -1,799 +1,664 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Run several batches together - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Several batches + + + + + Batches of different formats and sizes, generated together in one run. - - - - - - - - - - - Batch 1 + + + + + + + + + + + + + Batch 1 + + + + + + + + + + + + + + Duplicate + + + + + Remove + + + + + + + Format + + + + + + + + + + + + + avif + + + - - - - - - - - - - - Duplicate + + + + + + + + + Batch name + + * + + + + + + + + + + + + + + + + + + + + - - - - - Remove + + + + + + + + + + + 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. - - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - How many files - + + + + + + How many files + + + + + + + + + 1 - - - - - - - - - 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. + + - - - - - - - - - One size - - - - - - A range - - - - - - Around a limit - - - - - - - - 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. - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - Settings for avif - - - - - - - - - - - - - - - Notes for the manifest - - - - - - - - - - + + - - - - - - - - Batch 2 - - - - - - - - - - - - - Duplicate - + + + + + + + + + + + One size + A range + Around a limit + + + + + + + Size + + * - - - - - Remove - + + + + - - - - - - - Format + + + + + + + + + - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - second - - - - - - - - - - - How many files + + - - - - - - - - - 1 - - - - - - - - - - - - - - - - - - - - One size - - - - - - A range - - - - - - Around a limit - - - - - - - Size - - - - - * - - - - - - - - - - 1024 B - - - - - - - - - - - 1kb - - - - - - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - + - - - - - Settings for avif - + + + + + + - - - - + + + + + + + + + + + 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. - - + - - - - - Notes for the manifest - + + + + + + + + File names + + + + + + + + + + + + + + files_0001 - - - - + + - - - - + + + + + + + Settings for avif + + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + - - - - - - - - Output + + + + + + + + Batch 2 + + + + + + + + + + + + + + Duplicate + + + + + Remove + + + + + + + Format + + + + - - - - - - - Output directory - + + + + + + + + avif - - - * - + + - - - - + + + + + + + + + + Batch name + + * + + + + + + + + + + + + + + + second + - - - - - - - - - - /tfg/out - - + + + + + + + + + How many files + + + + + + + + + 1 - - - - - - Choose... + + - - - + + + - - - - - - - Manifest file name - - - - - - - - - - - - - - - - manifest.json - - - - + + + + + + + + + + + + + One size + A range + Around a limit + + + + + + + Size + + * + + + + + + + + + + + + + + + + 1kb - - + + - - - - - Seed - - - - - - + + + + + + 1 024 B - - - - - - - - - - 0 - - - - - - - - - - + - + - - - - - - - - - - Write a label inside each file - - - - - - - + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + + - - + + + + + + Settings for avif + + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - Preview + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out + + - - - - - Generate - + + + + Choose... - - - - - - - Nothing was written. Check the settings marked above. + + + + + + + + Manifest file name + + + + + + + + + + + + + + manifest.json + + + + + + + + + + + + + + + Seed + + + + + + + + + + + + + + + 0 + + + - + + - - - - - - - Donate + + + + Label in each file + + + + - - - - - - - Add a batch - + + + + @@ -801,13 +666,79 @@ - - + + + + + + + + - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + Files will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + Add a batch + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/recipe-refused.png b/internal/guard/testdata/screens/recipe-refused.png index 7b2cfc4f..0998b4a8 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 70c83f1f..211b8524 100644 --- a/internal/guard/testdata/screens/recipe-refused.xml +++ b/internal/guard/testdata/screens/recipe-refused.xml @@ -1,526 +1,431 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Run several batches together - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Several batches + + + + + Batches of different formats and sizes, generated together in one run. - - - - - - - - - - - Batch 1 + + + + + + + + + + + + + Batch 1 + + + + + + + + + + + + + + Duplicate + + + + + + + Format + + + + + + + + + + + + + avif + + + - - - - + + + + + + + + + Batch name + + * + + + + + + + + + + + + + + + + + + + + + - - - - - - Duplicate + + + + + + + + + + + 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. - - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - How many files - + + + + + + How many files + + + + + + + + + 1 - - - - - - - - - 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. + + - - - - - - - - - One size - - - - - - A range - - - - - - Around a limit - - - - - - - - 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. - - - - - - - - - - - File names + + + + + + + + + + + + + + + + + One size + A range + Around a limit + + + + + + + Size + + * + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - files_0001 - - - - - + + - - - - - - - - - Settings for avif - - - - - - - - + + - - - - - Notes for the manifest - + + + + + + - - - - + + + + + + + + + + + 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. - - + - + - - - - - - - - - - Output + + + + File names + + + + - - - - - - - Output directory + + + + + + + + + files_0001 - - - - * + + - - - - - - - - - - - - - - - /tfg/out - - - - - - - - - Choose... - - - - - - + + + - - - - - - - Manifest file name - - - - - - - - - - - - - - - - manifest.json - - - - - - - - - - - - - - - Seed - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - - + + + + + Settings for avif + + + + + + + - - - - - - - - - - - Write a label inside each file - - - - - - - - - + + + + + + + Notes for the manifest + + + + + + + - + + - - - - - - - - - - - - - - - Preview + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out + + - - - - - Generate - + + + + Choose... - - - - - - - Nothing was written. Check the settings marked above. + + + + + + + + Manifest file name + + + + + + + + + + + + + + manifest.json + + + + + + + + + + + + + + + Seed + + + + + + + + + + + + + + + 0 + + + - + + - - - - - - - Donate + + + + Label in each file + + + + - - - - - - - Add a batch - + + + + @@ -528,13 +433,71 @@ - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + Files will go to /tfg/out + + + + + + + + + + + + + + + + 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 43df7847..078b51bd 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 729d1965..dd32b58b 100644 --- a/internal/guard/testdata/screens/recipe-two-batches.xml +++ b/internal/guard/testdata/screens/recipe-two-batches.xml @@ -1,786 +1,641 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Run several batches together - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Several batches + + + + + Batches of different formats and sizes, generated together in one run. - - - - - - - - - - - Batch 1 - - - - - - - - - - - - - Duplicate + + + + + + + + + + + + + Batch 1 + + + + + + + + + + + + + + Duplicate + + + + + Remove + + + + + + + Format + + + + + + + + + + + + + avif - - - - - - Remove + + + - - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - How many files - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - - - - One size - - - - - - A range + + + + + + + Batch name + + * + + + + + + + + + + + + + + + - - - - - Around a limit + + - - - - - - - Size - - - - - * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - Settings for avif + + + + + + + + + How many files + + + + + + + + + 1 - - - - - - - - - - - - - - Notes for the manifest + + - - - - - - - - + + + - - - - - - - - Batch 2 - - - - - - - - - - - - - Duplicate - + + + + + + + + + + + One size + A range + Around a limit + + + + + + + Size + + * - - - - - Remove - + + + + - - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - - - - - - - How many files + + - - - - - - - - - 1 - - - - - - - - - - - - - - - - - - - - One size - - - - - - A range - - - - - - Around a limit - - - - - - - Size - - - - - * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - - - - - - - - - - - Settings for avif - - - - - - + + + + + + - - + - - - - - Notes for the manifest - + + + + + + + + File names + + + + + + + + + + + + + + files_0001 - - - - + + - - - - + + + + + + + Settings for avif + + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + - - - - - - - - Output + + + + + + + + Batch 2 + + + + + + + + + + + + + + Duplicate + + + + + Remove + + + + + + + Format + + + + - - - - - - - Output directory - + + + + + + + + avif - - - * - + + - - - - + + + + + + + + + + Batch name + + * + + + + + + + + + + + + + + + + + + + - - - - - - - - - - /tfg/out - - + + + + + + + + + How many files + + + + + + + + + 1 - - - - - - Choose... + + - - - + + + - - - - - - - Manifest file name - - - - - - - - - - - - - - - - manifest.json - - - - + + + + + + + + + + + + + One size + A range + Around a limit + + + + + + + Size + + * + + + + + + + + + + + + + + + + + + + - - + + - - - - - Seed - - - - - - + + + + + + - - - - - - - - - - 0 - - - - - - - - - - + - + - - - - - - - - - - Write a label inside each file - - - - - - - + + + + + + File names + + + + + + + + + + + + + + files_0001 + + + + - - + + + + + + Settings for avif + + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - Preview + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out + + - - - - - Generate - + + + + Choose... - - - - - - - Files will go to /tfg/out + + + + + + + + Manifest file name + + + + + + + + + + + + + + manifest.json + + + + + + + + + + + + + + + Seed + + + + + + + + + + + + + + + 0 + + + - + + - - - - - - - Donate + + + + Label in each file + + + + - - - - - - - Add a batch - + + + + @@ -788,13 +643,79 @@ - - + + + + + + + + - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + 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 83aab5b7..6d2d7110 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 5298321b..e87966c7 100644 --- a/internal/guard/testdata/screens/recipe.xml +++ b/internal/guard/testdata/screens/recipe.xml @@ -1,507 +1,402 @@ - - - - - - - Single batch - - - Presets - - - Several batches - - - About - - - - - - - - - - - - - - - - Run several batches together - + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + Several batches + + + + + Batches of different formats and sizes, generated together in one run. - - - - - - - - - - - Batch 1 + + + + + + + + + + + + + Batch 1 + + + + + + + + + + + + + + Duplicate + + + + + + + Format + + + + + + + + + + + + + avif - - - - - - - - - - - - Duplicate + + + - - - - - - - Format - - - - - - - - - - - - - - - avif - - - - - - - - - - - - - - - - - Batch name - - - - - * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - How many files - - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - - - - One size - - - - - - A range + + + + + + + Batch name + + * + + + + + + + + + + + + + + + - - - - - Around a limit + + - - - - - - - Size - - - - - * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - File names - - - - - - - - - - - - - - - - files_0001 - - - - - - + + + + + + + + + How many files + + + + + + + + + 1 - - - - - - - - - Settings for avif + + - - - - - - - - - - - - - Notes for the manifest - - - - - - - - - - + + + - - - - - - - - - - Output - + + + + + + + + + + + One size + A range + Around a limit + - - - - - - Output directory - - - - - * - - - - - - - - - - - - - - - - - /tfg/out - + + + + + Size + + * + + + + + + + + + + + + + + + + + + + - - - - - Choose... - - - - + + - - - - - - - - Manifest file name - + + + + + - - - - - - - - - - - - - - manifest.json - - - - - - - - - + - - - - - Seed - + + + + + + + + File names + + + + + + + + + + + + + + files_0001 - - - - + + - - - - - - - - - - 0 - - - - - - - - - - - - - - - - - - - - - - - Write a label inside each file - - - - - - - - - + + + + + + Settings for avif + + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + - - - - - - - - - - - - - - - Preview + + + + + + + + Output + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out + + - - - - - Generate - + + + + Choose... - - - - - - - Files will go to /tfg/out + + + + + + + + Manifest file name + + + + + + + + + + + + + + manifest.json + + + + + + + + + + + + + + + Seed + + + + + + + + + + + + + + + 0 + + + - + + - - - - - - - Donate + + + + Label in each file + + + + - - - - - - - Add a batch - + + + + @@ -509,13 +404,71 @@ - - - + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + + + Files will go to /tfg/out + + + + + + + + + + + + + + + + Donate + + + + + + + Add a batch + + + + + + + + + - + diff --git a/internal/guard/translation_test.go b/internal/guard/translation_test.go index b7e73fd6..004b94df 100644 --- a/internal/guard/translation_test.go +++ b/internal/guard/translation_test.go @@ -68,7 +68,7 @@ func TestTheWindowSpeaksWhateverTheCatalogueSays(t *testing.T) { // kind and not the others would otherwise pass. const madeUpCatalogue = `{ "FieldSize": { "other": "ROZMIAREK" }, - "HeadingGenerate": { "other": "NAGLOWEK" }, + "TabOneTarget": { "other": "NAGLOWEK" }, "ButtonPreview": { "other": "PODGLAD" } }` @@ -95,7 +95,7 @@ func theWindowSpeaksTheCatalogue(t *testing.T) { say string }{ {"the name above the size box", text.FieldSize, "ROZMIAREK"}, - {"the heading of the generate screen", text.HeadingGenerate, "NAGLOWEK"}, + {"the heading of the generate screen", text.TabOneTarget, "NAGLOWEK"}, {"the words on the preview button", text.ButtonPreview, "PODGLAD"}, } { if got := want.got(); got != want.say { diff --git a/internal/guard/typeface_test.go b/internal/guard/typeface_test.go new file mode 100644 index 00000000..843352a0 --- /dev/null +++ b/internal/guard/typeface_test.go @@ -0,0 +1,74 @@ +package guard + +import ( + "strings" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// The window is set in Inter, and this asks the painter rather than the +// theme: a face the theme names and the painter never shapes with is a font +// that ships and is not seen. The two faces give a sentence two different +// widths, so the width is the measurement, and a theme that fell back to the +// toolkit's Noto Sans for either weight would give the toolkit's width. +func TestTheWindowIsSetInInter(t *testing.T) { + const sentence = "Files of one format and one size, as many as you need." + size := parts.Theme().Size(theme.SizeNameText) + + app := test.NewApp() + defer test.NewApp() + app.Settings().SetTheme(theme.DefaultTheme()) + toolkit := fyne.MeasureText(sentence, size, fyne.TextStyle{}) + toolkitBold := fyne.MeasureText(sentence, size, fyne.TextStyle{Bold: true}) + + app.Settings().SetTheme(parts.Theme()) + ours := fyne.MeasureText(sentence, size, fyne.TextStyle{}) + oursBold := fyne.MeasureText(sentence, size, fyne.TextStyle{Bold: true}) + + if ours.Width == toolkit.Width { + t.Errorf("a sentence is %.1f px wide under our theme and %.1f under the toolkit's, so the "+ + "painter is shaping the regular weight with the toolkit's face rather than Inter", ours.Width, toolkit.Width) + } + if oursBold.Width == toolkitBold.Width { + t.Errorf("a bold sentence is %.1f px wide under our theme and %.1f under the toolkit's, so the "+ + "painter is shaping the bold weight with the toolkit's face rather than Inter", oursBold.Width, toolkitBold.Width) + } + if ours.Width == oursBold.Width { + t.Errorf("regular and bold measure the same %.1f px, so one weight is standing in for both", ours.Width) + } + t.Logf("regular %.1f px (toolkit %.1f), bold %.1f px (toolkit %.1f)", + ours.Width, toolkit.Width, oursBold.Width, toolkitBold.Width) +} + +// The styles the window never draws in stay the toolkit's. Not a preference: +// Inter ships here in two weights and no italic, so an italic answered with +// the upright would be a style silently drawn as another, and a monospace +// answered with a proportional face would misalign the one thing monospace is +// for. The symbol face is the toolkit's own Inter Symbols and stays so. +func TestTheStylesTheWindowNeverDrawsKeepTheToolkitsFace(t *testing.T) { + ours := parts.Theme() + toolkit := theme.DefaultTheme() + for _, style := range []fyne.TextStyle{ + {Italic: true}, + {Bold: true, Italic: true}, + {Monospace: true}, + {Symbol: true}, + } { + got := ours.Font(style) + want := toolkit.Font(style) + if got.Name() != want.Name() { + t.Errorf("for %+v our theme answers %s and the toolkit %s - a style Inter does not ship "+ + "has to fall through to the face the toolkit would have used", style, got.Name(), want.Name()) + } + } + for _, style := range []fyne.TextStyle{{}, {Bold: true}} { + if name := ours.Font(style).Name(); !strings.HasPrefix(name, "Inter-") { + t.Errorf("for %+v our theme answers %s, which is not Inter", style, name) + } + } +} diff --git a/internal/guard/verify_test.go b/internal/guard/verify_test.go index 6c854d92..00f5ee49 100644 --- a/internal/guard/verify_test.go +++ b/internal/guard/verify_test.go @@ -151,12 +151,32 @@ func TestVerifyCatchesAMissingAnExtraAndAChangedFile(t *testing.T) { // The manifest normally sits in the directory it describes. Reporting it as a // file nobody asked for would make the most obvious invocation fail on the // tool's own output. +// +// Asked of the report, not of the exit code. Until 2026-09-16 this guard read +// the exit code alone, and the full mutation run found it green with the +// manifest no longer skipped: since 2026-09-07 an unclaimed manifest is named +// "another run's record" - a note, exit zero - so the tool said a false +// sentence about its own output and the guard heard nothing. A verify of a +// directory the tool has just written has no differences of any kind. func TestVerifyDoesNotReportTheManifestAsAnExtraFile(t *testing.T) { _, mf := generated(t) - code, _, errOut := run(t, "verify", mf) + code, stdout, errOut := run(t, "verify", mf, "--json") if code != cli.ExitOK { t.Fatalf("verify gave %d on its own output:\n%s", code, errOut) } + var report struct { + Differences []struct { + Kind string `json:"kind"` + Path string `json:"path"` + } `json:"differences"` + } + if err := json.Unmarshal([]byte(stdout), &report); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout) + } + for _, d := range report.Differences { + t.Errorf("verify names %q as %q in the directory it has just written - the manifest is "+ + "the tool's own record, not a difference of any kind", d.Path, d.Kind) + } } // A manifest describing nothing is neither a match nor a mismatch, and calling diff --git a/internal/guard/window_test.go b/internal/guard/window_test.go index 56e46ac5..e6954ab5 100644 --- a/internal/guard/window_test.go +++ b/internal/guard/window_test.go @@ -399,6 +399,14 @@ func TestTheWindowOpensOnTheGenerateScreen(t *testing.T) { } else if buttonNamed(tabs.Selected().Content, "Generate") == nil { t.Error("the tab the window opens on has no Generate button") } + // And the screen on show is the one whose content is VISIBLE, asked of the + // tree rather than of the strip: the strip could name one screen and show + // another, and everything above would still pass. + for _, tab := range tabs.Items() { + if shown := tab.Content.Visible(); shown != (tab.Text == text.TabOneTarget()) { + t.Errorf("the %q screen is visible=%v while the window opens on %q", tab.Text, shown, text.TabOneTarget()) + } + } } // walk visits every object of a tree, through both kinds of grouping this @@ -422,14 +430,6 @@ func walk(o fyne.CanvasObject, visit func(fyne.CanvasObject)) { // 2026-08-11. The first symptom was a nil type assertion in a guard // that had been passing for weeks. walk(v.Content, visit) - 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) - } case *widget.PopUp: // A field's longer explanation opens in one of these. walk(v.Content, visit) @@ -478,10 +478,17 @@ func walkUnknown(o fyne.CanvasObject, visit func(fyne.CanvasObject)) { // tabsIn is the tab strip of the window, which is where moving between screens // lives since 2026-08-11. -func tabsIn(o fyne.CanvasObject) *container.AppTabs { - var found *container.AppTabs +// +// Ours since 2026-09-15. The screens are no longer BELOW the strip in the tree +// - parts.Tabbed puts the strip and every screen side by side in a plain +// container, so walk reaches every screen, shown or not, without a case for +// the strip's type. That is the reason it is a container: the case this switch +// used to carry for the toolkit's tabs was the third such case added after a +// guard had gone quiet rather than red. +func tabsIn(o fyne.CanvasObject) *parts.Tabs { + var found *parts.Tabs walk(o, func(obj fyne.CanvasObject) { - if tabs, ok := obj.(*container.AppTabs); ok && found == nil { + if tabs, ok := obj.(*parts.Tabs); ok && found == nil { found = tabs } }) @@ -500,7 +507,7 @@ func tabNamed(t *testing.T, o fyne.CanvasObject, name string) fyne.CanvasObject if tabs == nil { t.Fatal("the window has no tabs") } - for _, item := range tabs.Items { + for _, item := range tabs.Items() { if item.Text == name { return item.Content } @@ -518,7 +525,7 @@ func selectTab(t *testing.T, o fyne.CanvasObject, name string) fyne.CanvasObject if tabs == nil { t.Fatal("the window has no tabs") } - for _, item := range tabs.Items { + for _, item := range tabs.Items() { if item.Text == name { tabs.Select(item) return item.Content @@ -534,7 +541,7 @@ func tabNames(o fyne.CanvasObject) []string { return nil } var out []string - for _, item := range tabs.Items { + for _, item := range tabs.Items() { out = append(out, item.Text) } return out @@ -544,11 +551,13 @@ func tabNames(o fyne.CanvasObject) []string { func textIn(o fyne.CanvasObject) string { var b strings.Builder walk(o, func(obj fyne.CanvasObject) { - switch v := obj.(type) { - case *widget.Label: - b.WriteString(v.Text) + if words, ok := wordsOf(obj); ok { + b.WriteString(words) b.WriteString("\n") - case *widget.Button: + return + } + switch v := obj.(type) { + case *parts.Button: b.WriteString(v.Text) b.WriteString("\n") case *parts.Entry: @@ -562,38 +571,33 @@ func textIn(o fyne.CanvasObject) string { return b.String() } -func buttonNamed(o fyne.CanvasObject, name string) *widget.Button { - var found *widget.Button +func buttonNamed(o fyne.CanvasObject, name string) *parts.Button { + var found *parts.Button walk(o, func(obj fyne.CanvasObject) { - if b, ok := obj.(*widget.Button); ok && b.Text == name { + if b, ok := obj.(*parts.Button); ok && b.Text == name { found = b } }) return found } -// checkNamed is a switch found by the words on it, which is where a switch -// carries its name - a heading above one leaves a bare square to click. +// checkNamed is the switch that stands under a name in the column of names. // -// It looks for parts.Switch rather than widget.Check. The window's switches -// report when the keyboard reaches them, which the toolkit's do not, and a -// type that embeds another is not that other type - so this asks for the one -// the window actually builds instead of matching both and pretending they are -// interchangeable. +// Found by the name beside it rather than by words on it, since 2026-09-15: a +// switch carries no words of its own any more - its name is in the column like +// every other field's - so it is the control under a heading, the same way +// every other field is found. It looks for parts.Toggle rather than +// widget.Check because a type that embeds another is not that other type, and +// the window builds its own. func checkNamed(o fyne.CanvasObject, name string) *parts.Toggle { - var found *parts.Toggle - walk(o, func(obj fyne.CanvasObject) { - if c, ok := obj.(*parts.Toggle); ok && c.Text == name { - found = c - } - }) - return found + toggle, _ := controlUnder(o, name).(*parts.Toggle) + return toggle } func buttonNames(o fyne.CanvasObject) []string { var out []string walk(o, func(obj fyne.CanvasObject) { - if b, ok := obj.(*widget.Button); ok { + if b, ok := obj.(*parts.Button); ok { out = append(out, b.Text) } }) @@ -621,7 +625,7 @@ func controlUnder(o fyne.CanvasObject, label string) fyne.CanvasObject { if isHeadingExtra(box.Objects[1]) { return } - if head := headingOf(box.Objects[0]); head != nil && head.Text == label { + if head, named := headingOf(box.Objects[0]); named && head == label { found = unringed(box.Objects[1]) } }) @@ -756,16 +760,15 @@ func detailButtonIn(row *fyne.Container) *parts.DetailButton { return nil } -func headingOf(o fyne.CanvasObject) *widget.Label { - if label, ok := o.(*widget.Label); ok { - return label +func headingOf(o fyne.CanvasObject) (string, bool) { + if words, ok := wordsOf(o); ok { + return words, true } row, ok := o.(*fyne.Container) if !ok || len(row.Objects) == 0 { - return nil + return "", false } - label, _ := row.Objects[0].(*widget.Label) - return label + return wordsOf(row.Objects[0]) } // entryUnder is the box somebody types into for a labelled field. diff --git a/internal/guard/windowmanifestreach_test.go b/internal/guard/windowmanifestreach_test.go index cbecd4b4..fc135109 100644 --- a/internal/guard/windowmanifestreach_test.go +++ b/internal/guard/windowmanifestreach_test.go @@ -83,11 +83,9 @@ func previewOf(t *testing.T, count string) string { if status == nil { t.Fatal("the screen has no status line, so this guard read the wrong tree") } - // Matched on the tail of the preview's own sentence, the way the action bar - // guard does it, so this cannot be satisfied by a refusal. - marker := text.PreviewCost(1, nil, "1 B") - tail := marker[strings.LastIndex(marker, " ")+1:] - if !strings.Contains(status.Text, tail) { + // Matched on the tail the preview puts on its own line, the way the + // action bar guard does it, so this cannot be satisfied by a refusal. + if !strings.Contains(status.Text, text.AndNothingWrittenYet()) { t.Fatalf("the preview of %s files was not accepted, so nothing here was asked about the manifest.\nIt said:\n%s", count, status.Text) } diff --git a/internal/guard/words_test.go b/internal/guard/words_test.go new file mode 100644 index 00000000..7deff14a --- /dev/null +++ b/internal/guard/words_test.go @@ -0,0 +1,51 @@ +package guard + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/widget" +) + +// wordsOf is the text an object shows, whichever of the two kinds of object +// that show words it is. +// +// Until 2026-09-14 every word on the window was a toolkit label, and every +// guard reading the screen asked for that one type. A field's name, a screen's +// title and a section's title are canvas texts now - drawn with no room of +// their own around the ink, so the gaps a layout is given are the gaps that +// reach the screen (parts/tokens.go) - and a guard that still asked for a +// label found nothing where the name is. That failure was loud, which is the +// good kind: nine guards named a field as nil in the first run. This is the +// one place the two kinds are told apart, so the next kind is one more case +// here rather than a hunt through thirty type assertions. +// +// A hidden object still answers. Whether something is on the screen is a +// question about its ancestors as well, and sizechoice_test.go asks it +// properly. Nothing here filters. +func wordsOf(o fyne.CanvasObject) (string, bool) { + switch v := o.(type) { + case *widget.Label: + return v.Text, true + case *canvas.Text: + return v.Text, true + } + return "", false +} + +// boldWordsAt is wordsOf for the guards that read RANK: whether the words are +// bold and at what size they are drawn. A label carries its size as a theme +// name and a canvas text as a number, so the size comes back in pixels for +// both, read from the theme the guards install. +func boldWordsAt(o fyne.CanvasObject) (text string, bold bool, size float32, ok bool) { + switch v := o.(type) { + case *widget.Label: + name := v.SizeName + if name == "" { + name = "text" + } + return v.Text, v.TextStyle.Bold, fyne.CurrentApp().Settings().Theme().Size(name), true + case *canvas.Text: + return v.Text, v.TextStyle.Bold, v.TextSize, true + } + return "", false, 0, false +} diff --git a/internal/gui/catalogue/catalogue.go b/internal/gui/catalogue/catalogue.go new file mode 100644 index 00000000..3a401629 --- /dev/null +++ b/internal/gui/catalogue/catalogue.go @@ -0,0 +1,125 @@ +// Package catalogue is every part of the window drawn in every state it has, +// from one registry. +// +// GUI rule 4 of the owner's asks for a hidden screen in the application that +// shows each component in all its states, with very long text and extreme +// values, and that is closed in both directions: it may not show components +// the application does not have, and may not miss ones it does. Until +// 2026-09-15 that screen was a probe outside the repository +// (tools/probes/partscatalog), and it stopped compiling at step 5 of the +// rework without anything saying so for two steps - a catalogue nothing +// builds is a catalogue that does not exist. This package is in the tree, the +// window opens it with --catalog, and guards hold it to the package it draws. +// +// The registry DRIVES the screen. The screen is built from Entries, the guard +// compares Entries with the exported surface of internal/gui/parts, and a +// second guard renders every state and refuses two that draw the same. If the +// drawing came from one list and the checking from another, the catalogue +// would be a third copy of the same list and would rot like the first two. +// +// What counts as a component was decided on 2026-09-15 (O209) and is written +// where each name lands: a type that draws and has states is an Entry or a +// state of one (Covers), a value or a helper is in NotDrawn with its reason, +// and a function that only arranges is in LayoutOnly. The lists of reasons +// are shorter than the list of entries, on purpose. +// +// The words here are English and do not come from the text package. This is a +// screen for whoever builds the window, reached by a flag nobody is told +// about, and translating sixty captions of states would be work with no +// reader - decision of the owner, 2026-09-15. The guard that keeps every word +// of the window in the text package names this package as the exception. +package catalogue + +import "fyne.io/fyne/v2" + +// Entry is one part of the window and the states worth seeing it in. +type Entry struct { + // Name is the part's name in internal/gui/parts: a constructor without + // its New, or a function. The guard matches it against that surface. + Name string + + // Covers names everything else in parts this entry draws: the types that + // are states of it (a ListRow is a state of the OpenList), the second + // constructor of the same control (NewGlyphButton), the functions its + // states call. Every name here has to exist, and nothing may be named by + // two entries - one part, one place to look at it. + Covers []string + + // States are drawn in this order, under this caption. + States []State + + // Natural says the states are drawn at the width they ask for and not + // the width of the column: a button, a menu, a switch. A row of a form, + // a list, a section fill the column the way they fill a screen. + Natural bool +} + +// State is one way a part can look, with the caption written above it. +type State struct { + Caption string + Build func() fyne.CanvasObject +} + +// Reason is a name from parts that the catalogue does not draw, and why. +type Reason struct { + Name string + Why string +} + +// Entries is the registry, in the order the screen shows it: the controls a +// person operates first, then the lists and the strip, then the rows of a +// form, then what is only read. +func Entries() []Entry { + return []Entry{ + button(), chooser(), entry(), toggle(), segments(), + openList(), tabs(), + fields(), propertyField(), byteCount(), tips(), errorArea(), progress(), folding(), + textRanks(), structure(), + } +} + +// NotDrawn is every exported type of parts that is not a component: a value +// something returns, the model a control draws from, a piece inside one. +// Named with the reason rather than left off the guard's list, because a name +// on no list is the way a component goes uncatalogued. +// +// What the guard asks about is written there: every exported type, every +// constructor, and every exported function that returns something drawable. +// A function returning a number or a yes is not a component and is not asked +// about, which is why Theme, WidestName and their kind are not here. +func NotDrawn() []Reason { + return []Reason{ + {"Choice", "a value: one row of an open list, read back by a guard"}, + {"Detail", "data: the longer explanation of a field and the sheet it opens on"}, + {"Field", "the model of one row of a form, drawn by Fields"}, + {"Tab", "data: one screen and the word that leads to it, drawn by Tabs"}, + {"Look", "an enum: which face a Button wears, every value drawn under Button"}, + {"PointerFocus", "a piece inside a control, knowing what put the keyboard there - no picture of its own"}, + {"Shortcuts", "a keyboard map: nothing to draw, and saying so is the point of this row"}, + } +} + +// LayoutOnly is every exported function of parts that returns something +// drawable and arranges rather than draws: it has no colour, no words and no +// state of its own, so a picture of it is a picture of what it was given. +func LayoutOnly() []Reason { + return []Reason{ + {"Column", "stacks children at one gap of the scale"}, + {"Row", "puts fields side by side"}, + {"FieldColumn", "the column a screen refills at run time, stacked like a section"}, + {"Indented", "the same left edge as the fields inside a panel"}, + {"Sized", "one width, given"}, + {"Numeric", "the width a number needs"}, + {"Padded", "one distance of the scale round its content"}, + {"Stacked", "panels one under another"}, + {"Screen", "the readable width round a head and its sections"}, + {"BesideFields", "the room to the right of the column of names"}, + {"WithRoomForARun", "the room under a form for the bar to speak into"}, + {"Flush", "a label kept by a screen, on the edge every other word stands on"}, + {"Clear", "a shape that takes room and draws nothing"}, + } +} + +// longText is the caption every control is offered once, because a catalogue +// that only shows short text hides the defect that shows up with real text. +const longText = "Write a label inside each generated file, including the ones that are far too small to hold it" diff --git a/internal/gui/catalogue/controls.go b/internal/gui/catalogue/controls.go new file mode 100644 index 00000000..4c241403 --- /dev/null +++ b/internal/gui/catalogue/controls.go @@ -0,0 +1,220 @@ +package catalogue + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// The states below are reached the way a person reaches them: MouseIn is the +// pointer arriving, FocusGained is the keyboard arriving, MouseDown is a +// press. Those are the control's own methods, so the catalogue drives its +// state machine rather than reaching into it - and a control whose state +// cannot be reached this way is a control a person cannot reach either. + +func button() Entry { + var states []State + for _, face := range []struct { + look parts.Look + name string + }{{parts.Primary, "primary"}, {parts.Secondary, "secondary"}, {parts.Quiet, "quiet"}} { + states = append(states, faceStates(face.look, face.name)...) + } + states = append(states, State{"long text", func() fyne.CanvasObject { + return parts.NewButton(parts.Secondary, longText, func() {}) + }}) + states = append(states, glyphStates()...) + return Entry{Name: "Button", Covers: []string{"GlyphButton"}, Natural: true, States: states} +} + +// faceStates is one face of a button in the four states a person can put it +// in, and the fifth - disabled - for the two faces that draw it differently. +// Disabled, a primary and a secondary button are one face, the outline of a +// button nobody can press, so it is drawn once, under the secondary: the +// guard for identical states refused it twice. +func faceStates(look parts.Look, name string) []State { + build := func() *parts.Button { return parts.NewButton(look, "Generate", func() {}) } + states := []State{ + {name + " at rest", func() fyne.CanvasObject { return build() }}, + {name + " under the pointer", func() fyne.CanvasObject { + b := build() + b.MouseIn(&desktop.MouseEvent{}) + return b + }}, + {name + " pressed", func() fyne.CanvasObject { + b := build() + b.MouseIn(&desktop.MouseEvent{}) + b.MouseDown(&desktop.MouseEvent{}) + return b + }}, + {name + " holding the keyboard", func() fyne.CanvasObject { + b := build() + b.FocusGained() + return b + }}, + } + if look != parts.Primary { + states = append(states, State{name + " disabled", func() fyne.CanvasObject { + b := build() + b.Disable() + return b + }}) + } + return states +} + +// glyphStates is the mark-only button - the (i) beside a name - in its states. +func glyphStates() []State { + build := func() *parts.Button { return parts.NewGlyphButton(theme.InfoIcon(), func() {}) } + return []State{ + {"glyph at rest", func() fyne.CanvasObject { return build() }}, + {"glyph under the pointer", func() fyne.CanvasObject { + b := build() + b.MouseIn(&desktop.MouseEvent{}) + return b + }}, + {"glyph holding the keyboard", func() fyne.CanvasObject { + b := build() + b.FocusGained() + return b + }}, + {"glyph disabled", func() fyne.CanvasObject { + b := build() + b.Disable() + return b + }}, + } +} + +func chooser() Entry { + options := []string{"png", "jpg", "avif"} + // On a screen a menu stands inside the ring a field gives it (WithRing), + // which is what draws the keyboard's mark round it - so the states with + // something to say are built the way a field builds them. A refused menu + // holding the keyboard draws as a refused one: the refusal is about what + // will happen and wins, by the rule written on Ring, so it is not a state + // of its own here. + return Entry{Name: "Chooser", Covers: []string{"Ring", "WithRing", "Menu"}, Natural: true, States: []State{ + {"at rest", func() fyne.CanvasObject { + return parts.Menu(parts.NewChooser(options, func(string) {})) + }}, + {"chosen", func() fyne.CanvasObject { + c := parts.NewChooser(options, func(string) {}) + c.SetSelected("avif") + return parts.Menu(c) + }}, + {"holding the keyboard", func() fyne.CanvasObject { + c := parts.NewChooser(options, func(string) {}) + o, _ := parts.WithRing(parts.Menu(c)) + c.FocusGained() + return o + }}, + {"refused", func() fyne.CanvasObject { + o, ring := parts.WithRing(parts.Menu(parts.NewChooser(options, func(string) {}))) + ring.Refuse(true) + return o + }}, + {"disabled", func() fyne.CanvasObject { + c := parts.NewChooser(options, func(string) {}) + c.Disable() + return parts.Menu(c) + }}, + {"long value", func() fyne.CanvasObject { + c := parts.NewChooser([]string{longText, "png"}, func(string) {}) + c.SetSelected(longText) + return parts.Menu(c) + }}, + }} +} + +func entry() Entry { + return Entry{Name: "Entry", States: []State{ + {"empty, with its hint", func() fyne.CanvasObject { + e := parts.NewEntry() + e.SetPlaceHolder("10mb") + return e + }}, + {"typed", func() fyne.CanvasObject { + e := parts.NewEntry() + e.SetText("2048") + return e + }}, + {"holding the keyboard", func() fyne.CanvasObject { + e := parts.NewEntry() + e.SetText("2048") + e.FocusGained() + return e + }}, + {"disabled", func() fyne.CanvasObject { + e := parts.NewEntry() + e.SetText("2048") + e.Disable() + return e + }}, + {"long text", func() fyne.CanvasObject { + e := parts.NewEntry() + e.SetText(longText) + return e + }}, + }} +} + +func toggle() Entry { + return Entry{Name: "Toggle", Natural: true, States: []State{ + {"off", func() fyne.CanvasObject { return parts.NewToggle(func(bool) {}) }}, + {"on", func() fyne.CanvasObject { + t := parts.NewToggle(func(bool) {}) + t.SetChecked(true) + return t + }}, + {"under the pointer", func() fyne.CanvasObject { + t := parts.NewToggle(func(bool) {}) + t.MouseIn(&desktop.MouseEvent{}) + return t + }}, + {"holding the keyboard", func() fyne.CanvasObject { + t := parts.NewToggle(func(bool) {}) + t.SetChecked(true) + t.FocusGained() + return t + }}, + {"disabled", func() fyne.CanvasObject { + t := parts.NewToggle(func(bool) {}) + t.SetChecked(true) + t.Disable() + return t + }}, + }} +} + +func segments() Entry { + ways := []string{"Exact", "Range", "Boundary"} + return Entry{Name: "Segments", Natural: true, States: []State{ + {"first chosen", func() fyne.CanvasObject { + return parts.NewSegments(ways, func(string) {}) + }}, + {"middle chosen", func() fyne.CanvasObject { + s := parts.NewSegments(ways, func(string) {}) + s.SetSelected("Range") + return s + }}, + {"holding the keyboard", func() fyne.CanvasObject { + s := parts.NewSegments(ways, func(string) {}) + s.FocusGained() + return s + }}, + {"the last one under the pointer", func() fyne.CanvasObject { + s := parts.NewSegments(ways, func(string) {}) + // On the last segment rather than at the origin: the origin is + // the chosen segment, and a chosen segment under the pointer draws + // as chosen, which the guard for identical states pointed out. + s.MouseIn(&desktop.MouseEvent{PointEvent: fyne.PointEvent{Position: fyne.NewPos(s.MinSize().Width-parts.GapInline, 0)}}) + return s + }}, + {"long words", func() fyne.CanvasObject { + return parts.NewSegments([]string{"Exactly this size", "Somewhere in a range", "On a boundary"}, func(string) {}) + }}, + }} +} diff --git a/internal/gui/catalogue/fields.go b/internal/gui/catalogue/fields.go new file mode 100644 index 00000000..e679cfa3 --- /dev/null +++ b/internal/gui/catalogue/fields.go @@ -0,0 +1,221 @@ +package catalogue + +import ( + "errors" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver/desktop" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// form is a set of fields with the column of names a screen would give them: +// measured from the names the catalogue shows, the way a screen measures it +// from every name it can show, so a row here stands where a row stands there. +func form() *parts.Fields { + s := parts.NewFields() + s.LabelColumn(parts.WidestName("Size", "Name", "How many files", "Label in each file", "Compression", "Member size", "Password")) + return s +} + +// A row of the form: the name in the column of names, the control beside it, +// and whatever the row has to say about its value. +func fields() Entry { + sizeRow := func(s *parts.Fields) fyne.CanvasObject { + e := parts.NewEntry() + e.SetText("10mb") + return s.Add("size", "Size", "10mb", parts.NoDetail, parts.Numeric(e)) + } + return Entry{Name: "Fields", Covers: []string{"FieldSaying", "CellSaying", "FieldRow", "RequiredMark"}, States: []State{ + {"a row", func() fyne.CanvasObject { return sizeRow(form()) }}, + {"a row that has to be filled in", func() fyne.CanvasObject { + s := form() + s.Require("size") + return sizeRow(s) + }}, + {"a row the run refused", func() fyne.CanvasObject { + s := form() + row := sizeRow(s) + s.Mark("size", errors.New("a size of 3 B is below the smallest png, which is 73 B")) + return row + }}, + {"a row frozen while a run goes", func() fyne.CanvasObject { + s := form() + row := sizeRow(s) + s.Freeze(true) + return row + }}, + {"a switch under its name", func() fyne.CanvasObject { + s := form() + return s.AddToggle("label", "Label in each file", "", parts.NoDetail, parts.NewToggle(func(bool) {})) + }}, + {"a cell of a table, the name over the control", func() fyne.CanvasObject { + s := form() + e := parts.NewEntry() + e.SetText("report.txt") + return s.AddCell("name", "Name", "", parts.NoDetail, e) + }}, + {"a row of arbitrary cells", func() fyne.CanvasObject { + s := form() + return s.Row(parts.Prose("Anything"), parts.NewEntry(), parts.NewButton(parts.Secondary, "Choose...", func() {})) + }}, + {"a long name", func() fyne.CanvasObject { + s := form() + return s.Add("size", longText, "10mb", parts.NoDetail, parts.NewEntry()) + }}, + }} +} + +// A field drawn from a declaration - what a format's settings and a preset's +// parameters look like, one state per kind of setting the registry can declare. +func propertyField() Entry { + declared := []struct { + caption string + p format.Property + }{ + {"a number with a range", format.Property{Name: "width", Kind: format.PropertyInt, Min: 1, Max: 20000, Unit: "px", Default: "800", Detail: "How wide the picture is."}}, + {"a choice from a closed set", format.Property{Name: "compression", Kind: format.PropertyChoice, Choices: []string{"deflate", "store"}, Default: "deflate", Detail: "How each file inside is packed."}}, + {"a yes or no", format.Property{Name: "bom", Kind: format.PropertyBool, Default: "false", Detail: "Whether the file starts with a byte order mark."}}, + {"a size", format.Property{Name: "member_size", Kind: format.PropertySize, Default: "1kb", Detail: "How big each file inside is."}}, + {"free text", format.Property{Name: "password", Kind: format.PropertyText, Shape: "text", Detail: "What the archive is locked with."}}, + } + var states []State + for _, d := range declared { + d := d + states = append(states, State{d.caption, func() fyne.CanvasObject { + s := form() + tips := parts.NewTips() + _, objects := parts.DeclaredFields([]format.Property{d.p}, s, tips) + return tips.Over(parts.FieldColumn(objects...)) + }}) + } + return Entry{Name: "PropertyField", Covers: []string{"FromProperty", "DeclaredFields", "PropertyFields", "ShapedFor", "PropertyDetail"}, States: states} +} + +// The count of bytes beside a size, which the row shows when the field is one +// of the ones that hold a size. +func byteCount() Entry { + counted := func(size string) fyne.CanvasObject { + s := form() + s.InBytes("size") + e := parts.NewEntry() + e.SetText(size) + return s.Add("size", "Size", "10mb", parts.NoDetail, parts.Numeric(e)) + } + return Entry{Name: "ByteCount", States: []State{ + {"a size counted out", func() fyne.CanvasObject { return counted("10mb") }}, + {"nothing to count yet", func() fyne.CanvasObject { return counted("") }}, + {"a large size", func() fyne.CanvasObject { return counted("2gb") }}, + }} +} + +// The small button beside a name that shows the field's longer explanation. +func tips() Entry { + explained := func() (fyne.CanvasObject, *parts.DetailButton) { + tips := parts.NewTips() + detail := tips.Say("The exact size every file will have, to the byte. Units count in 1024s, so 10mb is 10 485 760 B.") + s := form() + row := s.Add("size", "Size", "10mb", detail, parts.Numeric(parts.NewEntry())) + button, _ := find(row, func(o fyne.CanvasObject) bool { + _, ok := o.(*parts.DetailButton) + return ok + }).(*parts.DetailButton) + return tips.Over(row), button + } + return Entry{Name: "Tips", Covers: []string{"DetailButton"}, States: []State{ + {"the button beside a name", func() fyne.CanvasObject { + o, _ := explained() + return o + }}, + {"the button under the pointer", func() fyne.CanvasObject { + o, b := explained() + if b != nil { + b.MouseIn(&desktop.MouseEvent{}) + } + return o + }}, + }} +} + +func errorArea() Entry { + return Entry{Name: "ErrorArea", States: []State{ + {"silent", func() fyne.CanvasObject { return parts.NewErrorArea().Object() }}, + {"one line", func() fyne.CanvasObject { + a := parts.NewErrorArea() + a.Say("size 3 B is below the smallest png, which is 73 B") + return a.Object() + }}, + {"a long message", func() fyne.CanvasObject { + a := parts.NewErrorArea() + a.Say(longText + ". " + longText) + return a.Object() + }}, + }} +} + +func progress() Entry { + at := func(value float64) fyne.CanvasObject { + p := parts.NewProgress() + p.Max = 100 + p.SetValue(value) + return p + } + return Entry{Name: "Progress", States: []State{ + {"nothing done yet", func() fyne.CanvasObject { return at(0) }}, + {"half done", func() fyne.CanvasObject { return at(50) }}, + {"done", func() fyne.CanvasObject { return at(100) }}, + }} +} + +func folding() Entry { + // A fold is built open, which the first render of this catalogue found + // out: "open" drew exactly as "closed" did, because the closed one had + // never been closed. Guarded now, and closed here on purpose. + return Entry{Name: "Folding", Covers: []string{"InnerFolding"}, States: []State{ + {"open", func() fyne.CanvasObject { + return parts.NewFolding("Notes for the manifest", nil, parts.Prose("inside the fold")).Object() + }}, + {"closed", func() fyne.CanvasObject { + f := parts.NewFolding("Notes for the manifest", nil, parts.Prose("inside the fold")) + f.Set(false) + return f.Object() + }}, + {"closed, saying what it holds", func() fyne.CanvasObject { + f := parts.NewFolding("Settings for png", nil, parts.Prose("inside the fold")) + f.Say("width 800, height 600") + f.Set(false) + return f.Object() + }}, + {"inner, open", func() fyne.CanvasObject { + return parts.NewInnerFolding("Advanced", parts.Prose("inside the inner fold")).Object() + }}, + {"inner, closed", func() fyne.CanvasObject { + f := parts.NewInnerFolding("Advanced", parts.Prose("inside the inner fold")) + f.Set(false) + return f.Object() + }}, + {"a long title", func() fyne.CanvasObject { + return parts.NewFolding(longText, nil, parts.Prose("inside the fold")).Object() + }}, + }} +} + +// find walks containers for the first object the predicate accepts. The rows +// a form builds are containers of containers, so this is all a builder needs +// to lay hands on the button inside one. +func find(o fyne.CanvasObject, accept func(fyne.CanvasObject) bool) fyne.CanvasObject { + if accept(o) { + return o + } + c, ok := o.(*fyne.Container) + if !ok { + return nil + } + for _, child := range c.Objects { + if found := find(child, accept); found != nil { + return found + } + } + return nil +} diff --git a/internal/gui/catalogue/lists.go b/internal/gui/catalogue/lists.go new file mode 100644 index 00000000..1a4dc3e1 --- /dev/null +++ b/internal/gui/catalogue/lists.go @@ -0,0 +1,95 @@ +package catalogue + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver/desktop" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// The open list draws its rows only once it has a canvas to draw them on, so +// the two states a row has under a person's hands - the pointer on it and the +// keyboard on it - cannot be reached from a builder that runs before the +// canvas exists. They are looked at as stored scenes of the window instead +// (generate-menu-hovered and generate-menu-keyed), which is where they arise. +// What CAN be built here is everything the list is given: few values, many, +// a value already chosen, the kind of thing each value is, and the room a +// window leaves it - the list has no ceiling of its own, the window it opens +// in sets one (parts.ListCeiling), so the cut face is built by telling the +// list about a short window. +// +// Nor has it a width of its own. On a form the list is as wide as the box it +// drops from, and the toolkit's list measures its width off an empty template +// row, so a list stood here bare was a 42 px strip with the first letter of +// each value on it - seen on the render of 2026-09-15, and accepted with the +// rest of the catalogue before anybody read it at that height. Each state +// is drawn as wide as the box a menu of the same values would be, which is +// the rule the form follows. +func openList() Entry { + few := []string{"png", "jpg", "avif"} + many := []string{"avif", "bmp", "csv", "docx", "gif", "html", "ico", "jpg", "json", "jxl", "log", "md"} + // shortWindow is the height of the window the cut state stands in - a + // value the state is given, like the words above, not a look. Tall enough + // for a few rows of the list and not for all twelve. + const shortWindow = 240 + return Entry{Name: "OpenList", Covers: []string{"ListRow", "KindOfFile"}, Natural: true, States: []State{ + {"a few values, one chosen", func() fyne.CanvasObject { + return asWideAsItsBox(few, parts.NewOpenList(few, "jpg", func(string, bool) {}, func(bool) {})) + }}, + {"more values than a short window shows at once", func() fyne.CanvasObject { + l := parts.NewOpenList(many, "json", func(string, bool) {}, func(bool) {}) + l.LimitTo(parts.ListCeiling(shortWindow)) + return asWideAsItsBox(many, l) + }}, + {"every value with the kind of thing it is", func() fyne.CanvasObject { + l := parts.NewOpenList(many, "docx", func(string, bool) {}, func(bool) {}) + l.KindOf = parts.KindOfFile + return asWideAsItsBox(many, l) + }}, + {"a long value", func() fyne.CanvasObject { + values := []string{longText, "png"} + return asWideAsItsBox(values, parts.NewOpenList(values, "png", func(string, bool) {}, func(bool) {})) + }}, + }} +} + +// asWideAsItsBox gives an open list the width of the menu it would drop from: +// the box a Chooser of the same values is given by parts.Menu, which is as +// wide as the widest value plus the arrow. +func asWideAsItsBox(values []string, list *parts.OpenList) fyne.CanvasObject { + box := parts.Menu(parts.NewChooser(values, func(string) {})) + return parts.Sized(box.MinSize().Width, list) +} + +func tabs() Entry { + four := func() *parts.Tabs { + return parts.NewTabs( + &parts.Tab{Text: "Single batch", Content: parts.Prose("the first screen")}, + &parts.Tab{Text: "Presets", Content: parts.Prose("the second screen")}, + &parts.Tab{Text: "Several batches", Content: parts.Prose("the third screen")}, + &parts.Tab{Text: "About", Content: parts.Prose("the fourth screen")}, + ) + } + return Entry{Name: "Tabs", Covers: []string{"TabWord", "Tabbed"}, States: []State{ + {"the strip, first word chosen", func() fyne.CanvasObject { return four() }}, + {"a word under the pointer", func() fyne.CanvasObject { + t := four() + t.Words()[1].MouseIn(&desktop.MouseEvent{}) + return t + }}, + {"a word holding the keyboard", func() fyne.CanvasObject { + t := four() + t.Words()[2].FocusGained() + return t + }}, + {"the strip with its screens under it", func() fyne.CanvasObject { + return parts.Tabbed(four()) + }}, + {"long words", func() fyne.CanvasObject { + return parts.NewTabs( + &parts.Tab{Text: longText, Content: parts.Prose("one")}, + &parts.Tab{Text: "Short", Content: parts.Prose("two")}, + ) + }}, + }} +} diff --git a/internal/gui/catalogue/screen.go b/internal/gui/catalogue/screen.go new file mode 100644 index 00000000..97cfa961 --- /dev/null +++ b/internal/gui/catalogue/screen.go @@ -0,0 +1,58 @@ +package catalogue + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// Screen is the catalogue as the window shows it: one section per entry, and +// under each entry its states in order, each under its caption, in the same +// column and at the same widths as the fields of a form - so a control here +// is the size it is on a screen and not the size it would take if left alone. +// +// Two sections at the end say what the catalogue does not draw and why. A +// catalogue that is silent about what it leaves out looks complete, which is +// rule 6 of the untouchables from the other side. +func Screen() fyne.CanvasObject { + return container.NewVScroll(Page()) +} + +// Page is the catalogue before it is put in a scroll: the whole height of +// it, for a guard that stores a picture of all of it rather than of the +// first screenful. +func Page() fyne.CanvasObject { + entries := Entries() + sections := make([]fyne.CanvasObject, 0, len(entries)+2) + for _, e := range entries { + sections = append(sections, section(e)) + } + sections = append(sections, + parts.Section("Not drawn, and why", parts.Bullets(sentences(NotDrawn()))), + parts.Section("Layout only, and why", parts.Bullets(sentences(LayoutOnly()))), + ) + return parts.Screen(parts.Titled("Catalogue", "Every part of the window, in every state it has."), sections...) +} + +// section is one entry as the screen shows it: its name over its states, +// each under its caption. +func section(e Entry) fyne.CanvasObject { + rows := make([]fyne.CanvasObject, 0, len(e.States)) + for _, s := range e.States { + built := s.Build() + if e.Natural { + built = container.NewHBox(built) + } + rows = append(rows, parts.Column(parts.GapInline, parts.Caption(s.Caption), built)) + } + return parts.Section(e.Name, rows...) +} + +func sentences(reasons []Reason) []string { + out := make([]string, 0, len(reasons)) + for _, r := range reasons { + out = append(out, r.Name+" - "+r.Why) + } + return out +} diff --git a/internal/gui/catalogue/words.go b/internal/gui/catalogue/words.go new file mode 100644 index 00000000..cd6c7078 --- /dev/null +++ b/internal/gui/catalogue/words.go @@ -0,0 +1,75 @@ +package catalogue + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// The ranks of text, from the title of a screen down to a note. A scale is a +// thing a person looks at as a whole, which is why every rank is here once, +// one under another, and once more with a long line to see where it wraps. +func textRanks() Entry { + return Entry{Name: "Title", Covers: []string{"Subtitle", "Titled", "Heading", "Subheading", "Prose", "Note", "Caption", "Bullets"}, States: []State{ + {"the title of a screen", func() fyne.CanvasObject { return parts.Title("Single batch") }}, + {"the sentence under a title", func() fyne.CanvasObject { + return parts.Subtitle("Files of one format and one size, as many as you need.") + }}, + {"a title with its sentence", func() fyne.CanvasObject { + return parts.Titled("Single batch", "Files of one format and one size, as many as you need.") + }}, + {"the name of a field", func() fyne.CanvasObject { return parts.Heading("Output directory") }}, + {"the name of a block inside a section", func() fyne.CanvasObject { return parts.Subheading("Typically finds") }}, + {"a paragraph", func() fyne.CanvasObject { + return parts.Prose("Every file has exactly the size you ask for, to the byte, or the run refuses before writing anything.") + }}, + {"a note that has to stay visible", func() fyne.CanvasObject { + return parts.Note("The limit of 100 000 files is this tool's, not the disk's.") + }}, + {"a list of short statements", func() fyne.CanvasObject { + return parts.Bullets([]string{"Upload validators", "Size limits", "Archive handling"}) + }}, + {"a caption, the smallest rank", func() fyne.CanvasObject { + return parts.Caption("10 485 760 B") + }}, + {"a long line at every rank", func() fyne.CanvasObject { + return parts.Column(parts.GapField, + parts.Title(longText), parts.Heading(longText), parts.Subheading(longText), + parts.Prose(longText), parts.Note(longText)) + }}, + }} +} + +// What a screen is built from besides its fields: the panel a section is, +// the line between two things, and the bar a run is started from. +func structure() Entry { + twoRows := func() []fyne.CanvasObject { + s := form() + one := s.Add("size", "Size", "10mb", parts.NoDetail, parts.NewEntry()) + two := s.Add("count", "How many files", "1", parts.NoDetail, parts.NewEntry()) + return []fyne.CanvasObject{one, two} + } + return Entry{Name: "Section", Covers: []string{"Divider", "ActionBar"}, States: []State{ + {"a section of two rows", func() fyne.CanvasObject { + return parts.Section("File configuration", twoRows()...) + }}, + {"a section with a line across it", func() fyne.CanvasObject { + rows := twoRows() + return parts.Section("Output", rows[0], parts.Divider(), rows[1]) + }}, + {"the bar a run starts from", func() fyne.CanvasObject { + // Composed the way the work screens compose it: the rail at the + // left edge, the buttons centred between two spacers. + return parts.ActionBar(container.NewHBox(parts.NewButton(parts.Quiet, "Donate", func() {})), + container.NewHBox(layout.NewSpacer(), + parts.NewButton(parts.Secondary, "Preview", func() {}), + parts.NewButton(parts.Primary, "Generate", func() {}), + layout.NewSpacer())) + }}, + {"a section with a long title", func() fyne.CanvasObject { + return parts.Section(longText, twoRows()...) + }}, + }} +} diff --git a/internal/gui/font/Inter-Bold.ttf b/internal/gui/font/Inter-Bold.ttf new file mode 100644 index 00000000..9fb9b751 Binary files /dev/null and b/internal/gui/font/Inter-Bold.ttf differ diff --git a/internal/gui/font/Inter-Regular.ttf b/internal/gui/font/Inter-Regular.ttf new file mode 100644 index 00000000..b7aaca8d Binary files /dev/null and b/internal/gui/font/Inter-Regular.ttf differ diff --git a/internal/gui/font/carry.go b/internal/gui/font/carry.go new file mode 100644 index 00000000..8aa53f0c --- /dev/null +++ b/internal/gui/font/carry.go @@ -0,0 +1,19 @@ +package font + +import "github.com/donislawdev/TestingFilesGenerator/internal/legal" + +// The font announces itself to the licence registry the moment this package +// is linked, which is how "tfg license" and the bill of materials know that +// the window carries Inter and the command line does not: both binaries are +// one module, so the build's own record cannot tell them apart, and a package +// that is linked is a package whose init ran. A guard asks that every entry of +// this kind in internal/legal is announced this way. +func init() { + legal.Carrying(packagePath) +} + +// packagePath is this package as go list spells it, which is the spelling the +// registry entry carries. Written out rather than derived, because the only +// thing that could derive it at run time is reflection on a type this package +// does not otherwise need. +const packagePath = "github.com/donislawdev/TestingFilesGenerator/internal/gui/font" diff --git a/internal/gui/font/font.go b/internal/gui/font/font.go new file mode 100644 index 00000000..e1918e9e --- /dev/null +++ b/internal/gui/font/font.go @@ -0,0 +1,37 @@ +// Package font carries the typeface the window is set in. +// +// Why a package of its own holding bytes and nothing else: the same reason as +// internal/gui/icon. The bytes import nothing, so a guard asking what we ship +// can read them whatever the build, and the one line that hands them to the +// toolkit as a font resource lives beside the theme (internal/gui/parts). +// +// What is here. Inter, the static Regular and Bold instances from the v4.1 +// release of github.com/rsms/inter (extras/ttf in the release archive, +// published 2024-11-16), under the SIL Open Font License 1.1. That is read +// from LICENSE.txt in the release and from the name table of each file +// (records 0 and 13), not remembered: the copyright line is "Copyright 2016 +// The Inter Project Authors", no Reserved Font Name is declared, and the +// files call themselves version 4.001. The entry that travels with a release +// is in internal/legal/assets.go and THIRD-PARTY-NOTICES.md. +// +// Two weights and not four. Measured on 2026-09-15: nothing the window draws +// asks for italic or monospace - the only TextStyle{} outside these two is a +// measurement in a guard - so those styles keep the toolkit's own faces and +// the third of a megabyte an italic would cost buys nothing anybody sees. +// +// Static instances rather than the variable font. docs/GUI-REDESIGN +// section 5 left open whether the toolkit's shaper handles a variable font, +// and a static file asks it nothing it does not already do for Noto Sans. +package font + +import _ "embed" + +// Regular is what the body of every screen is set in. +// +//go:embed Inter-Regular.ttf +var Regular []byte + +// Bold is what titles and field names are set in. +// +//go:embed Inter-Bold.ttf +var Bold []byte diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 06966d4b..d959d075 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -22,6 +22,27 @@ import "io" // table the way the command line has - docs/GUI.md section 5 says that is a // fact to write down rather than a gap to fill. The code still exists because // this is a process, and it answers one question: did the window come up. -func Run(errOut io.Writer) int { - return run(errOut) +// +// args are the process's arguments after its name. One of them is read, and +// it is the first argument this binary has ever read: --catalogue (or +// --catalog) opens the catalogue of the window's parts instead of the work +// screens - GUI rule 4's hidden screen, reached from the launch line. Every +// other argument is ignored, which is what happened to all of them before +// this and is written here rather than changed: a window started from a +// shortcut with odd arguments has always opened. +func Run(args []string, errOut io.Writer) int { + return run(wantsCatalogue(args), errOut) +} + +// wantsCatalogue says whether the launch line asked for the catalogue. Two +// spellings, because the tree writes the word one way and the flag was +// named the other on the day it was decided, and a flag typed the wrong way +// would open the ordinary window without a word. +func wantsCatalogue(args []string) bool { + for _, arg := range args { + if arg == "--catalogue" || arg == "--catalog" { + return true + } + } + return false } diff --git a/internal/gui/parts/button.go b/internal/gui/parts/button.go new file mode 100644 index 00000000..79a94872 --- /dev/null +++ b/internal/gui/parts/button.go @@ -0,0 +1,427 @@ +package parts + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// Look is which of the window's button faces one wears. +type Look int + +const ( + // Primary is the one button on a screen that does the work - Generate. A + // filled face in the accent colour, so the eye lands on it. + Primary Look = iota + // Secondary is a button beside the primary one - Preview, Choose, Add a + // batch. An outline round nothing, so it reads as a button without + // competing with the filled one. + Secondary + // Quiet is a button that is not about the work in front of you - Donate. + // Words with a surface only under the pointer, an outline round + // transparency, because it stands on more than one background and a fill + // in the colour of any one of them would be wrong on the others (the Fyne + // guide, section 3.11). + Quiet + // Glyph is a button that is a single mark rather than a word - the little i + // that opens an explanation, the arrow that folds a section. The quietest + // face, in the room of a glyph rather than of a label. + Glyph +) + +// Button is a button this window draws itself, in place of widget.Button. +// +// # Why it stopped being the toolkit's, on 2026-09-15 +// +// The toolkit's button draws its "the keyboard is here" mark by blending the +// focus colour into its fill - widget/button.go, buttonColorNames - and on the +// filled primary button that is blue over blue: measured at 1.11 contrast on +// Generate on 2026-09-15, a mark drawn and not seen (O207). Its hover is the +// same shape and the same problem, 1.12 on the same button (O205). It has no +// pressed state at all, only an animation, and it answers the keyboard on the +// space bar and not on Enter. None of that is reachable through a theme, which +// can reach a colour and a radius and not the arithmetic that blends two of +// them - see the Fyne guide, section 4. +// +// # What this costs, and what it pays back +// +// A renderer of ours is a piece of the toolkit written again, and this project +// caps the number of those. Drawing the button itself is also what LETS the +// focus ring stand clear of the face, in one colour on every look, rather than +// a fill the primary face swallows. The states are read off fields here rather +// than out of the toolkit's private ones (the Fyne guide, section 3.26), so +// what the renderer draws is a fact this type owns. +type Button struct { + widget.DisableableWidget + + // Text and OnTapped match widget.Button, so a caller moving between them + // learns no new spelling and the guards that read Text keep working. + Text string + OnTapped func() + + // Icon is the mark a Glyph button draws, and the picture before the word on + // any other - nil for none. + Icon fyne.Resource + + look Look + + // The pointer's state and the keyboard's, kept here because the toolkit + // keeps its own in unexported fields a renderer of ours cannot read. + // + // hovered and pressed are the pointer. marked is the keyboard, and it is + // true only when a key put the focus here. A press with the mouse never + // focuses this control - the driver unfocuses on a tap and does not focus + // the tapped widget (glfw/window.go, mouseClicked), read rather than + // assumed - so nothing has to make the focus quiet the way a menu does. + hovered bool + pressed bool + marked bool +} + +// The driver asks for these by type, so a button that quietly stops answering +// one goes on compiling and stops answering the mouse or the keyboard +// (glfw/window.go, findObjectAtPositionMatching). Asserted here so that is a +// build error instead. +var ( + _ fyne.Tappable = (*Button)(nil) + _ fyne.Focusable = (*Button)(nil) + _ desktop.Hoverable = (*Button)(nil) + _ desktop.Mouseable = (*Button)(nil) +) + +// NewButton builds a button of one of the four looks. +func NewButton(look Look, label string, tapped func()) *Button { + b := &Button{look: look, Text: label, OnTapped: tapped} + b.ExtendBaseWidget(b) + return b +} + +// NewGlyphButton builds the small mark-only button an icon stands in. +func NewGlyphButton(icon fyne.Resource, tapped func()) *Button { + b := &Button{look: Glyph, Icon: icon, OnTapped: tapped} + b.ExtendBaseWidget(b) + return b +} + +// SetText changes the label and redraws. +func (b *Button) SetText(label string) { + b.Text = label + b.Refresh() +} + +// SetIcon changes the mark and redraws. +func (b *Button) SetIcon(icon fyne.Resource) { + b.Icon = icon + b.Refresh() +} + +// Hovered, Pressed and Marked report the drawn state, for a guard - the toolkit +// keeps the same three in unexported fields, so the alternative is reading a +// colour off the canvas and deciding what it meant. +func (b *Button) Hovered() bool { return b.hovered } +func (b *Button) Pressed() bool { return b.pressed } +func (b *Button) Marked() bool { return b.marked } + +// Look is which face this button wears, for a guard asking whether the way to +// stop a run is drawn as a button rather than as bare words. +func (b *Button) Look() Look { return b.look } + +// Tapped presses the button, unless it cannot be pressed. +func (b *Button) Tapped(*fyne.PointEvent) { + if b.Disabled() || b.OnTapped == nil { + return + } + b.OnTapped() +} + +// MouseIn notes the pointer arrived. A disabled button draws nothing under the +// pointer, so this records the fact and the face throws it away - the state of +// the pointer is about the pointer, whether it means anything is about the +// control. +func (b *Button) MouseIn(*desktop.MouseEvent) { + b.hovered = true + b.Refresh() +} + +// MouseMoved has nothing to do: the whole button looks the same wherever the +// pointer is inside it. +func (b *Button) MouseMoved(*desktop.MouseEvent) {} + +// MouseOut forgets the pointer and any press it was holding. +// +// The press is forgotten here as well as in MouseUp because MouseUp does not +// necessarily come back: the driver sends the release to whatever is under the +// pointer when the button is let go (glfw/window.go, the Fyne guide section +// 3.27), so pressing this, sliding off and releasing would otherwise leave it +// drawn as pressed for the rest of its life. +func (b *Button) MouseOut() { + b.hovered, b.pressed = false, false + b.Refresh() +} + +// MouseDown draws the pressed face. +func (b *Button) MouseDown(*desktop.MouseEvent) { + b.pressed = true + b.Refresh() +} + +// MouseUp lets the press go. +func (b *Button) MouseUp(*desktop.MouseEvent) { + b.pressed = false + b.Refresh() +} + +// FocusGained draws the ring, because the keyboard is now here - and it only +// ever arrives here from the keyboard, see the note on marked. +func (b *Button) FocusGained() { + b.marked = true + b.Refresh() +} + +// FocusLost takes the ring away. +func (b *Button) FocusLost() { + b.marked = false + b.Refresh() +} + +// TypedRune answers nothing, and that is the whole of it. One press of the +// space bar reaches a focused control TWICE from the desktop driver: as the +// key (internal/driver/glfw/window.go, processKeyPressed, which ends in +// TypedKey) and as the character (processCharInput, which ends in +// TypedRune) - measured in the pinned module on 2026-09-16, after an outside +// review said so. The toolkit's own button leaves TypedRune empty for exactly +// this reason. Until that day this pressed on the space character as well, +// on the sentence that the toolkit's button "answers to space", which is true +// of its TypedKey and not of this hook - so one press of space on Add batch +// added two batches in the real window, and no guard saw it, because the test +// driver delivers a key and a character as two separate calls. +func (b *Button) TypedRune(rune) {} + +// TypedKey presses on Enter as well as the space bar. The toolkit answers space +// alone, so Enter on a focused button did nothing - and silence in place of an +// answer is the one thing this window refuses everywhere else. Both names of +// the key, because a keyboard has two and nobody thinks of them as different. +func (b *Button) TypedKey(event *fyne.KeyEvent) { + if event == nil { + return + } + switch event.Name { + case fyne.KeyReturn, fyne.KeyEnter, fyne.KeySpace: + b.Tapped(nil) + } +} + +// CreateRenderer draws the face. +func (b *Button) CreateRenderer() fyne.WidgetRenderer { + b.ExtendBaseWidget(b) + ring := canvas.NewRectangle(color.Transparent) + ring.CornerRadius = RadiusField + ring.StrokeColor = PaletteColour(theme.ColorNamePrimary, theme.VariantDark) + bg := canvas.NewRectangle(color.Transparent) + bg.CornerRadius = RadiusField + label := canvas.NewText("", color.Transparent) + label.TextStyle = fyne.TextStyle{Bold: true} + label.Alignment = fyne.TextAlignCenter + icon := canvas.NewImageFromResource(nil) + icon.FillMode = canvas.ImageFillContain + r := &buttonRenderer{button: b, ring: ring, bg: bg, label: label, icon: icon} + r.Refresh() + return r +} + +// buttonRenderer draws one of the four faces, in whatever state the button is. +// +// The objects are made once and their properties changed, rather than a face +// rebuilt each Refresh: the shape of this face does not differ between states, +// only its colours do, so there is nothing to swap. The ring is one of the +// objects rather than a thing laid over the top, and it is placed OUTSIDE the +// face - see ringGap - so that a mark meaning "the keyboard is here" is never +// the same blue as the face it marks. +type buttonRenderer struct { + button *Button + ring *canvas.Rectangle + bg *canvas.Rectangle + label *canvas.Text + icon *canvas.Image +} + +func (r *buttonRenderer) Layout(size fyne.Size) { + r.bg.Resize(size) + r.bg.Move(fyne.NewPos(0, 0)) + // Outside the face on every side, without the face giving up any room - + // Fyne clips nothing (the Fyne guide, section 3.3), so a child at a + // negative offset is drawn there. + r.ring.Resize(size.Add(fyne.NewSquareSize(ringGap * 2))) + r.ring.Move(fyne.NewPos(-ringGap, -ringGap)) + + glyph := r.button.look == Glyph + hasIcon := r.button.Icon != nil + hasLabel := r.button.Text != "" && !glyph + iconSize := fyne.NewSquareSize(Theme().Size(theme.SizeNameInlineIcon)) + labelSize := r.label.MinSize() + + switch { + case glyph: + r.icon.Resize(iconSize) + r.icon.Move(fyne.NewPos((size.Width-iconSize.Width)/2, (size.Height-iconSize.Height)/2)) + case hasIcon && hasLabel: + gap := float32(GapInline) + row := iconSize.Width + gap + labelSize.Width + x := (size.Width - row) / 2 + r.icon.Resize(iconSize) + r.icon.Move(fyne.NewPos(x, (size.Height-iconSize.Height)/2)) + r.label.Resize(labelSize) + r.label.Move(fyne.NewPos(x+iconSize.Width+gap, (size.Height-labelSize.Height)/2)) + default: + r.label.Resize(labelSize) + r.label.Move(fyne.NewPos((size.Width-labelSize.Width)/2, (size.Height-labelSize.Height)/2)) + } +} + +func (r *buttonRenderer) MinSize() fyne.Size { + if r.button.look == Glyph { + return fyne.NewSquareSize(GlyphButton) + } + size := r.label.MinSize() + if r.button.Icon != nil { + size.Width += Theme().Size(theme.SizeNameInlineIcon) + GapInline + } + // The room inside a box to type in, on both axes, so a button stands the + // same height as the field beside it. + return size.Add(fyne.NewSquareSize(ControlInset * 2)) +} + +func (r *buttonRenderer) Refresh() { + f := buttonFace(r.button.look, r.state()) + r.bg.FillColor = f.fill + r.bg.StrokeColor = f.edge + r.bg.StrokeWidth = f.edgeWidth + if r.button.marked { + r.ring.StrokeWidth = ringWidth + } else { + r.ring.StrokeWidth = 0 + } + r.label.Text = r.button.Text + r.label.Color = PaletteColour(f.ink, theme.VariantDark) + r.label.TextSize = TextBody + if r.button.Icon != nil { + // Coloured by the same ink as the words, so a glyph follows the state + // of the button it stands in - the toolkit's own way of tinting a + // resource. + r.icon.Resource = theme.NewColoredResource(r.button.Icon, f.ink) + r.icon.Show() + } else { + r.icon.Hide() + } + if r.button.Text == "" || r.button.look == Glyph { + r.label.Hide() + } else { + r.label.Show() + } + canvas.Refresh(r.button) + r.Layout(r.button.Size()) +} + +func (r *buttonRenderer) state() buttonState { + switch { + case r.button.Disabled(): + return stateDisabled + case r.button.pressed: + return statePressed + case r.button.hovered: + return stateHovered + default: + return stateRest + } +} + +func (r *buttonRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.ring, r.bg, r.icon, r.label} +} + +func (r *buttonRenderer) Destroy() {} + +// buttonState is which of a button's four states it is drawn in, in the order +// of precedence the toolkit uses: off wins over everything, then a press, then +// the pointer, then rest. +type buttonState int + +const ( + stateRest buttonState = iota + stateHovered + statePressed + stateDisabled +) + +// face is the colours one look wears in one state. +// +// The ink is a name rather than a colour, because both the words and a glyph +// have to draw in it and a glyph is coloured by name. +type face struct { + fill color.Color + ink fyne.ThemeColorName + edge color.Color + edgeWidth float32 +} + +// buttonFace is the colours of one look in one state, worked out from the +// palette rather than picked. The measurements behind the primary face are on +// ColorNameLift and ColorNameShade in theme.go. The rest follow from what a +// surface a button stands on already is. +func buttonFace(look Look, state buttonState) face { + dark := theme.VariantDark + border := face{edge: PaletteColour(theme.ColorNameSeparator, dark), edgeWidth: edgeWidth} + if state == stateDisabled { + // No fill on any look when it is off, and a faint outline so the shape + // is still there to see. The words recede to the disabled ink, which + // is a step brighter than a hint on purpose - a disabled button is a + // control somebody may want to read, see ColorNameDisabled. + border.ink = theme.ColorNameDisabled + if look == Quiet || look == Glyph { + border.edgeWidth = 0 + } + return border + } + + switch look { + case Primary: + f := face{fill: PaletteColour(theme.ColorNamePrimary, dark), ink: theme.ColorNameForegroundOnPrimary} + switch state { + case stateHovered: + f.fill = blended(f.fill, PaletteColour(ColorNameLift, dark)) + case statePressed: + f.fill = blended(f.fill, PaletteColour(ColorNameShade, dark)) + case stateRest, stateDisabled: + // The face as it is. Disabled fades the whole button elsewhere. + } + return f + case Secondary: + f := face{ink: theme.ColorNameForeground, edge: PaletteColour(theme.ColorNameInputBorder, dark), edgeWidth: edgeWidth} + f.fill = pointerFill(state) + return f + default: // Quiet and Glyph: no resting edge, a surface only under the pointer. + ink := theme.ColorNameForeground + if look == Glyph && state == stateRest { + ink = theme.ColorNamePlaceHolder + } + return face{fill: pointerFill(state), ink: ink} + } +} + +// pointerFill is the wash a face that is not filled at rest wears under the +// pointer and under a press - translucent, so it is right on whatever surface +// the button stands on. +func pointerFill(state buttonState) color.Color { + switch state { + case stateHovered: + return PaletteColour(theme.ColorNameHover, theme.VariantDark) + case statePressed: + return PaletteColour(theme.ColorNamePressed, theme.VariantDark) + default: + return color.Transparent + } +} diff --git a/internal/gui/parts/bytecount.go b/internal/gui/parts/bytecount.go index b3066cf4..e4c64951 100644 --- a/internal/gui/parts/bytecount.go +++ b/internal/gui/parts/bytecount.go @@ -1,12 +1,10 @@ package parts import ( - "fyne.io/fyne/v2" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/core" - "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) // ByteCount says what the size in a box comes to, counted out in bytes. @@ -22,12 +20,14 @@ import ( // different number than the files do - and if the box holds something that is // not a size it says nothing at all rather than guessing. // -// It lives on the field's NAME line, at the far right, and that placement is -// the whole reason this was cheap. A line of its own under the box would have -// cost about eighteen pixels per size field, and the single batch screen fits -// its window today with about seventeen to spare - so the honest version of -// "put it under the box" was "make the screen scroll". The name line has the -// rest of the column empty. +// It stands beside the box, on the same line, since 2026-09-14. It lived at +// the far right of the field's name line before that - the cheap place while a +// name stood over its box, and 190 px from the box it was counting for. A +// field is one line now and the count is the thing after the box on it. +// +// Spelled by core.ExactBytes, which is what the command line prints. The +// window had a spelling of its own without the grouping until 2026-09-14, so +// one number came out as 10485760 B here and 10 485 760 B there. type ByteCount struct { widget.Label } @@ -40,7 +40,6 @@ func newByteCount() *ByteCount { // it wants it. c.SizeName = theme.SizeNameCaptionText c.Importance = widget.LowImportance - c.Alignment = fyne.TextAlignTrailing return c } @@ -55,5 +54,5 @@ func (c *ByteCount) show(size string) { c.SetText("") return } - c.SetText(text.ExactBytes(bytes)) + c.SetText(core.ExactBytes(bytes)) } diff --git a/internal/gui/parts/detail.go b/internal/gui/parts/detail.go index 1c042b17..f8930631 100644 --- a/internal/gui/parts/detail.go +++ b/internal/gui/parts/detail.go @@ -5,19 +5,10 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) -// DetailWidth is how wide the longer explanation gets when it opens. -// -// Narrower than the form on purpose. The column is 820 px because that is what -// the form needs, and the same width for a paragraph of prose is about 112 -// characters a line - well past the 45 to 75 that reads easily. A block of text -// with nothing beside it has no reason to be as wide as a row of fields. -const DetailWidth = 380 - // Tips is the sheet a screen's explanations are drawn on. // // It exists because of where an explanation may NOT go, and that took two @@ -105,18 +96,6 @@ func alsoSaying(line string, detail Detail) Detail { return Detail{Text: text.OneExplanation(line, detail.Text), on: detail.on} } -// withDetail puts the button that opens the longer explanation beside a label. -// -// Nothing at all when there is nothing more to say, rather than a button that -// opens an empty box. A control that is always there and sometimes does nothing -// teaches people to stop pressing it. -func withDetail(head fyne.CanvasObject, detail Detail) fyne.CanvasObject { - if detail.Text == "" || detail.on == nil { - return head - } - return container.NewHBox(head, newDetailButton(detail)) -} - // DetailButton is the small control that shows one field's explanation. // // Exported for the same reason ErrorArea is: a guard has to be able to tell it @@ -126,7 +105,12 @@ func withDetail(head fyne.CanvasObject, detail Detail) fyne.CanvasObject { // // An icon rather than a word, because it sits on the same line as the field // name and a word there competes with it. Low importance so it recedes: it is -// the quietest thing on the row until somebody wants it. +// the quietest thing on the row until somebody wants it. Quiet in colour as +// well, the grey a hint is drawn in, and no bigger than the glyph and the room +// a finger needs: it was 32 px square until 2026-09-14, the tallest thing on +// every line it stood on, so the name of a field stood 13 px above the middle +// of its own row and thirty three of these were the loudest marks on a screen +// at rest. // // It opens on HOVER, which is what anybody meeting a small letter i expects, // and a press toggles it. Both, deliberately: hovering is not something a @@ -140,7 +124,7 @@ func withDetail(head fyne.CanvasObject, detail Detail) fyne.CanvasObject { // desktop.Hoverable, three methods a widget can answer, so this is built here // and no third party package enters the graph for it. type DetailButton struct { - widget.Button + Button detail Detail // shown is the box while it is on the sheet, and nil when it is not. Only @@ -150,9 +134,9 @@ type DetailButton struct { func newDetailButton(detail Detail) *DetailButton { b := &DetailButton{detail: detail} - b.ExtendBaseWidget(b) + b.look = Glyph b.Icon = theme.InfoIcon() - b.Importance = widget.LowImportance + b.ExtendBaseWidget(b) b.OnTapped = b.toggle return b } @@ -227,7 +211,7 @@ func (t *Tips) open(near fyne.CanvasObject, detail string) fyne.CanvasObject { } driver := app.Driver() - box := container.NewStack(panelSurface(), container.NewPadded(Prose(detail))) + box := container.NewStack(panelSurface(), Padded(Inset, Prose(detail))) // Sized twice, and this is the same finding the render probe records rather // than superstition. A wrapping label reports the height it needs for the diff --git a/internal/gui/parts/entry.go b/internal/gui/parts/entry.go index 89e95c2d..f5c1d667 100644 --- a/internal/gui/parts/entry.go +++ b/internal/gui/parts/entry.go @@ -33,6 +33,14 @@ type Entry struct { // onOurs is where a shortcut goes when this box has no use for it. Set by // the screen, because the screen is what knows the canvas. onOurs func(fyne.Shortcut) + + // ring is the edge a field draws round this box when the keyboard is in it + // or a run refused it, so a box carries the same 2 px mark as the menu and + // the switch beside it rather than only the toolkit's own 1 px border - + // which came out at 2.0 against the resting border, under the 3.0 a state + // is held to (O207). Nil until a field wires one, which is every field the + // box stands in. + ring *Ring } // NewEntry builds one. @@ -42,6 +50,28 @@ func NewEntry() *Entry { return e } +// useRing takes the edge a field draws round this box. The box keeps its own +// toolkit border at rest - the ring stays invisible until the keyboard arrives +// or a run refuses the box - so there is one line round it in every state and +// the ring is the 2 px one that says something has changed. +func (e *Entry) useRing(r *Ring) { e.ring = r } + +// FocusGained draws the ring as well as letting the box do its own thing. +func (e *Entry) FocusGained() { + e.Entry.FocusGained() + if e.ring != nil { + e.ring.Focus(true) + } +} + +// FocusLost takes the ring away again. +func (e *Entry) FocusLost() { + e.Entry.FocusLost() + if e.ring != nil { + e.ring.Focus(false) + } +} + // PassShortcutsTo says where a shortcut this box has no use for should go. // // Called with the canvas rather than resolved from the widget, because diff --git a/internal/gui/parts/field.go b/internal/gui/parts/field.go index 59c3ad68..36d1442a 100644 --- a/internal/gui/parts/field.go +++ b/internal/gui/parts/field.go @@ -7,16 +7,15 @@ import ( "fyne.io/fyne/v2/widget" ) -// Field is one labelled control, the line that explains it, and the longer -// explanation behind a button. +// Field is one labelled control, with the longer explanation behind a button +// beside its name. // // Two lengths rather than one, split on 2026-08-12. The sentence used to be // whole and permanent, so a form of eight settings carried eight grey // paragraphs and the explanations took more vertical room than the controls // did - measured on the generate screen, where the help outweighed the fields -// it was helping. What stays under a field is the line that says what it does. -// What goes behind the button is the consequence, the example and the units, -// which are things somebody needs once. +// it was helping. Since 2026-08-25 the line that says what a field does is the +// first line of the explanation, so at rest a field is its name and its box. // // It is a button rather than a tooltip because the toolkit has no tooltips - // issue 1650 is still open - and it is visible rather than a hover because a @@ -24,6 +23,7 @@ import ( // renders is the shape docs/CLAUDE.md warns about: text with no reader becomes // pressure on the text beside it, and the label starts trying to say // everything on its own. +// // The plain Field function is gone as of 2026-08-12, and its absence is the // fix rather than a side effect of one. It built a field with nowhere to put a // refusal about it, so which boxes could be marked depended on which of two @@ -33,6 +33,11 @@ import ( // FieldSaying is a field that can carry a refusal of its own, underneath it. // +// One row: the name in the column of names, the control beside it, and +// whatever the control has to say about its value - the count of bytes a +// size comes to - after the control. See fieldRow for why a row and not a +// name over a box. +// // UX8 asks for a message near where the error came from, and in a window that // means beside the field rather than at the foot of the form. Measured on // 2026-08-11: the refusal about "how many" sat 748 px below the box it named, @@ -46,11 +51,28 @@ import ( // as the sentence is there. Two marks rather than one on purpose - a colour on // its own says nothing to somebody who cannot tell it from the others, and a // sentence on its own leaves them looking for which of eight boxes it means. -func FieldSaying(label, hint string, detail Detail, required bool, trailing, control fyne.CanvasObject) (object, body fyne.CanvasObject, area *ErrorArea) { +func FieldSaying(names float32, label string, detail Detail, required bool, trailing, control fyne.CanvasObject) (object, body fyne.CanvasObject, area *ErrorArea) { marked, ring := WithRing(shapedForItsValues(control)) - area = NewErrorArea() + area = newErrorArea(names) area.edge = ring - body = Column(GapTight, fieldParts(label, hint, detail, required, trailing, marked)...) + cells := []fyne.CanvasObject{headingRow(label, detail, required), marked} + if trailing != nil { + cells = append(cells, trailing) + } + body = FieldRow(names, cells...) + return Column(GapTight, body, area.Object()), body, area +} + +// CellSaying is a field drawn as a cell of a table: the name over the control, +// for a list of rows that all have the same columns. A row of the form puts +// the name beside the control - see FieldSaying - and a table puts the names +// once, over the columns, where three files inside an archive would otherwise +// carry nine names for three kinds of value. +func CellSaying(label string, detail Detail, required bool, control fyne.CanvasObject) (object, body fyne.CanvasObject, area *ErrorArea) { + marked, ring := WithRing(shapedForItsValues(control)) + area = newErrorArea(0) + area.edge = ring + body = Column(GapLabel, headingRow(label, detail, required), marked) return Column(GapTight, body, area.Object()), body, area } @@ -72,44 +94,6 @@ func shapedForItsValues(control fyne.CanvasObject) fyne.CanvasObject { return control } -// fieldParts is the run of pieces every field is made of, in order. -// -// One place rather than two, because the pair above differed by a single line -// and the pair is where a field's shape would quietly come apart - one of them -// growing a button and the other not. -func fieldParts(label, hint string, detail Detail, required bool, trailing, control fyne.CanvasObject) []fyne.CanvasObject { - items := []fyne.CanvasObject{headingRow(label, detail, required, trailing), control} - if hint != "" { - items = append(items, Note(hint)) - } - return items -} - -// ToggleSaying lays out a switch that carries its own name, with the -// explanation behind the button beside it. -// -// A switch is the one control that does not take a heading above it. Given one -// it arrives as a bare square with the words somewhere else: the name above, -// the sentence below, and nothing to read on the thing you click. That is what -// O72 saw on screen. Putting the name on the switch also makes the words part -// of the target, which is the difference between a click and an aimed click. -// It carries no edge of its own. A ring round a switch is a ring round the -// words as well as the square - measured from a screenshot on 2026-08-12, -// where it read as a box drawn around a sentence - and a switch has two -// positions, neither of which the engine can refuse. What it does get is -// somewhere to speak, because "every field has one" is worth more than the one -// exception nobody would remember. -func ToggleSaying(name, hint string, detail Detail, check *Toggle) (object, body fyne.CanvasObject, area *ErrorArea) { - check.Text = name - area = NewErrorArea() - items := []fyne.CanvasObject{withDetail(WithRoomForItsName(check), detail)} - if hint != "" { - items = append(items, Note(hint)) - } - body = Column(GapTight, items...) - return Column(GapTight, body, area.Object()), body, area -} - // Note is a quiet line under something, for what a person needs once. // // Quiet by weight rather than by slant. docs/UX.md section 8.5 says italics @@ -126,57 +110,54 @@ func ToggleSaying(name, hint string, detail Detail, check *Toggle) (object, body // worse than the italics it was replacing. So it went in as ordinary text // until the palette arrived. // -// Under our palette the same widget.LowImportance is text-subdued, #9DA3A8, -// which computes to 7.03:1. The control was never the problem. O70 and O71. +// It goes through quiet rather than inkTight since 2026-09-15, which is O213: +// widget.LowImportance draws in ColorNameDisabled, #C2C8CD at 80 L*, a step +// brighter than the hint a caption is meant to be. quiet remaps it to the +// placeholder, #9DA3A8 at 66.7 L*, 5.61:1 on a panel. O70 and O71. func Note(content string) fyne.CanvasObject { label := widget.NewLabel(content) label.Wrapping = fyne.TextWrapWord label.Importance = widget.LowImportance - // Smaller as well as quieter. At the same size as the value above it, an - // explanation of two lines outweighs the field it explains - which is the - // screen this one was: more words than controls, and the words winning. label.SizeName = theme.SizeNameCaptionText - return label + return quiet(label) } -// 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 -// this tool has four parts - what happened, why, what is allowed, what to do -// instead - and a control that shows one line forces a message carrying one of -// the four. So this wraps, it never truncates, and it holds nothing at all -// until there is something to say. An empty red box on a fresh screen reads as -// a fault that has already happened. +// ErrorArea is where a field says what a run said about it. type ErrorArea struct { label *widget.Label - box *fyne.Container - - // edge is the line round the control this area belongs to, or nil for the - // area at the foot of the form, which is about the run rather than about - // one box. Set by FieldSaying, so a refusal marks and unmarks the box in - // the same call that shows and hides the sentence - two things that would - // otherwise be two calls, and the second one is the one somebody forgets on - // the path where the run succeeds. + box fyne.CanvasObject + // edge is the line round the control, if it has one. Marked and cleared + // with the sentence, so the two never disagree. edge *Ring } -// NewErrorArea returns an area with nothing in it. -func NewErrorArea() *ErrorArea { +// NewErrorArea builds one that stands on its own, for the line at the foot of +// the form that speaks for the whole run. +func NewErrorArea() *ErrorArea { return newErrorArea(0) } + +// newErrorArea builds one for a field whose names stand in a column that +// wide, so the sentence starts under the control it is about rather than +// under the name. Nought for a cell of a table, where the sentence starts +// under the cell. +func newErrorArea(names float32) *ErrorArea { label := widget.NewLabel("") label.Wrapping = fyne.TextWrapWord label.Importance = widget.DangerImportance - - area := &ErrorArea{label: label, box: container.NewVBox(label)} + var box fyne.CanvasObject + if names > 0 { + box = FieldRow(names, Clear(), inkTight(label)) + } else { + box = container.NewVBox(inkTight(label)) + } + area := &ErrorArea{label: label, box: box} area.Clear() return area } -// Object is the area, to put on a screen. +// Object is what the area puts on the screen. func (a *ErrorArea) Object() fyne.CanvasObject { return a.box } -// Say shows one refusal, whole. The text arrives as the engine wrote it - the -// window does not shorten it, because every one of the four parts is there -// because somebody could not act without it. +// Say shows a sentence, or clears the area when handed nothing. func (a *ErrorArea) Say(text string) { if text == "" { a.Clear() @@ -187,22 +168,18 @@ func (a *ErrorArea) Say(text string) { a.mark(true) } -// Clear takes the last refusal back, which is what every fresh attempt starts -// with. A message left over from the previous press describes a state that is -// no longer true. +// Clear takes the sentence away and gives the room back. func (a *ErrorArea) Clear() { a.label.SetText("") a.box.Hide() a.mark(false) } -// mark turns the edge of the control this area belongs to on or off. The area -// at the foot of the form has no control, and says so by having no edge. func (a *ErrorArea) mark(refused bool) { if a.edge != nil { a.edge.Refuse(refused) } } -// Text is what the area is currently saying, for a guard to read. +// Text is what the area says, for the guards that read it back. func (a *ErrorArea) Text() string { return a.label.Text } diff --git a/internal/gui/parts/fieldrow.go b/internal/gui/parts/fieldrow.go new file mode 100644 index 00000000..6086f682 --- /dev/null +++ b/internal/gui/parts/fieldrow.go @@ -0,0 +1,126 @@ +package parts + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "image/color" +) + +// A field is one row: its name in a column of names, its control in a column +// of controls, so every name lines up with every other name and every value +// starts on one edge. GUI rule 13 of CLAUDE.md, in the owner's words: a form +// is a grid with a column of labels, not a centred stack of controls. +// +// Until 2026-09-14 a field was the name ABOVE the control, which cost two +// rows a field and put the name of a box 32 px above the box because the +// button beside the name was 32 px tall. In the row the name and the control +// share a line, so the height of a field is the height of its control. + +// fieldRow lays a field out as cells: the first is the name and gets the +// column of names, the last gets whatever width is left, and anything between +// stands at its own width. GapColumns separates them. +// +// The last cell takes the rest rather than every cell sharing it out, so a +// box holding a path fills the row and a box holding a number, which sizes +// itself, does not - the row does not decide how wide a control is, the +// control does (see Numeric and Sized). +// +// Every cell keeps its own height and is centred on the row's, so a name +// stands level with the middle of its box rather than with the top of it, and +// the line drawn round a refused box goes round the box and not round the +// row - the stack holding a control and its mark is as tall as the control. +type fieldRow struct{ names float32 } + +// FieldRow puts objects into one row of the form: the name cell first, the +// control next, anything else after. For the screens that need a row the +// field builders do not make - a switch that belongs to three boxes, a +// button that belongs to no field. +func FieldRow(names float32, cells ...fyne.CanvasObject) fyne.CanvasObject { + return container.New(fieldRow{names: names}, cells...) +} + +func (f fieldRow) MinSize(objects []fyne.CanvasObject) fyne.Size { + size := fyne.NewSize(0, 0) + shown := 0 + for i, o := range objects { + if !o.Visible() { + continue + } + min := o.MinSize() + if i == 0 { + size.Width += fyne.Max(min.Width, f.names) + } else { + size.Width += min.Width + } + size.Height = fyne.Max(size.Height, min.Height) + shown++ + } + if shown > 1 { + size.Width += GapColumns * float32(shown-1) + } + return size +} + +func (f fieldRow) Layout(objects []fyne.CanvasObject, size fyne.Size) { + last := -1 + for i, o := range objects { + if o.Visible() { + last = i + } + } + x := float32(0) + for i, o := range objects { + if !o.Visible() { + continue + } + width := o.MinSize().Width + switch { + case i == 0: + width = fyne.Max(width, f.names) + case i == last: + width = fyne.Max(width, size.Width-x) + } + height := fyne.Min(o.MinSize().Height, size.Height) + o.Resize(fyne.NewSize(width, height)) + o.Move(fyne.NewPos(x, (size.Height-height)/2)) + x += width + GapColumns + } +} + +// Clear is a shape that takes room and draws nothing. +// +// For the name cell of a row whose control carries its own name - a switch +// says what it is on the part somebody clicks - so the control still stands +// in the column of controls. Transparent rather than the colour of the panel, +// because a rectangle in the colour of the thing under it is right at the call +// site and wrong on the first screen that puts the row on something else. +func Clear() fyne.CanvasObject { + return canvas.NewRectangle(color.Transparent) +} + +// WidestName is how wide a column of names has to be to hold every name given, +// with the star and the button that can stand beside one. +// +// Measured from the same objects a heading is drawn with, so the answer and +// the drawing cannot come apart - a width worked out from a copy of the +// arithmetic would be a second copy of the drawing (the sibling project paid +// four pixels for exactly that on 2026-09-09). +func WidestName(names ...string) float32 { + widest := float32(0) + for _, name := range names { + if w := Heading(name).MinSize().Width; w > widest { + widest = w + } + } + return widest + GapInline + newRequiredMark().MinSize().Width + GapLabel + GlyphButton +} + +// IsFieldRow says whether a container is one row of the form, for a guard +// reading the names off a screen. Asked by the layout rather than by shape, +// because a section is also a container whose first thing is words and whose +// second is not. +func IsFieldRow(c *fyne.Container) bool { + _, is := c.Layout.(fieldRow) + return is +} diff --git a/internal/gui/parts/fields.go b/internal/gui/parts/fields.go index bd057596..c4001dfe 100644 --- a/internal/gui/parts/fields.go +++ b/internal/gui/parts/fields.go @@ -100,8 +100,27 @@ type Fields struct { // the wiring would be one where Ctrl+Enter does nothing, which is a defect // nobody would find by looking. shortcuts func(fyne.Shortcut) + + // names is how wide the column of names is, so every field on the screen + // puts its control on the same edge. Set once by the screen from the + // widest name the window can ever show - see LabelColumn - and nought + // until then, which lays a name out at its own width. + names float32 } +// LabelColumn says how wide the column of names is on this screen. +// +// Every field built after this stands its control on that edge. Handed in +// rather than measured from the fields as they arrive, because the settings a +// chosen format declares arrive after the screen is built, and a column that +// widened to fit them would move every control on the screen the moment +// somebody chose a format with a long setting name. +func (s *Fields) LabelColumn(width float32) { s.names = width } + +// Names is the width of the column of names, for a screen laying out a row +// of its own beside the fields. +func (s *Fields) Names() float32 { return s.names } + // PassShortcutsTo says where the boxes of this screen should send a shortcut // they have no use for. Called once, before the fields are built. func (s *Fields) PassShortcutsTo(deliver func(fyne.Shortcut)) { @@ -192,7 +211,13 @@ func (s *Fields) counter(setting string, control fyne.CanvasObject) fyne.CanvasO // And once now, for a box that arrives with a size already in it. count.show(b.Text) } - return count + // Beside the box on its own line, with no room of its own around the ink, + // so what the row puts between the box and the count is what reaches the + // screen. Until 2026-09-14 it stood at the far end of the name's line, 190 + // px from the box it was counting for. Drawn through quiet since 2026-09-15 + // - it is a low importance label, so the same O213 that dimmed a caption + // dimmed this. See parts.quiet. + return quiet(count) } // Add builds a field and hands back the thing to put on the screen. @@ -203,8 +228,21 @@ func (s *Fields) Add(setting, label, hint string, detail Detail, control fyne.Ca // The line under a field is now the first sentence behind its button - see // alsoSaying. Folded here rather than at the thirty-two call sites, so a // field that still carries one is not something anybody can write. - object, body, area := FieldSaying(label, "", alsoSaying(hint, detail), + object, body, area := FieldSaying(s.names, label, alsoSaying(hint, detail), s.required[setting], s.counter(setting, control), control) + return s.register(setting, label, control, object, body, area) +} + +// AddCell builds a field as a cell of a table - its name over its control - +// for the lists whose rows all have the same columns. Everything else about +// it is a field: the refusal, the edge, the count, the registry. +func (s *Fields) AddCell(setting, label, hint string, detail Detail, control fyne.CanvasObject) fyne.CanvasObject { + object, body, area := CellSaying(label, alsoSaying(hint, detail), s.required[setting], control) + return s.register(setting, label, control, object, body, area) +} + +// register is what every kind of field goes through once it is built. +func (s *Fields) register(setting, label string, control, object, body fyne.CanvasObject, area *ErrorArea) fyne.CanvasObject { f := &Field{Setting: setting, Label: label, Control: control, area: area, object: object, body: body} s.list = append(s.list, f) // Last one wins, which is what a rebuilt screen needs: the preset screen @@ -216,20 +254,28 @@ func (s *Fields) Add(setting, label, hint string, detail Detail, control fyne.Ca return object } -// AddToggle is a switch, which is the one control that carries its own name. +// Unlabelled is a row of the form for something that is not a field and has +// no name of its own - the switch that chooses between three ways of saying +// how big - so it stands in the column of controls like everything else. +func (s *Fields) Unlabelled(control fyne.CanvasObject) fyne.CanvasObject { + return FieldRow(s.names, Clear(), control) +} + +// AddToggle is a switch, and since 2026-09-15 it is a field like any other: its +// name in the column of names, its square in the column of controls. // -// It goes through the same registry as everything else. A switch cannot hold a -// value the engine refuses today, and leaving it out would be an exception to -// remember - which is the class of thing this type exists to end. +// It used to be the one field whose name sat on the control rather than in the +// column - the answer to O72 while names stood ABOVE their boxes. In a grid the +// name stands beside the square like every other name, so this is Add with no +// special case: WithRing leaves the square alone because it draws its own edge, +// and the switch cannot be refused so its error area never speaks. func (s *Fields) AddToggle(setting, name, hint string, detail Detail, check *Toggle) fyne.CanvasObject { - object, body, area := ToggleSaying(name, "", alsoSaying(hint, detail), check) - f := &Field{Setting: setting, Label: name, Control: check, area: area, object: object, body: body} - s.list = append(s.list, f) - s.by[setting] = f - return object + return s.Add(setting, name, hint, detail, check) } -// Row puts fields side by side and gives their refusals the whole width. +// Row puts cells of a table side by side and gives their refusals the whole +// width. For fields built with AddCell - a field built with Add is a row of +// the form already and stands under the one before it. // // A refusal in this tool has four parts - what happened, why, what is allowed, // what to do instead - so it is a sentence and not a word. Inside a column of @@ -290,23 +336,67 @@ func (s *Fields) WhenTypedIn(tell func(setting string)) { s.tell = tell } -// listen makes every box under one control report what is typed into it. +// listen makes every control under one field report a change to it: a box +// when it is typed into, a menu when a value is chosen, a switch when it is +// flipped. // // Chained rather than assigned, because a control that already had something to -// do on a change keeps doing it. Nothing in this window does today, and a -// silently dropped callback is the kind of thing nobody notices until the -// screen stops reacting. +// do on a change keeps doing it - the format menu rebuilds the settings under +// it, and a switch on the batch screen shows and hides boxes. A silently +// dropped callback is the kind of thing nobody notices until the screen stops +// reacting. The chain runs the control's own work FIRST, so what is reported +// is read off a screen that has already changed. +// +// Menus and switches since 2026-09-14. Only boxes reported until then, which +// was enough while the only listener was the live check and the only thing a +// menu could be wrong about was nothing. It stopped being enough when the +// foot of the form started saying what the form comes to: a format chosen from +// the menu changed the run and the line went on naming the old one. func (s *Fields) listen(setting string, control fyne.CanvasObject) { - for _, box := range boxesIn(control) { - already := box.OnChanged - box.OnChanged = func(value string) { - if already != nil { - already(value) + // Read at the moment somebody types rather than at the moment this is + // wired, so a listener asked for after the field exists still hears it. + report := func() { + if s.tell != nil { + s.tell(setting) + } + } + walkControls(control, func(o fyne.CanvasObject) { + switch it := o.(type) { + case *Entry: + already := it.OnChanged + it.OnChanged = func(value string) { + if already != nil { + already(value) + } + report() + } + case *Chooser: + already := it.OnChanged + it.OnChanged = func(value string) { + if already != nil { + already(value) + } + report() } - if s.tell != nil { - s.tell(setting) + case *Toggle: + already := it.OnChanged + it.OnChanged = func(on bool) { + if already != nil { + already(on) + } + report() } } + }) +} + +// walkControls visits a control and everything inside it, containers included. +func walkControls(o fyne.CanvasObject, visit func(fyne.CanvasObject)) { + visit(o) + if box, ok := o.(*fyne.Container); ok { + for _, child := range box.Objects { + walkControls(child, visit) + } } } diff --git a/internal/gui/parts/focus.go b/internal/gui/parts/focus.go index 1f55d3e8..9fb3e970 100644 --- a/internal/gui/parts/focus.go +++ b/internal/gui/parts/focus.go @@ -1,12 +1,7 @@ package parts import ( - "image/color" - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" ) // PointerFocus is the part of a control that knows what put the keyboard in it. @@ -66,114 +61,3 @@ func FocusQuietly(canvas fyne.Canvas, control fyne.Focusable) { } canvas.Focus(control) } - -// Toggle is a switch that shows the keyboard mark only when the keyboard put it -// there. -// -// The toolkit draws that mark as a disc behind the square, filled with the focus -// colour - widget/check.go updateFocusIndicator - and its shape cannot be -// changed from a theme, because the geometry is fixed in checkRenderer.Layout -// and the theme supplies only the colour. That is why this is not a third shape: -// there is no shape to choose. The disc is right for the keyboard and wrong for -// the mouse, so it is drawn for one and not the other. -// -// A ring is not the answer either and that is written down rather than guessed. -// One was built on 2026-08-12 and withdrawn the same hour, because a ring round -// a checkbox goes round its words as well as its square and reads as a box drawn -// around a sentence. See the note at the foot of ring.go. -type Toggle struct { - widget.Check - - from PointerFocus - marked bool -} - -// NewToggle makes a switch carrying its own name. -func NewToggle(name string, changed func(bool)) *Toggle { - t := &Toggle{} - t.Text = name - t.OnChanged = changed - t.ExtendBaseWidget(t) - return t -} - -// WithRoomForItsName puts a switch on the screen with its words clear of its -// square. -// -// Measured off the stored tree on 2026-08-18, after the focus disc stopped -// being drawn for a press and stopped filling the space: the square spans x=4 -// to x=24 and the words start at x=28, so four pixels separate a 20 px box from -// the sentence beside it and they read as touching. O95. -// -// The arithmetic is the toolkit's - checkRenderer.Layout puts the words at -// iconInline + innerPadding + 2 * inputBorder and the square at innerPadding/2 + -// inputBorder - so the gap is half the inner padding minus one border. There is -// no size named for it. Raising the inner padding FOR THIS SUBTREE moves the -// two apart without touching a single box or button on the form, which is the -// knob a note in theme.go said did not exist until 2026-08-18. See -// parts/openlist.go for where that was found. -func WithRoomForItsName(t *Toggle) fyne.CanvasObject { - return container.NewThemeOverride(t, roomierToggle{}) -} - -// roomierToggle is our theme with more room inside a switch and nowhere else. -type roomierToggle struct{} - -func (roomierToggle) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { - return Theme().Color(n, v) -} -func (roomierToggle) Font(s fyne.TextStyle) fyne.Resource { return Theme().Font(s) } -func (roomierToggle) Icon(n fyne.ThemeIconName) fyne.Resource { return Theme().Icon(n) } -func (roomierToggle) Size(n fyne.ThemeSizeName) float32 { - if n == theme.SizeNameInnerPadding { - // Ten rather than six takes the gap from four pixels to six, which is - // what the rows of an open list use between their mark and their words. - // One number for "a small gap beside a glyph" across the window. - return 10 - } - return Theme().Size(n) -} - -// Tapped flips the switch and takes the keyboard without drawing the mark. -// -// The value is flipped by the toolkit rather than here, on purpose: widget.Check -// changes Checked inside Tapped, so a switch that set its own value would set it -// twice or disagree about which press did it. -func (t *Toggle) Tapped(event *fyne.PointEvent) { - t.from.Quietly(func() { t.Check.Tapped(event) }) -} - -// FocusGained draws the mark only for the keyboard. See PointerFocus. -func (t *Toggle) FocusGained() { - if t.from.Quiet() { - return - } - t.mark() -} - -func (t *Toggle) mark() { - t.marked = true - t.Check.FocusGained() -} - -func (t *Toggle) FocusLost() { - t.marked = false - t.Check.FocusLost() -} - -// Quietly runs a focus change without drawing the mark. See FocusQuietly. -func (t *Toggle) Quietly(focus func()) { t.from.Quietly(focus) } - -// TypedKey turns the mark on, because somebody has now used the keyboard. Space -// flips the switch and the toolkit does that part. -func (t *Toggle) TypedKey(event *fyne.KeyEvent) { - if !t.marked { - t.mark() - } - t.Check.TypedKey(event) -} - -// Marked says whether the keyboard mark is drawn, for a guard. The toolkit keeps -// this in an unexported field, so the alternative is reading a colour off the -// canvas and deciding what it meant. -func (t *Toggle) Marked() bool { return t.marked } diff --git a/internal/gui/parts/folding.go b/internal/gui/parts/folding.go index 83987de3..c0d2a7c8 100644 --- a/internal/gui/parts/folding.go +++ b/internal/gui/parts/folding.go @@ -33,7 +33,7 @@ type Folding struct { open bool body fyne.CanvasObject line *widget.Label - toggle *widget.Button + toggle *Button // object is the whole thing, rebuilt when the fold moves so the layout // above it is told to take the room back. @@ -61,7 +61,7 @@ type Folding struct { // do the two things they are most likely to want from a list of them. func NewFolding(title string, head []fyne.CanvasObject, content ...fyne.CanvasObject) *Folding { f := newFolding(title, head, content...) - f.object = container.NewStack(panelSurface(), container.NewPadded(f.inside)) + f.object = container.NewStack(panelSurface(), Padded(Inset, f.inside)) return f } @@ -90,8 +90,7 @@ func newFolding(title string, head []fyne.CanvasObject, content ...fyne.CanvasOb f.line.Importance = widget.LowImportance f.line.Hide() - f.toggle = widget.NewButtonWithIcon("", theme.MenuDropDownIcon(), func() { f.Set(!f.open) }) - f.toggle.Importance = widget.LowImportance + f.toggle = NewGlyphButton(theme.MenuDropDownIcon(), func() { f.Set(!f.open) }) f.body = Column(GapField, content...) @@ -104,7 +103,9 @@ func newFolding(title string, head []fyne.CanvasObject, content ...fyne.CanvasOb // (TestEverythingAPersonReadsStartsOnOneLeftEdge). Indenting the section's // contents to match would put its fields off that edge instead, which is // worse - there are more of them and they are what somebody is reading. - row := []fyne.CanvasObject{sectionTitle(title), f.toggle, f.line, layout.NewSpacer()} + // The summary line goes through quiet, so it recedes to the hint's colour + // rather than the brighter disabled one widget.LowImportance draws (O213). + row := []fyne.CanvasObject{sectionTitle(title), f.toggle, quiet(f.line), layout.NewSpacer()} row = append(row, head...) f.inside = Column(GapField, container.NewHBox(row...), f.body) diff --git a/internal/gui/parts/listrow.go b/internal/gui/parts/listrow.go index bd9f06d6..7ce09e80 100644 --- a/internal/gui/parts/listrow.go +++ b/internal/gui/parts/listrow.go @@ -79,7 +79,7 @@ func (r *ListRow) MouseMoved(*desktop.MouseEvent) {} func (r *ListRow) CreateRenderer() fyne.WidgetRenderer { back := canvas.NewRectangle(color.Transparent) - back.CornerRadius = Theme().Size(theme.SizeNameInputRadius) + back.CornerRadius = RadiusField tick := canvas.NewImageFromResource(theme.ConfirmIcon()) kind := canvas.NewImageFromResource(nil) label := canvas.NewText("", Theme().Color(theme.ColorNameForeground, theme.VariantDark)) diff --git a/internal/gui/parts/openlist.go b/internal/gui/parts/openlist.go index e93d98b8..faf25f7d 100644 --- a/internal/gui/parts/openlist.go +++ b/internal/gui/parts/openlist.go @@ -2,6 +2,7 @@ package parts import ( "image/color" + "math" "strings" "fyne.io/fyne/v2" @@ -34,30 +35,6 @@ import ( // person types taken as a jump, and the value in the box marked in a list we // can read back. See docs/UX.md and OBSERVATIONS.md O92c and O92d. -// visibleRows is how many rows are shown before the list starts scrolling. -// -// Eight, decided by the owner on 2026-08-18. NN/g puts it as a rule rather than -// a number - the label and the context stay in view while the list is open - -// and eight is what leaves most of the form visible at the window sizes this -// program opens at, including 800x600. -const visibleRows = 8 - -// rowPadding is the room above and below a row's contents. -// -// Ours rather than the theme's, which is the entire point of this control. The -// theme's innerPadding is what a box to type in and a button are also built -// from, so a list cannot be made denser through it without making every control -// on the form denser too. -const rowPadding = 4 - -// rowGutter is the room in front of the mark, and rowGap the room after it. -// Together they put the text where the toolkit put it, so this change moves the -// density and nothing else - one thing at a time. -const ( - rowGutter = 6 - rowGap = 6 -) - // OpenList is the list of values a Chooser drops down. type OpenList struct { widget.BaseWidget @@ -192,27 +169,27 @@ type Choice struct { Marked bool } -// MinSize is as wide as the widest value and as tall as visibleRows of them. +// MinSize is as wide as the widest value and as tall as all of them, cut to +// the room the list was told it has. // // The ceiling is the point. Without one the list is as tall as it likes, which // at thirteen formats already covered the form and at twenty-five would not fit -// in the window. +// in the window. The ceiling is not worked out here, because it is a share of +// the window (ListCeiling) and the list does not know the window - the Chooser +// that opens it does, and tells it through LimitTo. Until 2026-09-15 a count +// of rows lived here instead, which made the list 224 px tall in every window +// there is (O203). func (l *OpenList) MinSize() fyne.Size { rows := len(l.options) - if rows > visibleRows { - rows = visibleRows - } if rows < 1 { rows = 1 } height := float32(rows) * listRowHeight() - // The room the window has left beats the row ceiling, where there is less - // of it. The ceiling is about not covering the form and says nothing about - // a window with fewer than eight rows to spare - and this has to happen in - // MinSize rather than by resizing the popup afterwards, because a popup is - // never laid out smaller than its content's minimum. Measured on - // 2026-08-19: asking for 195 px around a list whose minimum was 224 gave - // 224 (O113). + // The room the window has left is the whole of the ceiling, and this has + // to happen in MinSize rather than by resizing the popup afterwards, + // because a popup is never laid out smaller than its content's minimum. + // Measured on 2026-08-19: asking for 195 px around a list whose minimum + // was 224 gave 224 (O113). if l.room > 0 && height > l.room { height = l.room } @@ -222,16 +199,33 @@ func (l *OpenList) MinSize() fyne.Size { return fyne.NewSize(l.list.MinSize().Width, height) } -// LimitTo tells the list how much room it has, so it can be shorter than its -// row ceiling when the window is shorter than that. Nought means no limit. +// LimitTo tells the list how much room it has - the share of the window it +// may cover, cut further to the room on the side it opens on. Nought means no +// limit. func (l *OpenList) LimitTo(room float32) { l.room = room } +// ListCeiling is how tall an open list may be in a window this tall: listShare +// of the height, in whole rows, and never less than one row. +// +// Whole rows rather than the share to the pixel, so that a list at its ceiling +// ends on a row's edge the way it did when the ceiling was a count - a row cut +// through the middle is what the room on one side of a box does to a list in +// a cramped window, and that is the emergency, not the everyday. +func ListCeiling(canvasHeight float32) float32 { + row := listRowHeight() + rows := float32(math.Floor(float64(canvasHeight * listShare / row))) + if rows < 1 { + rows = 1 + } + return rows * row +} + func (l *OpenList) CreateRenderer() fyne.WidgetRenderer { // The surface is drawn here rather than left to the popup, so that the // colour a guard measures for "an open list is told from the form behind // it" is the colour actually on the screen. back := canvas.NewRectangle(Theme().Color(theme.ColorNameMenuBackground, theme.VariantDark)) - back.CornerRadius = Theme().Size(theme.SizeNameInputRadius) + back.CornerRadius = RadiusField return widget.NewSimpleRenderer(container.NewStack(back, container.NewThemeOverride(l.list, rowTheme{}))) } diff --git a/internal/gui/parts/parts.go b/internal/gui/parts/parts.go index 97663851..f099b855 100644 --- a/internal/gui/parts/parts.go +++ b/internal/gui/parts/parts.go @@ -21,8 +21,9 @@ package parts import ( - "fyne.io/fyne/v2" + "image/color" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/layout" @@ -40,14 +41,27 @@ import ( func Prose(text string) fyne.CanvasObject { label := widget.NewLabel(text) label.Wrapping = fyne.TextWrapWord - return label + return inkTight(label) } -// Heading is the name of one field, above its control. +// Heading is the name of one field, beside its control. +// +// Regular weight since 2026-09-14. Bold, at the same size as the value in the +// box beside it, every name on the form was as loud as every value, so a +// screen had twice as much heavy text as it had content and the eye had +// nothing to skip. One rank is bold on a screen now - the title of a section +// - and a name is read by where it stands: in the column of names, level with +// its box. func Heading(text string) fyne.CanvasObject { - label := widget.NewLabel(text) - label.TextStyle = fyne.TextStyle{Bold: true} - return label + return words(text, TextBody, false, theme.ColorNameForeground) +} + +// Subheading names a block inside a section - the list of what a preset finds, +// the table of files inside an archive - at the size of a field's name and +// the weight of a section's. The one bold thing at body size, so it is read as +// a heading of the things under it and not as the name of a box beside it. +func Subheading(text string) fyne.CanvasObject { + return words(text, TextBody, true, theme.ColorNameForeground) } // Title is the one line that says what a screen is for. @@ -57,10 +71,79 @@ func Heading(text string) fyne.CanvasObject { // of every field were one style, so nothing led the eye and the first point of // the UX section 7 checklist - squint, and see what stands out - had no answer. func Title(text string) fyne.CanvasObject { + return words(text, TextTitle, true, theme.ColorNameForeground) +} + +// Subtitle is the one quiet sentence under a screen's title, saying what the +// screen is for. +// +// It exists because every work screen had two names until 2026-09-15: the word +// on its tab and a title that said something else - "Single batch" over a +// screen headed "Generate files". One vocabulary now: the tab's word is the +// title, and what the title used to say is this sentence, in the colour of a +// hint rather than of a value, so it reads as an explanation of the word above +// it and not as a second heading competing with it. +// +// A toolkit label rather than canvas words, because a sentence wraps and a +// name does not - the same split words and Prose make. Its colour is the +// hint's, the same step the words on the strip stand at when they are not +// chosen, so the head of a screen has one quiet colour and not two. Asked for +// through quiet below, because the toolkit draws a low importance label in the +// DISABLED colour, and this palette keeps that a step brighter than a hint on +// purpose - see ColorNameDisabled in theme.go. +func Subtitle(sentence string) fyne.CanvasObject { + label := widget.NewLabel(sentence) + label.Wrapping = fyne.TextWrapWord + label.Importance = widget.LowImportance + return quiet(label) +} + +// Caption is a short quiet line over or under a thing, at the smallest rank +// of the scale: the name of a state in the catalogue, the count of bytes +// beside a size. The rank ByteCount has drawn since 2026-08-19, given a name +// on 2026-09-15 when a second thing needed it - a rank used twice without a +// name is GUI rule 7's definition of a missing style. +func Caption(text string) fyne.CanvasObject { label := widget.NewLabel(text) - label.TextStyle = fyne.TextStyle{Bold: true} - label.SizeName = theme.SizeNameSubHeadingText - return label + label.Wrapping = fyne.TextWrapWord + label.SizeName = theme.SizeNameCaptionText + label.Importance = widget.LowImportance + return quiet(label) +} + +// quiet draws a low importance label in the hint's colour rather than the +// disabled one, and takes the room a label keeps around itself off it. +// +// One helper rather than one at each call site, because O213 was four call +// sites saying different things: a caption under a field, the count of bytes, a +// folded section's summary and this subtitle were all widget.LowImportance, +// which the toolkit draws in ColorNameDisabled - #C2C8CD, 80 L*, a step +// BRIGHTER than a hint. So a caption meant to recede sat louder than the +// placeholder in an empty box beside it. Owner's decision on 2026-09-15: one +// quiet, the hint's, and the disabled colour kept for a value that is switched +// off - which is content somebody may want to re-read and so is right to be the +// brighter of the two. +func quiet(o fyne.CanvasObject) fyne.CanvasObject { + return container.NewThemeOverride(o, quietInk{noInnerPadding{Theme()}}) +} + +// quietInk is the window's theme with a low importance label drawn in the +// hint's colour rather than the disabled one, and without the room a label +// keeps around itself. +type quietInk struct{ noInnerPadding } + +func (q quietInk) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { + if name == theme.ColorNameDisabled { + return q.noInnerPadding.Color(theme.ColorNamePlaceHolder, variant) + } + return q.noInnerPadding.Color(name, variant) +} + +// Titled is what stands above a screen's sections: its name, and under it the +// sentence saying what it is for. The two are one step apart, the step a +// field's name keeps from its box, because they are one thing read together. +func Titled(name, sentence string) fyne.CanvasObject { + return Column(GapLabel, Title(name), Subtitle(sentence)) } // Section groups fields that answer one question, under a name. @@ -82,7 +165,7 @@ func Section(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.NewStack(panelSurface(), container.NewPadded(Column(GapField, body...))) + return container.NewStack(panelSurface(), Padded(Inset, Column(GapField, body...))) } // FieldColumn stacks fields the way a section stacks them, for the boxes a screen @@ -111,10 +194,7 @@ func FieldColumn(children ...fyne.CanvasObject) *fyne.Container { // ranks and this is the second, so moving off the toolkit's widget must not // quietly move the type with it. func sectionTitle(text string) fyne.CanvasObject { - label := widget.NewLabel(text) - label.TextStyle = fyne.TextStyle{Bold: true} - label.SizeName = theme.SizeNameHeadingText - return label + return words(text, TextHeading, true, theme.ColorNameForeground) } // panelSurface is what a section and the action bar stand on. @@ -133,7 +213,7 @@ func sectionTitle(text string) fyne.CanvasObject { // a surface. The border belongs to the fields now. func panelSurface() *canvas.Rectangle { rect := canvas.NewRectangle(PaletteColour(ColorNamePanel, theme.VariantDark)) - rect.CornerRadius = Theme().Size(theme.SizeNameCardRadius) + rect.CornerRadius = RadiusPanel return rect } @@ -151,9 +231,7 @@ func panelSurface() *canvas.Rectangle { func Bullets(items []string) fyne.CanvasObject { rows := make([]fyne.CanvasObject, 0, len(items)) for _, item := range items { - marker := widget.NewLabel(bulletMarker) - marker.Importance = widget.LowImportance - marker.SizeName = theme.SizeNameCaptionText + marker := words(bulletMarker, TextCaption, false, theme.ColorNamePlaceHolder) rows = append(rows, container.NewBorder(nil, nil, marker, nil, Note(item))) } // Tight, because these items are one list rather than a run of separate @@ -176,7 +254,53 @@ const bulletMarker = "•" // Stacked, each took a full width it did not need and pushed the next one off // the screen. func Row(fields ...fyne.CanvasObject) fyne.CanvasObject { - return container.NewGridWithColumns(len(fields), fields...) + return container.New(columns{gap: GapColumns}, fields...) +} + +// columns shares a row out in equal columns with one gap from the scale +// between them. The toolkit's grid does the same with the theme's padding, +// which is the smallest step - and two fields side by side are two things, +// not one thing and its caption. +type columns struct{ gap float32 } + +func (c columns) MinSize(objects []fyne.CanvasObject) fyne.Size { + size := fyne.NewSize(0, 0) + shown := 0 + for _, o := range objects { + if !o.Visible() { + continue + } + min := o.MinSize() + size.Width = fyne.Max(size.Width, min.Width) + size.Height = fyne.Max(size.Height, min.Height) + shown++ + } + if shown > 0 { + size.Width = size.Width*float32(shown) + c.gap*float32(shown-1) + } + return size +} + +func (c columns) Layout(objects []fyne.CanvasObject, size fyne.Size) { + shown := 0 + for _, o := range objects { + if o.Visible() { + shown++ + } + } + if shown == 0 { + return + } + width := (size.Width - c.gap*float32(shown-1)) / float32(shown) + x := float32(0) + for _, o := range objects { + if !o.Visible() { + continue + } + o.Resize(fyne.NewSize(width, size.Height)) + o.Move(fyne.NewPos(x, 0)) + x += width + c.gap + } } // BesideFields puts something that is not a field into a row of them without @@ -225,7 +349,7 @@ func Divider() fyne.CanvasObject { type dividerLayout struct{} func (dividerLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { - return fyne.NewSize(1+theme.Padding()*4, 0) + return fyne.NewSize(GapSection, 0) } func (dividerLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { @@ -233,7 +357,7 @@ func (dividerLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { // layout is what owns the width either way. for _, o := range objects { o.Resize(fyne.NewSize(1, size.Height)) - o.Move(fyne.NewPos(theme.Padding()*2, 0)) + o.Move(fyne.NewPos(GapSection/2, 0)) } } @@ -268,7 +392,7 @@ func ActionBar(rail fyne.CanvasObject, content ...fyne.CanvasObject) fyne.Canvas // 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...))) + column := container.New(readableWidth{}, Indented(Column(GapLabel, content...))) standing := fyne.CanvasObject(column) if rail != nil { // Laid over the column rather than beside it. Sharing the row, the rail @@ -280,29 +404,19 @@ func ActionBar(rail fyne.CanvasObject, content ...fyne.CanvasObject) fyne.Canvas // as tall as the bar is what the first attempt drew. standing = container.NewStack(column, container.NewVBox(rail)) } - return container.NewStack(panelSurface(), container.NewPadded(standing)) + return container.NewStack(panelSurface(), Padded(InsetBar, standing)) } -// SlimHeight is how tall a progress track is drawn. -// -// The toolkit's progress bar is as tall as the words "100%" and the padding -// around them, because it writes the percentage inside itself. The line -// directly under it already ends with that same percentage - see text.Progress -// - so the number stood on the screen twice and the second copy cost 23 px of -// a bar the owner asked to make smaller. Measured from the stored tree on -// 2026-08-19: the bar was 31 px and the line under it another 31. -const SlimHeight = 8 - -// Screen stacks sections with a heading on top. +// Screen stacks sections under a head - a Title, or a Titled pair. // // Windows compose sections rather than laying themselves out in one function. // That is not tidiness: the shape gate caps a function at eighty lines of // logic and window layout is long by nature, so a window written as one // function would arrive as an argument for raising the cap. The cap is a // ratchet and only goes down, so the composition has to come first. -func Screen(heading string, sections ...fyne.CanvasObject) fyne.CanvasObject { +func Screen(head fyne.CanvasObject, sections ...fyne.CanvasObject) fyne.CanvasObject { return container.New(readableWidth{}, - Stacked(append([]fyne.CanvasObject{Indented(Title(heading))}, sections...)...)) + Stacked(append([]fyne.CanvasObject{Indented(head)}, sections...)...)) } // Indented puts something that stands outside a panel on the same left edge as @@ -318,19 +432,11 @@ func Indented(o fyne.CanvasObject) fyne.CanvasObject { } // indent is the layout behind Indented. Horizontal only: the vertical scale -// above already says how far apart these things stand. -// -// It asks the installed theme at layout time rather than holding a number, and -// that is not caution. A panel gets its inset from container.NewPadded, which -// reads the installed theme - so an indent taken from our own palette object -// agrees with it only while the two are the same. They are not always: a test -// canvas that has not installed our theme pads by 4 where we pad by 6, and the -// first version of this was 2 px out under exactly that canvas. Alignment is a -// relationship between two things, so it has to be read from the same place -// both of them read it from. +// says how far apart these things stand. It is the same token a panel keeps +// between its edge and its content, so the two line up by construction. type indent struct{} -func (indent) by() float32 { return theme.Padding() } +func (indent) by() float32 { return Inset } func (i indent) MinSize(objects []fyne.CanvasObject) fyne.Size { size := fyne.NewSize(0, 0) @@ -349,33 +455,6 @@ func (i indent) Layout(objects []fyne.CanvasObject, size fyne.Size) { } } -// ColumnWidth is as wide as this form is allowed to get, whatever the window -// does. O72, measured on 2026-08-10 and again on 2026-08-11: maximised to -// 3862 px, every box was 3848 to 3854 px of it - 99.7 per cent - so the seed -// field holding "0" was nearly four thousand pixels wide. UX6 puts it as a -// question rather than a rule: run your eye along a row to the right edge, and -// if you got lost the row is too long. -// -// 820 comes from the longest sentence the form actually holds, which ends at -// 797 px - the hint under the self describing label - so nothing rewraps and -// this change only stops the stretching. It is not a claim about the ideal -// measure: prose is easiest at 45 to 75 characters a line and 820 px is about -// 112, so the typography pass has room to tighten this. It cannot widen it. -const ColumnWidth = 820 - -// NumericWidth is as wide as a box holding a number gets. -// -// A box is a promise about what goes in it, and one that runs half the window -// while holding "0" promises something the field cannot take. Measured on -// 2026-08-12 before this existed: the seed and the count were 397 and 399 px -// wide for a single digit, because a column split in two hands each half to -// whatever is in it. -// -// 140 px holds eleven digits at the text size this window uses, which covers -// every number any of these fields accepts - the ceiling on files is seven -// digits and the largest size anybody types is eight. -const NumericWidth = 140 - // Numeric sizes a control to what it holds rather than to the column it is in. // // Only for boxes taking a number. A path, a name template and an id are all @@ -459,38 +538,6 @@ func (readableWidth) Layout(objects []fyne.CanvasObject, size fyne.Size) { objects[0].Move(fyne.NewPos((size.Width-width)/2, 0)) } -// The vertical scale. Three steps, and the ratio between them is the point -// rather than the individual numbers. -// -// Measured on 2026-08-20, before this existed: every gap in the form came out -// of the theme's one padding value, so the distance from a label to its own -// control and the distance from the end of one field to the start of the next -// were 20 px and 23 px. The form said "these belong together" and "this group -// has ended" with the same space, which is the whole of what spacing is for. -// A picture of it reads as a wall of text, and that was the first finding of -// the design audit. -// -// The steps have to be far enough apart to be read without counting. The pairs -// above were fifteen per cent apart, which the eye does not resolve. -const ( - // GapTight is the space inside one field, between its name, its control - // and the sentence under it. Those are one thing, so they sit close. - GapTight = 1 - // GapField is the space between two fields in a section. - GapField = 9 - // GapSection is the space between two panels. - // - // Reported from use on 2026-08-18, looking at the recipe screen: with a - // panel per batch stacked one under another, the gap the toolkit left was - // small enough that two panels read as one long one with a line across it. - // The edge of a panel is what says where a batch begins, and it was doing - // that job at the same strength as the gap between two fields inside it. - // - // Applied wherever panels are stacked rather than inside Section, so a - // panel used on its own carries no stray space under it. - GapSection = 14 -) - // Column stacks its children with one fixed gap, whatever the theme's padding // is. // diff --git a/internal/gui/parts/property.go b/internal/gui/parts/property.go index 43c12be3..6f4ce3a1 100644 --- a/internal/gui/parts/property.go +++ b/internal/gui/parts/property.go @@ -138,7 +138,7 @@ func boolField(p format.Property) PropertyField { // place switches come from and the one nobody would remember, because there // is no bool property in the registry today - the first format to declare // one would have arrived with the defect already fixed everywhere else. - check := NewToggle("", nil) + check := NewToggle(nil) check.SetChecked(p.Default == "true") return PropertyField{ Name: p.Name, @@ -213,17 +213,14 @@ func PropertyFields(d format.Descriptor, into *Fields, tips *Tips) ([]PropertyFi // beside a size had to be written twice on the day it was added, which is what // made this obvious. // -// The visible change today is none, and that is worth saying rather than -// hiding: the one preset this build registers declares a single narrow -// parameter, so there is no pair to make. What changes is that the fourth thing -// added to a declared field arrives on all three screens instead of two. +// One setting a row since 2026-09-14. Two narrow settings used to share a row, +// and that was a way of using the width a name standing OVER its box left +// empty beside it - with the name beside the box there is no such width, a +// row is as tall as one box, and a form reads down one column of names. func DeclaredFields(declared []format.Property, into *Fields, tips *Tips) ([]PropertyField, []fyne.CanvasObject) { fields := make([]PropertyField, 0, len(declared)) objects := make([]fyne.CanvasObject, 0, len(declared)) - pair := PairNarrow(into.Row) - flush := func() { objects = append(objects, pair.rest()...) } - for _, p := range declared { f := FromProperty(p) fields = append(fields, f) @@ -242,16 +239,9 @@ func DeclaredFields(declared []format.Property, into *Fields, tips *Tips) ([]Pro // The button is what makes that safe rather than a loss: this is a tool // whose window and whose recipe file are two ways into one engine, so // somebody who finds a setting here has to be able to write it down. - object := into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(p), - tips.Say(text.SettingKey(p.Name)), ShapedFor(p, f.Control)) - if narrowOnAScreen(p) { - pair.add(object) - continue - } - flush() - objects = append(objects, object) + objects = append(objects, into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(p), + tips.Say(text.SettingKey(p.Name)), ShapedFor(p, f.Control))) } - flush() return fields, objects } @@ -302,50 +292,6 @@ func narrowOnAScreen(p format.Property) bool { } } -// PairNarrow lays settings two to a row where both of them are narrow. -// -// Two boxes for a number stacked one above the other cost a row of height each -// and leave two thirds of the panel empty beside them. Which ones are narrow is -// the declared kind, so nothing here names a format - and both screens that -// draw a format's settings go through this, which is the point. The width went -// into one of them first and the other kept drawing full width boxes for a -// commit, which is the shape D1 comes apart in. -func PairNarrow(row func(...fyne.CanvasObject) fyne.CanvasObject) *Pairs { - return &Pairs{row: row} -} - -// Pairs collects narrow fields until something wide arrives or the list ends. -type Pairs struct { - row func(...fyne.CanvasObject) fyne.CanvasObject - pending []fyne.CanvasObject -} - -func (p *Pairs) add(object fyne.CanvasObject) { p.pending = append(p.pending, object) } - -// Add takes one narrow field, for a caller outside this package. -func (p *Pairs) Add(object fyne.CanvasObject) { p.add(object) } - -func (p *Pairs) rest() []fyne.CanvasObject { - var out []fyne.CanvasObject - for len(p.pending) > 0 { - if len(p.pending) == 1 { - out = append(out, p.pending[0]) - p.pending = nil - break - } - out = append(out, p.row(p.pending[0], p.pending[1])) - p.pending = p.pending[2:] - } - return out -} - -// Rest is everything collected so far, in rows, for a caller outside this -// package. -func (p *Pairs) Rest() []fyne.CanvasObject { return p.rest() } - -// Narrow says whether a declared setting is one this would pair. -func Narrow(p format.Property) bool { return narrowOnAScreen(p) } - // PropertyDetail is what a property takes and what it is for, in that order. // What it takes comes first because that is what somebody looking at an empty // box needs. diff --git a/internal/gui/parts/required.go b/internal/gui/parts/required.go index 80f65260..055c8767 100644 --- a/internal/gui/parts/required.go +++ b/internal/gui/parts/required.go @@ -2,6 +2,7 @@ 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" @@ -22,41 +23,47 @@ import ( // cannot tell the red from the grey around it still sees a star that the fields // beside it do not have. The colour is the palette's error red, the same one a // refusal about the field will use, so the mark and the message that follows it -// are the same colour rather than two reds. +// are the same colour rather than two reds. A grey star was tried on 2026-09-14 +// and went back the same day: beside a regular-weight name and the grey button +// that opens the explanation it was one more grey glyph, and a mark that does +// not stand out from what it marks is not a mark. // // A type of its own for the reason DetailButton is one: the heading of a field // is a row, guards and probes read that row to find the control under it, and // recognising a thing by its position in a list is what breaks the third time -// somebody adds a fourth thing to the row. +// somebody adds a fourth thing to the row. Drawn with no room of its own +// around the glyph, like every other word on the form - see words. type RequiredMark struct { - widget.Label + widget.BaseWidget + glyph *canvas.Text } func newRequiredMark() *RequiredMark { - m := &RequiredMark{} + m := &RequiredMark{glyph: words(text.RequiredMark, TextBody, true, theme.ColorNameError)} 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 return m } +// CreateRenderer draws the glyph and nothing else. +func (m *RequiredMark) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(m.glyph) +} + +// MinSize is the glyph's, so the mark takes the room of a star and not of a +// label round one. +func (m *RequiredMark) MinSize() fyne.Size { return m.glyph.MinSize() } + // headingRow is a field's name, the mark saying it must be filled in, and the // button holding its longer explanation - in that order, on one line. // // Flat rather than nested, and that is load bearing. Every walk in this project -// finds a field by looking for a label with its control after it, so the name +// finds a field by looking for a name with its control after it, so the name // has to stay the FIRST thing in this row - wrapping the name and the star in a -// box of their own would hide the label one level down and every one of those +// box of their own would hide the name one level down and every one of those // walks would stop finding fields. Measured cost of getting that wrong: the // probe reported "there is no field labelled width" the first time this was // tried the other way round. -func headingRow(label string, detail Detail, required bool, trailing fyne.CanvasObject) fyne.CanvasObject { +func headingRow(label string, detail Detail, required bool) fyne.CanvasObject { head := Heading(label) row := []fyne.CanvasObject{head} if required { @@ -65,15 +72,6 @@ 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. - if trailing != nil { - row = append(row, trailing) - } - // A row of one is the label itself. Wrapping it would put every field's - // name a level deeper for no reason and change the shape of every stored - // screen that has no explanation and no star. if len(row) == 1 { return head } @@ -95,6 +93,9 @@ func headingRow(label string, detail Detail, required bool, trailing fyne.Canvas // belonged to neither. A mark that qualifies a name has to be nearer the name // than the next control, which is the same rule the form already follows // between a label, its box and the line explaining it. +// +// Everything on the line is centred on its height, so a star and a button of +// two different heights both sit level with the middle of the name. type headingLine struct{} // gapBefore is the room left in front of one thing on the heading line. @@ -107,15 +108,14 @@ type headingLine struct{} // trees said so on the first regeneration: a label 32 px tall became 31 and a // button at x=69 moved to 63 on fields this change was not supposed to touch. // -// So: nothing in front of the star, and the toolkit's own padding in front of -// everything else. A field with no star lays out exactly as it did before, and -// what a reader sees between the name and the star is the padding a label -// carries inside itself. +// So: the smallest step in front of the star, which qualifies the name, and +// the next one in front of everything else. Both on the scale, and the star +// nearer the name than the button is to the star. func gapBefore(o fyne.CanvasObject) float32 { if _, star := o.(*RequiredMark); star { - return 0 + return GapInline } - return theme.Padding() + return GapLabel } func (h headingLine) MinSize(objects []fyne.CanvasObject) fyne.Size { @@ -143,28 +143,13 @@ func (h headingLine) Layout(objects []fyne.CanvasObject, size fyne.Size) { if !o.Visible() { continue } - width := o.MinSize().Width - // The full height of the line rather than each thing's own, which is - // what a horizontal box does - and matching it is what keeps a field - // 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 - } - + min := o.MinSize() if !first { x += gapBefore(o) } first = false - o.Move(fyne.NewPos(x, 0)) - x += width + o.Resize(min) + o.Move(fyne.NewPos(x, (size.Height-min.Height)/2)) + x += min.Width } } diff --git a/internal/gui/parts/reveal.go b/internal/gui/parts/reveal.go index 582e2a84..984daaf2 100644 --- a/internal/gui/parts/reveal.go +++ b/internal/gui/parts/reveal.go @@ -5,11 +5,6 @@ import ( "fyne.io/fyne/v2/container" ) -// revealMargin is how much of the form is kept above a control brought into -// view, so it arrives looking like part of a form rather than pinned to the top -// edge with its own label cut off above it. -const revealMargin = 24 - // Reveal scrolls a control into view and puts the keyboard in it. // // Pressing Generate with a bad value in a box did nothing anybody could see. diff --git a/internal/gui/parts/ring.go b/internal/gui/parts/ring.go index 77b49da6..4d1248ff 100644 --- a/internal/gui/parts/ring.go +++ b/internal/gui/parts/ring.go @@ -62,15 +62,6 @@ func (r *Ring) Resting(edge color.Color) { r.draw() } -// ringWidth is how thick the line is. -// -// Twice the toolkit's input border, so it reads as a deliberate edge rather than -// as the box's own outline changing colour. One pixel lands between pixels and -// is anti-aliased away to something fainter than the number suggests - measured -// on the section surface on 2026-08-12, where a one pixel stroke of a 29.4 L* -// colour came out at 22.3. -const ringWidth = 2 - // WithRing puts a control on the screen with an edge it can draw when there is // something to say about it. // @@ -82,8 +73,18 @@ const ringWidth = 2 // same property the explanation sheet relies on, and the reason both are // content rather than overlays. func WithRing(control fyne.CanvasObject) (fyne.CanvasObject, *Ring) { + // A control that draws its own edge and its own ring is handed back + // untouched, with no ring for the field to mark. A switch is the one such + // control a field is built round today: its square carries its own border + // and its own focus ring, and a second one round the whole cell would be a + // ring round the name as well as the square - the box-round-a-sentence O72 + // warned about. It also cannot be refused, so the nil ring is a fact rather + // than a gap: the engine has no verdict about a switch to place. + if _, own := control.(selfEdged); own { + return control, nil + } rect := canvas.NewRectangle(color.Transparent) - rect.CornerRadius = Theme().Size(theme.SizeNameInputRadius) + rect.CornerRadius = RadiusField ring := &Ring{rect: rect} ring.draw() @@ -118,6 +119,11 @@ func wireRing(control fyne.CanvasObject, ring *Ring) { // ringed is a control that reports the keyboard arriving and leaving. type ringed interface{ useRing(*Ring) } +// selfEdged is a control that draws its own border and its own focus ring, so +// WithRing leaves it alone rather than drawing a second edge round the cell it +// stands in. +type selfEdged interface{ drawsOwnEdge() } + // Refuse turns the edge red, or takes the red away. Called with what the run // said about this setting rather than with a judgement made here - G1. func (r *Ring) Refuse(refused bool) { @@ -148,7 +154,7 @@ func (r *Ring) draw() { // Thinner than the two states above, so a control at rest cannot be // mistaken for one the run refused. r.rect.StrokeColor = r.resting - r.rect.StrokeWidth = 1 + r.rect.StrokeWidth = edgeWidth default: // No line at all rather than one in the background colour. A stroke // that is meant to be invisible is a thing that shows up the day the @@ -442,10 +448,6 @@ func (c *Chooser) drop(surface fyne.Canvas) { list.StartOn(c.Selected) } -// listEdgeGap is the space kept between an open list and the edge of the -// window, so that a list filling the room still reads as sitting inside it. -const listEdgeGap = 8 - // roomForList decides how tall an open list may be and where its top goes. // // It used to go under the box at its full height, always, which is right until @@ -454,16 +456,22 @@ const listEdgeGap = 8 // format menu showed four of its twenty values that way, with the rest past the // edge and the run buttons underneath it (O113). // -// Two things fix it and both are needed. It opens UPWARD when there is more -// room above the box than below it, which is what every desktop menu does. And -// it is cut to the room on whichever side it lands, rather than to a fixed -// number of rows - the eight row ceiling is about not covering the form, and it -// says nothing about a window that has less than eight rows left. +// Three things decide it and each is needed. The list may cover a share of +// the window and no more (ListCeiling) - the ceiling is about not taking the +// form away from the person reading it, and it follows the window rather than +// being a count of rows, which was eight in every window until 2026-09-15 +// (O203). It opens UPWARD when there is more room above the box than below +// it, which is what every desktop menu does. And it is cut to the room on +// whichever side it lands, because the ceiling says nothing about a window +// that has less than that left beside the box. // // Arithmetic rather than widgets so that it can be checked directly. The screen // level guard opens a real menu and measures the overlay, which is the half // that catches this being wired up wrongly. func roomForList(canvasHeight, boxTop, boxHeight, wanted float32) (height, top float32) { + if ceiling := ListCeiling(canvasHeight); wanted > ceiling { + wanted = ceiling + } below := canvasHeight - (boxTop + boxHeight) - listEdgeGap above := boxTop - listEdgeGap if below < 0 { @@ -521,6 +529,13 @@ func (c *Chooser) Quietly(focus func()) { c.from.Quietly(focus) } // UX9 asks that whatever the mouse can do the keyboard can - which has to mean // the same thing, not a second version of it. func (c *Chooser) TypedKey(event *fyne.KeyEvent) { + // A frozen menu answers no key. The toolkit's Select.TypedKey moves the + // value on Left and Right without asking, and a menu frozen for a run + // keeps the keyboard if it had it - see Segments.TypedKey for the same + // finding on the same day. + if event == nil || c.Disabled() { + return + } // The keyboard has been used, so from here on it is worth saying where it // is. Somebody who opened this list with the mouse and then reached for the // arrows is somebody who now needs to see which control is listening. diff --git a/internal/gui/parts/segments.go b/internal/gui/parts/segments.go new file mode 100644 index 00000000..34d285e6 --- /dev/null +++ b/internal/gui/parts/segments.go @@ -0,0 +1,339 @@ +package parts + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// Segments is a row of words joined into one control, of which exactly one is +// chosen - the three ways of saying how big, in place of three radio circles. +// +// # Why it is one control rather than the toolkit's radio group +// +// widget.RadioGroup is three separate focusable circles, so Tab stopped at each +// of them and the guard for reachability had to reach into its renderer to +// find them at all (reachability_test.go, before 2026-09-15). A segmented +// switch is one Tab stop whose arrows move the choice, which is what a row of +// mutually exclusive options is on every desktop - the toolkit draws its radio +// as a stack of discs it fills with the focus colour, the same mark this window +// took off its menus and switches (see PointerFocus). It carries the same four +// names a caller used on the radio - Options, Selected, SetSelected, OnChanged +// - so the switch behind the three ways of stating a size changed type and not +// the code around it. +// +// It draws its own border and its own ring, so a field built round it gets +// neither from WithRing (see selfEdged): the three ways cannot be refused, only +// the box the chosen way shows can. +type Segments struct { + widget.DisableableWidget + + Options []string + Selected string + OnChanged func(string) + + hovered int // the segment under the pointer, or -1 + marked bool + // from knows whether the keyboard arrived by press or by key - see + // PointerFocus and Toggle, which follow the same rule. + from PointerFocus +} + +var ( + _ fyne.Tappable = (*Segments)(nil) + _ fyne.Focusable = (*Segments)(nil) + _ desktop.Hoverable = (*Segments)(nil) +) + +// NewSegments builds a switch offering these words, open on the first. +func NewSegments(options []string, changed func(string)) *Segments { + s := &Segments{Options: options, OnChanged: changed, hovered: -1} + if len(options) > 0 { + s.Selected = options[0] + } + s.ExtendBaseWidget(s) + return s +} + +func (s *Segments) drawsOwnEdge() {} + +// Marked and Hovered report the drawn state, for a guard. +func (s *Segments) Marked() bool { return s.marked } +func (s *Segments) HoveredAt() int { return s.hovered } + +// SetSelected chooses a word by name, firing OnChanged only when it moves and +// only for a word the switch actually offers - a name it does not hold changes +// nothing, the way a switch of fixed choices should. +func (s *Segments) SetSelected(value string) { + if value == s.Selected || s.indexOf(value) < 0 { + return + } + s.Selected = value + s.Refresh() + if s.OnChanged != nil { + s.OnChanged(value) + } +} + +func (s *Segments) indexOf(value string) int { + for i, o := range s.Options { + if o == value { + return i + } + } + return -1 +} + +// Tapped chooses the segment under the pointer and puts the keyboard on the +// switch, quietly - the way the toolkit's radio item does (widget/radio_item.go +// Tapped), so the arrows step on from what was clicked. See Toggle.Tapped for +// the whole of why, and for what stood here until 2026-09-16. +func (s *Segments) Tapped(event *fyne.PointEvent) { + if s.Disabled() || event == nil { + return + } + s.takeTheKeyboardQuietly() + if at := s.segmentAt(event.Position.X); at >= 0 { + s.SetSelected(s.Options[at]) + } +} + +// takeTheKeyboardQuietly moves the focus here without the mark, unless it is +// here already. +func (s *Segments) takeTheKeyboardQuietly() { + app := fyne.CurrentApp() + if app == nil { + return + } + canvas := app.Driver().CanvasForObject(s) + if canvas == nil || canvas.Focused() == s { + return + } + s.from.Quietly(func() { canvas.Focus(s) }) +} + +// Quietly runs a focus change without drawing the mark. See PointerFocus and +// FocusQuietly. +func (s *Segments) Quietly(focus func()) { s.from.Quietly(focus) } + +// segmentAt is the segment a point falls in, or -1 past the last one. +func (s *Segments) segmentAt(x float32) int { + edge := float32(0) + for i, o := range s.Options { + edge += s.segmentWidth(o) + if x < edge { + return i + } + edge += Hairline + } + return -1 +} + +func (s *Segments) segmentWidth(word string) float32 { + return fyne.MeasureText(word, TextBody, fyne.TextStyle{}).Width + ControlInset*2 +} + +func (s *Segments) MouseIn(e *desktop.MouseEvent) { s.MouseMoved(e) } +func (s *Segments) MouseMoved(e *desktop.MouseEvent) { + at := -1 + if e != nil { + at = s.segmentAt(e.Position.X) + } + if at != s.hovered { + s.hovered = at + s.Refresh() + } +} +func (s *Segments) MouseOut() { + if s.hovered != -1 { + s.hovered = -1 + s.Refresh() + } +} + +// FocusGained draws the ring when the keyboard is what brought the focus here. +// A press brings it quietly and the first key turns the ring on. +func (s *Segments) FocusGained() { + if s.from.Quiet() { + return + } + s.mark() +} + +func (s *Segments) mark() { + s.marked = true + s.Refresh() +} +func (s *Segments) FocusLost() { + s.marked = false + s.Refresh() +} + +func (s *Segments) TypedRune(rune) {} + +// TypedKey moves the choice. Left and right step, Home and End jump - and each +// MOVES the choice rather than only the focus, because a row of exclusive +// options is chosen by arrowing through it, the manual-activation exception a +// tab strip makes (see TabWord) not applying where the choice does not move the +// keyboard off the control. +func (s *Segments) TypedKey(event *fyne.KeyEvent) { + // Disabled asked here and not left to the renderer: a switch frozen for + // the length of a run keeps the keyboard if it had it, because the focus + // manager checks Disabled only when it MOVES the focus (internal/app/ + // focus_manager.go), and the driver hands every key to whatever is + // focused. Without this the arrows moved the choice under a form drawn as + // frozen - an outside review of the pull request named it, 2026-09-16. + if event == nil || s.Disabled() || len(s.Options) == 0 { + return + } + if !s.marked { + s.mark() + } + at := s.indexOf(s.Selected) + switch event.Name { + case fyne.KeyLeft: + if at > 0 { + s.SetSelected(s.Options[at-1]) + } + case fyne.KeyRight: + if at >= 0 && at < len(s.Options)-1 { + s.SetSelected(s.Options[at+1]) + } + case fyne.KeyHome: + s.SetSelected(s.Options[0]) + case fyne.KeyEnd: + s.SetSelected(s.Options[len(s.Options)-1]) + } +} + +// CreateRenderer draws the joined words, the chosen one filled. +func (s *Segments) CreateRenderer() fyne.WidgetRenderer { + s.ExtendBaseWidget(s) + border := canvas.NewRectangle(color.Transparent) + border.CornerRadius = RadiusField + border.StrokeColor = PaletteColour(theme.ColorNameInputBorder, theme.VariantDark) + border.StrokeWidth = edgeWidth + ring := canvas.NewRectangle(color.Transparent) + ring.CornerRadius = RadiusField + ring.StrokeColor = PaletteColour(theme.ColorNamePrimary, theme.VariantDark) + r := &segmentsRenderer{seg: s, border: border, ring: ring} + r.build() + r.Refresh() + return r +} + +type segmentsRenderer struct { + seg *Segments + border *canvas.Rectangle + ring *canvas.Rectangle + fills []*canvas.Rectangle + words []*canvas.Text + rules []*canvas.Rectangle +} + +// build makes one fill and one word per option, and a rule between each pair. +// The count is fixed once, because a switch does not gain options after it is +// built. +func (r *segmentsRenderer) build() { + for range r.seg.Options { + fill := canvas.NewRectangle(color.Transparent) + r.fills = append(r.fills, fill) + r.words = append(r.words, canvas.NewText("", color.Transparent)) + } + for i := 1; i < len(r.seg.Options); i++ { + r.rules = append(r.rules, canvas.NewRectangle(PaletteColour(theme.ColorNameInputBorder, theme.VariantDark))) + } +} + +func (r *segmentsRenderer) Layout(fyne.Size) { + h := r.MinSize().Height + x := float32(0) + for i, word := range r.seg.Options { + w := r.seg.segmentWidth(word) + r.fills[i].Resize(fyne.NewSize(w, h)) + r.fills[i].Move(fyne.NewPos(x, 0)) + ink := r.words[i].MinSize() + r.words[i].Resize(ink) + r.words[i].Move(fyne.NewPos(x+(w-ink.Width)/2, (h-ink.Height)/2)) + if i > 0 { + r.rules[i-1].Resize(fyne.NewSize(Hairline, h)) + r.rules[i-1].Move(fyne.NewPos(x-Hairline, 0)) + } + x += w + Hairline + } + total := fyne.NewSize(fyne.Max(0, x-Hairline), h) + r.border.Resize(total) + r.ring.Resize(total.Add(fyne.NewSquareSize(ringGap * 2))) + r.ring.Move(fyne.NewPos(-ringGap, -ringGap)) +} + +func (r *segmentsRenderer) MinSize() fyne.Size { + width := float32(0) + for i, word := range r.seg.Options { + width += r.seg.segmentWidth(word) + if i > 0 { + width += Hairline + } + } + // The height of a box to type in, so the switch stands the same height as + // the fields around it. + return fyne.NewSize(width, TextBody+ControlInset*2+edgeWidth*2) +} + +func (r *segmentsRenderer) Refresh() { + dark := theme.VariantDark + off := r.seg.Disabled() + chosen := r.seg.indexOf(r.seg.Selected) + for i, word := range r.seg.Options { + r.words[i].Text = word + r.words[i].TextSize = TextBody + switch { + case off: + r.fills[i].FillColor = color.Transparent + r.words[i].Color = PaletteColour(theme.ColorNameDisabled, dark) + case i == chosen: + r.fills[i].FillColor = PaletteColour(theme.ColorNameSelection, dark) + r.words[i].Color = PaletteColour(theme.ColorNameForeground, dark) + case i == r.seg.hovered: + r.fills[i].FillColor = PaletteColour(theme.ColorNameHover, dark) + r.words[i].Color = PaletteColour(theme.ColorNameForeground, dark) + default: + r.fills[i].FillColor = color.Transparent + r.words[i].Color = PaletteColour(theme.ColorNamePlaceHolder, dark) + } + r.fills[i].Refresh() + r.words[i].Refresh() + } + if r.seg.marked { + r.ring.StrokeWidth = ringWidth + } else { + r.ring.StrokeWidth = 0 + } + r.border.Refresh() + r.ring.Refresh() + canvas.Refresh(r.seg) + r.Layout(r.seg.Size()) +} + +// Objects draws the fills first, then the rules and border over their edges, +// then the words on top, then the ring outside all of it. +func (r *segmentsRenderer) Objects() []fyne.CanvasObject { + out := make([]fyne.CanvasObject, 0, len(r.fills)*2+len(r.rules)+2) + for _, f := range r.fills { + out = append(out, f) + } + for _, rule := range r.rules { + out = append(out, rule) + } + out = append(out, r.border) + for _, w := range r.words { + out = append(out, w) + } + return append(out, r.ring) +} + +func (r *segmentsRenderer) Destroy() {} diff --git a/internal/gui/parts/tabs.go b/internal/gui/parts/tabs.go new file mode 100644 index 00000000..5cce8d11 --- /dev/null +++ b/internal/gui/parts/tabs.go @@ -0,0 +1,438 @@ +package parts + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// Tab is one screen of the window and the word on the strip that leads to it. +type Tab struct { + Text string + Content fyne.CanvasObject +} + +// Tabs is the strip of words across the top of the window: one word a screen, +// the chosen one underlined. +// +// Our own rather than the toolkit's container.AppTabs since 2026-09-15, and +// the reasons are three measurements rather than a preference. The toolkit's +// strip stands on the edge of the window while everything a person reads +// stands on the column - 10 px against 157 at the width the window opens at - +// and its renderer reserves the bar's height unconditionally (tabs.go, +// layout), so a strip drawn anywhere else would have paid for the toolkit's +// as well. Its buttons answer the pointer and not the keyboard (tabs.go: +// tabButton is Tappable and Hoverable and nothing else), so four words no key +// could reach stood across the top of a window whose rule is that whatever the +// mouse can do the keyboard can (UX9). And the one thing a strip has to say - +// which word is chosen - it said in a colour handed in through a theme +// override, because the words were drawn inside a renderer nothing here could +// reach. +// +// The strip is a widget because it has behaviour: it is the one thing that +// knows which screen is on show. What it stands ABOVE is not its business - +// see Tabbed, which puts it and the screens in a plain container, so that every +// walk that already knows a container reaches every screen, shown or not. +type Tabs struct { + widget.BaseWidget + + items []*Tab + words []*TabWord + current int + + // OnSelected is told which tab was chosen and whether the keyboard chose + // it. The second answer decides where the keyboard goes next: after a + // press it is placed quietly, after a key it is handed on where it can be + // seen - see PointerFocus for why those are two different things. + OnSelected func(tab *Tab, byKeyboard bool) +} + +// NewTabs builds a strip leading to these screens, open on the first. +func NewTabs(tabs ...*Tab) *Tabs { + t := &Tabs{items: tabs} + t.ExtendBaseWidget(t) + for i, tab := range tabs { + t.words = append(t.words, newTabWord(t, i, tab.Text)) + // Shown and hidden by the strip rather than by whatever lays the + // screens out, so that there is one answer to which screen is on show + // and no second copy of it to fall out of step. + if i == 0 { + tab.Content.Show() + } else { + tab.Content.Hide() + } + } + return t +} + +// Items is every screen the strip leads to, in the order of the words. +func (t *Tabs) Items() []*Tab { return t.items } + +// Selected is the screen on show, or nil for a strip with no words. +func (t *Tabs) Selected() *Tab { + if t.current < 0 || t.current >= len(t.items) { + return nil + } + return t.items[t.current] +} + +// Select moves to a screen the way a press does. A tab this strip does not +// hold, or the one already on show, changes nothing and says nothing. +func (t *Tabs) Select(tab *Tab) { + for i, item := range t.items { + if item == tab { + t.choose(i, false) + return + } + } +} + +// Words are the things on the strip a person presses, hovers or puts the +// keyboard on, in the order of the screens. For a guard that wants to do one +// of those. +func (t *Tabs) Words() []*TabWord { return t.words } + +func (t *Tabs) choose(i int, byKeyboard bool) { + if i == t.current || i < 0 || i >= len(t.items) { + return + } + t.items[t.current].Content.Hide() + t.current = i + t.items[i].Content.Show() + t.Refresh() + if t.OnSelected != nil { + t.OnSelected(t.items[i], byKeyboard) + } +} + +// focusWord puts the keyboard on the word at i, for the arrow keys. Past either +// end an arrow does nothing rather than wrapping round - a strip of four words +// is short enough to see the whole of, so there is nothing to wrap to. +func (t *Tabs) focusWord(i int) { + if i < 0 || i >= len(t.words) { + return + } + if c := fyne.CurrentApp().Driver().CanvasForObject(t); c != nil { + c.Focus(t.words[i]) + } +} + +// CreateRenderer draws the words in a row and the mark under the chosen one. +func (t *Tabs) CreateRenderer() fyne.WidgetRenderer { + indicator := canvas.NewRectangle(PaletteColour(theme.ColorNamePrimary, theme.VariantDark)) + r := &tabsRenderer{strip: t, indicator: indicator} + r.Refresh() + return r +} + +type tabsRenderer struct { + strip *Tabs + indicator *canvas.Rectangle +} + +// Layout puts the words in a row and the mark below the row, so that the mark +// never covers the fill or the ring a word draws for itself. +func (r *tabsRenderer) Layout(size fyne.Size) { + x := float32(0) + row := fyne.Max(0, size.Height-TabIndicator) + for _, word := range r.strip.words { + min := word.MinSize() + word.Move(fyne.NewPos(x, 0)) + word.Resize(fyne.NewSize(min.Width, row)) + x += min.Width + GapTabs + } + r.placeIndicator(size) +} + +func (r *tabsRenderer) placeIndicator(size fyne.Size) { + if r.strip.Selected() == nil { + r.indicator.Hide() + return + } + word := r.strip.words[r.strip.current] + r.indicator.Show() + r.indicator.Move(fyne.NewPos(word.Position().X, size.Height-TabIndicator)) + r.indicator.Resize(fyne.NewSize(word.Size().Width, TabIndicator)) +} + +func (r *tabsRenderer) MinSize() fyne.Size { + size := fyne.NewSize(0, TabIndicator) + for i, word := range r.strip.words { + min := word.MinSize() + if i > 0 { + size.Width += GapTabs + } + size.Width += min.Width + size.Height = fyne.Max(size.Height, min.Height+TabIndicator) + } + return size +} + +func (r *tabsRenderer) Refresh() { + for i, word := range r.strip.words { + word.chosen = i == r.strip.current + word.Refresh() + } + r.placeIndicator(r.strip.Size()) + r.indicator.Refresh() +} + +func (r *tabsRenderer) Objects() []fyne.CanvasObject { + objects := make([]fyne.CanvasObject, 0, len(r.strip.words)+1) + for _, word := range r.strip.words { + objects = append(objects, word) + } + return append(objects, r.indicator) +} + +func (r *tabsRenderer) Destroy() {} + +// TabWord is one word on the strip. +// +// Its own widget for the reasons a ListRow is: it answers the pointer itself, +// it draws its own surface for the states it has, and it can hold the keyboard +// - which the toolkit's tab button cannot. Four states, drawn in this order of +// precedence: chosen, holding the keyboard, under the pointer, at rest. A tab +// has no disabled state and no refused one in this window, so neither is drawn +// - a state nothing can reach is a state that lies the day something does. +type TabWord struct { + widget.BaseWidget + + strip *Tabs + index int + text string + + chosen bool + hovered bool + marked bool + from PointerFocus +} + +func newTabWord(strip *Tabs, index int, text string) *TabWord { + w := &TabWord{strip: strip, index: index, text: text} + w.ExtendBaseWidget(w) + return w +} + +// Text is the word, for a guard. +func (w *TabWord) Text() string { return w.text } + +// Chosen says whether this word's screen is the one on show. +func (w *TabWord) Chosen() bool { return w.chosen } + +// Hovered says whether the pointer is over this word. +func (w *TabWord) Hovered() bool { return w.hovered } + +// Marked says whether the keyboard mark is drawn - see PointerFocus. +func (w *TabWord) Marked() bool { return w.marked } + +// Tapped chooses this word's screen. The keyboard comes to the word quietly +// first, so that the mark meaning "the keyboard is here" is not drawn for +// somebody using the mouse - the same decision every control in this package +// takes, see PointerFocus. +func (w *TabWord) Tapped(*fyne.PointEvent) { + if c := fyne.CurrentApp().Driver().CanvasForObject(w); c != nil { + w.from.Quietly(func() { c.Focus(w) }) + } + w.strip.choose(w.index, false) +} + +func (w *TabWord) MouseIn(*desktop.MouseEvent) { + w.hovered = true + w.Refresh() +} + +func (w *TabWord) MouseMoved(*desktop.MouseEvent) {} + +func (w *TabWord) MouseOut() { + w.hovered = false + w.Refresh() +} + +// FocusGained draws the mark only for the keyboard. See PointerFocus. +func (w *TabWord) FocusGained() { + if w.from.Quiet() { + return + } + w.mark() +} + +func (w *TabWord) mark() { + w.marked = true + w.Refresh() +} + +func (w *TabWord) FocusLost() { + w.marked = false + w.Refresh() +} + +// Quietly runs a focus change without drawing the mark. See FocusQuietly. +func (w *TabWord) Quietly(focus func()) { w.from.Quietly(focus) } + +func (w *TabWord) TypedRune(rune) {} + +// TypedKey is the keyboard's half of the strip. Enter and Space choose the +// word's screen. The arrows and Home and End move the keyboard along the strip +// WITHOUT choosing - choosing hands the keyboard on to the screen's first field, +// so an arrow that chose would leave the strip on every press, and somebody +// looking for the third word would be on the second screen's form instead. +// That is the manual activation pattern the web uses for a row of tabs, and it +// is the one that fits a strip whose choice moves the keyboard. +func (w *TabWord) TypedKey(event *fyne.KeyEvent) { + if !w.marked { + w.mark() + } + switch event.Name { + case fyne.KeyReturn, fyne.KeyEnter, fyne.KeySpace: + w.strip.choose(w.index, true) + case fyne.KeyLeft: + w.strip.focusWord(w.index - 1) + case fyne.KeyRight: + w.strip.focusWord(w.index + 1) + case fyne.KeyHome: + w.strip.focusWord(0) + case fyne.KeyEnd: + w.strip.focusWord(len(w.strip.words) - 1) + } +} + +func (w *TabWord) CreateRenderer() fyne.WidgetRenderer { + back := canvas.NewRectangle(color.Transparent) + back.CornerRadius = RadiusField + ring := canvas.NewRectangle(color.Transparent) + ring.CornerRadius = RadiusField + ring.StrokeColor = PaletteColour(theme.ColorNamePrimary, theme.VariantDark) + r := &tabWordRenderer{word: w, back: back, ring: ring, text: words(w.text, TextBody, true, theme.ColorNamePlaceHolder)} + r.Refresh() + return r +} + +type tabWordRenderer struct { + word *TabWord + back *canvas.Rectangle + ring *canvas.Rectangle + text *canvas.Text +} + +func (r *tabWordRenderer) Layout(size fyne.Size) { + r.back.Resize(size) + r.ring.Resize(size) + ink := r.text.MinSize() + r.text.Move(fyne.NewPos(TabInset, (size.Height-ink.Height)/2)) + r.text.Resize(ink) +} + +func (r *tabWordRenderer) MinSize() fyne.Size { + return r.text.MinSize().Add(fyne.NewSquareSize(TabInset * 2)) +} + +// Refresh draws the state. The word is at full strength when it is chosen and +// when the pointer is over it, and quiet otherwise, so the strip has one word +// that answers "where am I" and one that answers "where would this take me". +// The keyboard mark is a ring and not a fill, for the reason Ring gives. +func (r *tabWordRenderer) Refresh() { + r.text.Text = r.word.text + if r.word.chosen || r.word.hovered { + r.text.Color = PaletteColour(theme.ColorNameForeground, theme.VariantDark) + } else { + r.text.Color = PaletteColour(theme.ColorNamePlaceHolder, theme.VariantDark) + } + if r.word.hovered { + r.back.FillColor = PaletteColour(theme.ColorNameHover, theme.VariantDark) + } else { + r.back.FillColor = color.Transparent + } + if r.word.marked { + r.ring.StrokeWidth = ringWidth + } else { + r.ring.StrokeWidth = 0 + } + r.back.Refresh() + r.ring.Refresh() + r.text.Refresh() +} + +func (r *tabWordRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.back, r.ring, r.text} +} + +func (r *tabWordRenderer) Destroy() {} + +// Tabbed is the strip with its screens under it: the strip on the column the +// screens keep their words on, a hairline across the whole window under it, +// and every screen in the room below, one on top of another, the chosen one +// showing. +// +// A plain container rather than a widget, for the reason Section gives: a walk +// over the tree that meets a widget it was never told about stops there and +// reports nothing below it, and that has made a guard pass while proving +// nothing more than once in this project. A container is known to every walk +// already, so every screen is reached whether or not it is on show - which is +// what the guards asked of the toolkit's tabs by walking their items. +// +// The strip goes through readableWidth and Indented, which is exactly what a +// screen's title goes through, so the first word of the strip and the title +// stand on one edge by construction rather than by two numbers agreeing. +func Tabbed(strip *Tabs) *fyne.Container { + rule := canvas.NewRectangle(PaletteColour(theme.ColorNameSeparator, theme.VariantDark)) + objects := []fyne.CanvasObject{rule, container.New(readableWidth{}, Indented(strip))} + for _, tab := range strip.items { + objects = append(objects, tab.Content) + } + return container.New(tabbed{}, objects...) +} + +// tabbed is the layout behind Tabbed. Objects[0] is the hairline, Objects[1] +// the strip in its column, and everything after that a screen. +// +// The rule is laid out under the strip's last pixel rather than below it, and +// the strip is painted after the rule, so the mark under the chosen word sits +// on the line and covers it - one line that the chosen word interrupts, rather +// than a line and a mark stacked apart. +// +// The screens are all given the whole room, hidden or not. A hidden screen +// costs a layout pass and no pixels, and a screen that is laid out before it is +// shown is one that appears at its size rather than a frame later. +type tabbed struct{} + +func (tabbed) MinSize(objects []fyne.CanvasObject) fyne.Size { + if len(objects) < 2 { + return fyne.Size{} + } + strip := objects[1].MinSize() + size := fyne.NewSize(strip.Width, InsetBar+strip.Height+GapUnderTabs) + // Every screen rather than the one on show, so that moving between them + // never asks the window to grow - the same answer the toolkit's tabs gave. + screens := fyne.NewSize(0, 0) + for _, screen := range objects[2:] { + screens = screens.Max(screen.MinSize()) + } + size.Width = fyne.Max(size.Width, screens.Width) + size.Height += screens.Height + return size +} + +func (tabbed) Layout(objects []fyne.CanvasObject, size fyne.Size) { + if len(objects) < 2 { + return + } + rule, strip := objects[0], objects[1] + height := strip.MinSize().Height + strip.Move(fyne.NewPos(0, InsetBar)) + strip.Resize(fyne.NewSize(size.Width, height)) + foot := InsetBar + height + rule.Move(fyne.NewPos(0, foot-Hairline)) + rule.Resize(fyne.NewSize(size.Width, Hairline)) + + top := foot + GapUnderTabs + for _, screen := range objects[2:] { + screen.Move(fyne.NewPos(0, top)) + screen.Resize(fyne.NewSize(size.Width, fyne.Max(0, size.Height-top))) + } +} diff --git a/internal/gui/parts/theme.go b/internal/gui/parts/theme.go index 038544b2..8e0da164 100644 --- a/internal/gui/parts/theme.go +++ b/internal/gui/parts/theme.go @@ -4,7 +4,6 @@ import ( "image/color" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" ) @@ -31,6 +30,23 @@ import ( // colours at call sites. const ColorNamePanel fyne.ThemeColorName = "panel" +// ColorNameLift is what the pointer does to a face that is itself light - the +// filled primary button - and ColorNameShade is what a press does to it. +// +// Two names of ours rather than the toolkit's Hover and Pressed, and the +// reason is arithmetic rather than taste. L* is not linear: the palette's hover +// of white at 0x22 moves a dark face by 12.7 L* and the primary face by 3.7, +// which is the 1.12 contrast O205 measured on Generate - a hover drawn and not +// seen. To move the primary face by the 10 L* this palette calls noticeable +// takes white at 0x66, and that same alpha on a dark face would move it by +// twenty five. One name cannot be right for both faces, so the light face has +// its own two. Measured on 2026-09-15: 83.0 L* under the pointer and 58.5 +// pressed, against 71.9 at rest, with the ink on every one of them above 4.5. +const ( + ColorNameLift fyne.ThemeColorName = "lift" + ColorNameShade fyne.ThemeColorName = "shade" +) + var ( darkColours = map[fyne.ThemeColorName]color.Color{ theme.ColorNameBackground: hex(0x1E, 0x1E, 0x1E), @@ -91,7 +107,14 @@ var ( // hover of docs/UX.md section 8.2 to the byte. The measured look is // kept and the behaviour is corrected, because section 8 computed this // as the background of a row and the toolkit uses it on anything. - theme.ColorNameHover: overlay(0xFF, 0xFF, 0xFF, 0x22), + theme.ColorNameHover: overlay(0xFF, 0xFF, 0xFF, 0x22), + // What a press does to a dark face: more of the same white, because a + // press has to be told from the hover it follows and black over + // #2A2A2D moves it by 3.9 L*, which nobody sees. Measured 2026-09-15. + theme.ColorNamePressed: overlay(0xFF, 0xFF, 0xFF, 0x40), + // The two for the light face, see ColorNameLift. + ColorNameLift: overlay(0xFF, 0xFF, 0xFF, 0x66), + ColorNameShade: overlay(0x00, 0x00, 0x00, 0x33), theme.ColorNameSelection: hex(0x2C, 0x4A, 0x6B), // A box to type in has to be findable without reading a word, and until // 2026-08-23 it was not. Measured on the palette as it stood: a field @@ -244,7 +267,13 @@ var ( // Black rather than white here: this page is white, so its hover is // DARKER than what is under it. 0x20 over white comes out at #DFDFDF, // which is what section 8.3 measured. - theme.ColorNameHover: overlay(0x00, 0x00, 0x00, 0x20), + theme.ColorNameHover: overlay(0x00, 0x00, 0x00, 0x20), + // The same way up as the hover: a light face is pressed darker. The + // light primary is dark blue, so it is lifted with white like the dark + // palette's - written the same way, measured only for the dark one. + theme.ColorNamePressed: overlay(0x00, 0x00, 0x00, 0x33), + ColorNameLift: overlay(0xFF, 0xFF, 0xFF, 0x66), + ColorNameShade: overlay(0x00, 0x00, 0x00, 0x33), theme.ColorNameSelection: hex(0xCF, 0xE4, 0xF7), // The same step, worked out the same way against a white page. Here a // field is the sunken one - on white there is nowhere lighter to go - @@ -283,6 +312,29 @@ func hex(r, g, b uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: 0x // decides what a hovered button looks like. func overlay(r, g, b, a uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: a} } +// blended is an overlay laid over a face, worked out here so that a control +// whose face IS the fill - the primary button - can paint one opaque colour +// rather than stacking a translucent rectangle on an opaque one. +// +// The same arithmetic as the toolkit's blendColor (widget/button.go), the +// over operator on premultiplied 16 bit channels, kept in step by hand +// because the toolkit's is unexported: a guard measures the pixels a button +// comes out as, so the two cannot quietly disagree without something going +// red. +func blended(under, over color.Color) color.Color { + dstR, dstG, dstB, dstA := under.RGBA() + srcR, srcG, srcB, srcA := over.RGBA() + blend := func(src, dst, alpha uint32) uint16 { + return uint16((src + dst - (dst * alpha / 0xFFFF)) & 0xFFFF) + } + return color.RGBA64{ + R: blend(srcR, dstR, srcA), + G: blend(srcG, dstG, srcA), + B: blend(srcB, dstB, srcA), + A: blend(srcA, dstA, srcA), + } +} + // ours is the palette laid over the toolkit's theme. // // Everything not named above falls through to the default rather than being @@ -324,36 +376,34 @@ func (o ours) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.C // than the one its cards use, whatever the names suggest. Measured on screen: // with the toolkit's 24 and 18 the sections shouted over the page they were on. func (o ours) Size(name fyne.ThemeSizeName) float32 { + // Every answer is a token, so the ladder of type and the scale of + // distances have one home (parts/tokens.go). A size not named here is the + // toolkit's own, and the point of naming one is that somebody chose it. + // + // The room inside a control is the one that was argued over: the + // toolkit's 8 makes a row of a menu 41 px tall for 13 px of text - + // measured off the open list on 2026-08-12 - and moving it here made the + // whole form denser, not the list. The list got a control of its own + // instead (parts/openlist.go), and 6 stays on its own merits: it is the + // room inside every box and button on the form, and it went in against a + // render of the form. switch name { case theme.SizeNameSubHeadingText: - return 20 // the screen title + return TextTitle case theme.SizeNameHeadingText: - return 17 // a section title, drawn by the card + return TextHeading + case theme.SizeNameText: + return TextBody case theme.SizeNameCaptionText: - return 12 // an explanation under a field, at 11 it was hard work + return TextCaption case theme.SizeNamePadding: - return 6 // room between things, the toolkit's 4 was tight + return ThemePadding case theme.SizeNameInnerPadding: - // Room inside a control, above and below whatever it holds. The - // toolkit's 8 makes a row of a menu 41 px tall for 13 px of text - - // measured off the open list on 2026-08-12, which is 3.1 times the - // text and about half again what a desktop menu uses. - // - // The first half of what stood here is true and the conclusion was - // wrong, corrected on 2026-08-18. The theme IS asked for a size by name - // and not by widget. What that does not follow from is "it is the only - // knob there is", which is what this comment said for six days: a theme - // can be replaced for a SUBTREE with container.NewThemeOverride, and - // nobody had looked. So a list was made denser by moving the padding of - // the whole form, the owner reported the list again, and the answer the - // second time was a control of our own - see parts/openlist.go. - // - // This number stays at 6 on its own merits: it is the room inside every - // control on the form, it went in against a render of the form, and the - // toolkit's 8 was measured as too loose there as well. - return 6 + return ControlInset case theme.SizeNameCardRadius: - return 8 + return RadiusPanel + case theme.SizeNameInputRadius, theme.SizeNameButtonRadius: + return RadiusField } return o.Theme.Size(name) } @@ -363,47 +413,6 @@ func (o ours) Size(name fyne.ThemeSizeName) float32 { // of a screen nobody has. func Theme() fyne.Theme { return ours{theme.DefaultTheme()} } -// QuietUnlessChosen dims the ordinary text colour for one part of the screen. -// -// The tab strip is the reason it exists. The toolkit draws the tab somebody is -// on in the accent colour and every other tab in the ordinary foreground - -// tabs.go, the two lines either side of 716 - so measured off a render on -// 2026-08-20 the SELECTED tab stood at 7.71 against the page while the three -// nobody is on stood at 13.36. The one that is chosen was the dimmest label in -// the strip, and four names competed at full strength for a strip that has one -// answer. -// -// A theme for a subtree rather than a colour at a call site, because the strip -// is drawn inside the toolkit's own renderer and there is nothing there to -// hand a colour to. That mechanism is container.NewThemeOverride, which this -// project spent six days believing did not exist - the comment claiming the -// theme was "the only knob there is" was wrong, and this is the second place -// the correction pays. -// -// Everything else falls through to our own theme, so the strip keeps the -// palette, the sizes and the spacing the rest of the window has. -func QuietUnlessChosen(o fyne.CanvasObject) fyne.CanvasObject { - return container.NewThemeOverride(o, quiet{Theme()}) -} - -// AtFullStrength puts a subtree back on the ordinary theme. -// -// The screens live inside the tab container, so anything applied to the strip -// reaches them as well. This is what stops a dimmer strip from dimming every -// word on the form under it. -func AtFullStrength(o fyne.CanvasObject) fyne.CanvasObject { - return container.NewThemeOverride(o, Theme()) -} - -type quiet struct{ fyne.Theme } - -func (q quiet) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { - if name == theme.ColorNameForeground { - return q.Theme.Color(theme.ColorNamePlaceHolder, variant) - } - return q.Theme.Color(name, variant) -} - // PaletteColour is one colour of either palette, for a guard to measure. // // It reads the palette for the variant asked about rather than going through diff --git a/internal/gui/parts/toggle.go b/internal/gui/parts/toggle.go new file mode 100644 index 00000000..8a226ccf --- /dev/null +++ b/internal/gui/parts/toggle.go @@ -0,0 +1,269 @@ +package parts + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// Toggle is a switch this window draws itself: a square in the column of +// controls, with its name in the column of names like every other field. +// +// # Why the name is not on it any more, on 2026-09-15 +// +// It carried its own words until then, and that was the answer to O72: a switch +// given a heading ABOVE it arrived as a bare square with nothing to read on the +// thing you click. In a grid of names beside controls the name stands level +// with the square, in the same column as every other name, so there is +// something to read next to it without the switch being the one control that +// breaks the grid (GUI rule 13). This is the deliberate other side of O72, not +// a slip back into it: the name did not move onto the square, the form grew a +// column to hold it. +// +// # Why it is drawn here rather than through widget.Check +// +// widget.Check draws the mark that says the keyboard is here as a disc behind +// the square, filled with the focus colour, and it fills that disc whether the +// keyboard arrived by key or by press - see PointerFocus for the whole of that. +// Drawing the square ourselves puts the keyboard mark where every other control +// in this window puts it, a ring standing clear of the control, and makes the +// pointer draw a halo the toolkit's check never did (O205). +type Toggle struct { + widget.DisableableWidget + + // Checked is the value, exported so a screen can read it and hold a pointer + // to it the way the two label switches do. + Checked bool + OnChanged func(bool) + + hovered bool + marked bool + // from knows whether the keyboard arrived by press or by key, so that a + // press can put the keyboard here without drawing the mark that says so. + from PointerFocus +} + +var ( + _ fyne.Tappable = (*Toggle)(nil) + _ fyne.Focusable = (*Toggle)(nil) + _ desktop.Hoverable = (*Toggle)(nil) +) + +// NewToggle makes a switch, off, that calls changed when it flips. +func NewToggle(changed func(bool)) *Toggle { + t := &Toggle{OnChanged: changed} + t.ExtendBaseWidget(t) + return t +} + +// SetChecked sets the value and redraws, calling OnChanged only when it moved - +// the toolkit's own rule, so a screen that resets a switch to what it already +// was does not fire the callback. +func (t *Toggle) SetChecked(on bool) { + if t.Checked == on { + return + } + t.Checked = on + t.Refresh() + if t.OnChanged != nil { + t.OnChanged(on) + } +} + +// Hovered and Marked report the drawn state, for a guard. +func (t *Toggle) Hovered() bool { return t.hovered } +func (t *Toggle) Marked() bool { return t.marked } + +// drawsOwnEdge tells WithRing to leave this alone: the square has its own +// border and its own ring, see selfEdged. +func (t *Toggle) drawsOwnEdge() {} + +// Tapped flips the switch and puts the keyboard on it, quietly. +// +// Quietly, because the mark means "the keyboard is here and you are using +// it" and a press is not that - see PointerFocus. But the keyboard does come +// here, the way it comes to the toolkit's own check (widget/check.go Tapped, +// focusIfNotMobile) and to every checkbox on every desktop: the next Space +// flips this switch again and the next Tab leaves from here. Until 2026-09-16 +// it did not come at all. The sentence that stood here said the driver does +// not focus a tapped widget, which is true - it UNFOCUSES whatever had the +// keyboard (internal/driver/glfw/window.go, mouseClicked) and leaves the rest +// to the widget - and the conclusion drawn from it was that nothing needed +// doing, so a press left the keyboard nowhere. An outside review of the pull +// request named it, with a mechanism that was wrong about the driver and a +// conclusion that was right about this switch. +func (t *Toggle) Tapped(*fyne.PointEvent) { + if t.Disabled() { + return + } + t.takeTheKeyboardQuietly() + t.SetChecked(!t.Checked) +} + +// takeTheKeyboardQuietly moves the focus here without the mark, unless it is +// here already - a Space on a focused switch goes through Tapped too, and +// re-focusing it would run FocusLost and FocusGained for nothing. +func (t *Toggle) takeTheKeyboardQuietly() { + app := fyne.CurrentApp() + if app == nil { + return + } + canvas := app.Driver().CanvasForObject(t) + if canvas == nil || canvas.Focused() == t { + return + } + t.from.Quietly(func() { canvas.Focus(t) }) +} + +// Quietly runs a focus change without drawing the mark. See PointerFocus and +// FocusQuietly. +func (t *Toggle) Quietly(focus func()) { t.from.Quietly(focus) } + +func (t *Toggle) MouseIn(*desktop.MouseEvent) { + t.hovered = true + t.Refresh() +} +func (t *Toggle) MouseMoved(*desktop.MouseEvent) {} +func (t *Toggle) MouseOut() { + t.hovered = false + t.Refresh() +} + +// FocusGained draws the ring when the keyboard is what brought the focus here. +// A press brings it quietly and the first key drawn on it turns the ring on - +// the same rule as the Chooser, so a person reaching for the keyboard after a +// click sees which control is listening. +func (t *Toggle) FocusGained() { + if t.from.Quiet() { + return + } + t.mark() +} + +func (t *Toggle) mark() { + t.marked = true + t.Refresh() +} + +func (t *Toggle) FocusLost() { + t.marked = false + t.Refresh() +} + +// TypedRune answers nothing, for the reason Button.TypedRune gives: the +// desktop driver delivers one press of the space bar as the key and as the +// character, and a switch answering both flipped twice on one press - back to +// where it started, which reads as a switch that ignores the space bar. +func (t *Toggle) TypedRune(rune) {} + +// TypedKey flips the switch on the space bar, and draws the mark if the +// keyboard arrived quietly - a key has been used now. +func (t *Toggle) TypedKey(event *fyne.KeyEvent) { + if event == nil || t.Disabled() { + return + } + if !t.marked { + t.mark() + } + if event.Name == fyne.KeySpace { + t.Tapped(nil) + } +} + +// CreateRenderer draws the square, its mark and its ring. +func (t *Toggle) CreateRenderer() fyne.WidgetRenderer { + t.ExtendBaseWidget(t) + halo := canvas.NewRectangle(color.Transparent) + halo.CornerRadius = RadiusField + ring := canvas.NewRectangle(color.Transparent) + ring.CornerRadius = RadiusMark + ring.StrokeColor = PaletteColour(theme.ColorNamePrimary, theme.VariantDark) + square := canvas.NewRectangle(color.Transparent) + square.CornerRadius = RadiusMark + tick := canvas.NewImageFromResource(theme.NewColoredResource(theme.ConfirmIcon(), theme.ColorNameForegroundOnPrimary)) + tick.FillMode = canvas.ImageFillContain + r := &toggleRenderer{toggle: t, halo: halo, ring: ring, square: square, tick: tick} + r.Refresh() + return r +} + +// toggleRenderer draws a 20 px square inside a 24 px target, so a switch is the +// same size as the button that explains a field beside it and stands in the +// same box. +type toggleRenderer struct { + toggle *Toggle + halo *canvas.Rectangle + ring *canvas.Rectangle + square *canvas.Rectangle + tick *canvas.Image +} + +func (r *toggleRenderer) Layout(size fyne.Size) { + // At the LEFT of whatever room the row gives the control, so the square + // lands on the same edge as every box above it rather than in the middle + // of a cell that fills the form. The row makes the control cell as wide as + // what is left, so a switch centred in it would sit far to the right of the + // column its name stands in. + target := fyne.NewSquareSize(GlyphButton) + top := (size.Height - GlyphButton) / 2 + r.halo.Resize(target) + r.halo.Move(fyne.NewPos(0, top)) + square := fyne.NewSquareSize(markSide) + at := fyne.NewPos((GlyphButton-markSide)/2, top+(GlyphButton-markSide)/2) + r.square.Resize(square) + r.square.Move(at) + // The ring hugs the square rather than the target, so the mark is round the + // thing that changes and not round the room a finger needs. + r.ring.Resize(square.Add(fyne.NewSquareSize(ringGap * 2))) + r.ring.Move(at.Subtract(fyne.NewPos(ringGap, ringGap))) + r.tick.Resize(fyne.NewSquareSize(markSide - GapInline*2)) + r.tick.Move(at.Add(fyne.NewPos(GapInline, GapInline))) +} + +func (r *toggleRenderer) MinSize() fyne.Size { return fyne.NewSquareSize(GlyphButton) } + +func (r *toggleRenderer) Refresh() { + dark := theme.VariantDark + off := r.toggle.Disabled() + if r.toggle.hovered && !off { + r.halo.FillColor = PaletteColour(theme.ColorNameHover, dark) + } else { + r.halo.FillColor = color.Transparent + } + if r.toggle.marked { + r.ring.StrokeWidth = ringWidth + } else { + r.ring.StrokeWidth = 0 + } + switch { + case off: + r.square.FillColor = color.Transparent + r.square.StrokeColor = PaletteColour(theme.ColorNameDisabled, dark) + r.square.StrokeWidth = edgeWidth + r.tick.Hide() + case r.toggle.Checked: + r.square.FillColor = PaletteColour(theme.ColorNamePrimary, dark) + r.square.StrokeWidth = 0 + r.tick.Show() + default: + r.square.FillColor = PaletteColour(theme.ColorNameInputBackground, dark) + r.square.StrokeColor = PaletteColour(theme.ColorNameInputBorder, dark) + r.square.StrokeWidth = edgeWidth + r.tick.Hide() + } + r.halo.Refresh() + r.ring.Refresh() + r.square.Refresh() + r.tick.Refresh() + canvas.Refresh(r.toggle) +} + +func (r *toggleRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.halo, r.ring, r.square, r.tick} +} + +func (r *toggleRenderer) Destroy() {} diff --git a/internal/gui/parts/tokens.go b/internal/gui/parts/tokens.go new file mode 100644 index 00000000..d919cd87 --- /dev/null +++ b/internal/gui/parts/tokens.go @@ -0,0 +1,215 @@ +package parts + +// Every number that decides how the window LOOKS lives in this file and +// nowhere else. A view names a distance, a size or a width by its role and +// never by its value, so a change of look is a change in one place - and a +// number that is not here is a number nobody chose. +// +// Measured on 2026-09-11, before this file existed: 149 different gaps between +// neighbours across the stored screens, on 2113 pairs, 72 of them zero. The +// window had three gaps of its own (1, 9 and 14) and every stock container +// added the theme's padding of 6 on top, so what reached the screen was the +// sum of a step somebody chose and one nobody did. The scale below is the +// answer to that number, not to a taste. + +// The scale of distances. Six steps, and every gap in the window is one of +// them. Unexported on purpose: a view asks for a role from the next block, and +// a distance that fits no role gets a role here rather than a bare step at the +// call site. +const ( + space1 = 4 + space2 = 8 + space3 = 12 + space4 = 16 + space5 = 24 + space6 = 32 +) + +// Distances, each a step of the scale, named for what they say. The steps +// have to be far enough apart to be read without counting: a field holds +// together at one step and two fields stand apart at twice it, which is the +// ratio TestAFieldHoldsTogetherMoreTightlyThanTwoFieldsDo asks for. +const ( + // GapInline separates things that share one line: a name and the mark + // beside it, an icon and its word. + GapInline = space1 + // GapTight is the space inside one field below its control - between the + // control and the sentence under it, and before the line that says what + // was refused. Those are one thing, so they sit close. + GapTight = space1 + // GapLabel is the space between a field's name and its control. Wider than + // GapTight because the name is drawn without room of its own around the + // ink, where a toolkit label carried its padding. + GapLabel = space2 + // GapField is the space between two fields in a section. + GapField = space4 + // GapSection is the space between two panels. Bigger than the room inside + // a panel, because a boundary drawn more weakly than the padding inside it + // groups the wrong things - TestTheGapBetweenSectionsIsWiderThanTheGapBetweenFields. + GapSection = space5 + // GapColumns is the space between two fields standing side by side. + GapColumns = space4 + // GapTabs is the space between two words on the strip across the top, + // box to box. The words keep TabInset inside their boxes as well, so the + // ink of two neighbours stands three steps apart. + GapTabs = space2 + // TabInset is the room a word on the strip keeps inside its own box, all + // round the letters. It is what the fill under the pointer and the ring + // round the keyboard have to draw in, so it is the smallest step that + // leaves a ring readable as a ring rather than as an outline of the ink. + TabInset = space2 + // GapUnderTabs is the space between the line under the strip and the title + // of the screen it leads to. A section's step, because the strip and the + // screen are two things and not one. + GapUnderTabs = space5 + // Inset is the room a panel keeps between its edge and its content, and + // the distance a title standing outside a panel is indented by, so the two + // line up by construction rather than by two numbers agreeing. + Inset = space4 + // InsetBar is the room the strip at the foot keeps around what stands on + // it. A step less than a panel: a bar reads as a bar by running the whole + // width, and the room a card keeps would make it read as a third panel. + InsetBar = space3 + // ControlInset is the room INSIDE a box to type in and a button, between + // their edge and their words. Off the scale on purpose: the scale is for + // the distance between things, and this is what keeps a box 32 px tall, + // which is what it has been since the palette was measured. The toolkit's + // own value is 8. + ControlInset = 6 + // ThemePadding is what the toolkit is told when a stock container asks how + // far apart to put its children. The smallest step, so a container this + // package did not replace lands ON the scale rather than beside it - zero + // would make every such container glue its content together in silence. + ThemePadding = space1 + // listEdgeGap is the space kept between an open list and the edge of the + // window, so that a list filling the room still reads as sitting inside it. + listEdgeGap = space2 + // revealMargin is how much of the form is kept above a control brought + // into view, so it arrives looking like part of a form rather than pinned + // to the top edge with its own label cut off above it. + revealMargin = space5 +) + +// The ladder of type. Four sizes and nothing between them. +const ( + // TextTitle is the one line that says what a screen is for. + TextTitle = 20 + // TextHeading names a section, between the screen and a field. + TextHeading = 17 + // TextBody is a value, a field's name, a button and a sentence someone + // reads at rest - the toolkit's own text size, kept. + TextBody = 14 + // TextCaption is an explanation under a field. At 11 it was hard work. + TextCaption = 12 +) + +// Corners. Chosen here rather than inherited: the toolkit's input radius was +// read in three places to keep our shapes in step with its boxes and never +// picked in any of them (GUI.md section 10.1). +const ( + // RadiusField rounds a box to type in, a menu and a button. + RadiusField = 6 + // RadiusPanel rounds the surface a section stands on. + RadiusPanel = 8 + // RadiusMark rounds the square of a switch. A step under RadiusField, + // because the square is 20 px and a 6 px corner on it reads as a disc. + RadiusMark = 4 +) + +// Strokes. +const ( + // ringWidth is how thick the line round a control that holds the keyboard + // or was refused is. Twice the toolkit's input border, so it reads as a + // deliberate edge rather than as the box's own outline changing colour. One + // pixel lands between pixels and is anti-aliased away to something fainter + // than the number suggests - measured on the section surface on 2026-08-12, + // where a one pixel stroke of a 29.4 L* colour came out at 22.3. + ringWidth = 2 + // ringGap is the room between a control's face and the ring the keyboard + // draws round it, on the controls that draw the ring themselves - a + // button, the square of a switch. Read in the painter rather than assumed + // (internal/painter/draw.go, drawOblong): a stroke runs down the middle of + // its rectangle's edge, so a ring drawn ON the edge of the filled primary + // button is primary over primary and cannot be seen. Standing this far + // clear of the face the ring is one colour on every face. It is painted + // outside the control's bounds, which the toolkit allows because it clips + // nothing, so the control stays the size of its face. + ringGap = 2 + // edgeWidth is the line round a control at rest that has one - a menu, a + // plain button. One pixel, and it arrives fainter than its colour for the + // reason ringWidth gives, which is why a resting edge and a state ring are + // never the same thickness: the eye tells them apart by weight before it + // reads the colour. + edgeWidth = 1 + // TabIndicator is how thick the mark under the chosen word on the strip + // is. The same thickness as a ring, for the same reason: one pixel is + // anti-aliased to something fainter than the colour it was given. + TabIndicator = 2 + // Hairline is a line that separates and says nothing else - the rule + // under the strip across the top. One pixel, and drawn as a filled + // rectangle rather than a stroke, so it is one pixel and not a blend. + Hairline = 1 +) + +// Widths and heights that are not distances. +const ( + // ColumnWidth is as wide as the form is allowed to get, whatever the + // window does. O72, measured on 2026-08-10 and again on 2026-08-11: + // maximised to 3862 px, every box was 3848 to 3854 px of it - 99.7 per + // cent - so the seed field holding "0" was nearly four thousand pixels + // wide. 820 comes from the longest sentence the form actually holds, which + // ends at 797 px. Prose is easiest at 45 to 75 characters a line and 820 px + // is about 112, so the typography pass has room to tighten this. It cannot + // widen it. + ColumnWidth = 820 + // NumericWidth is as wide as a box holding a number gets. 140 px holds + // eleven digits at the text size this window uses, which covers every + // number any of these fields accepts - the ceiling on files is seven digits + // and the largest size anybody types is eight. + NumericWidth = 140 + // GlyphButton is the side of the small square button that holds one + // glyph - the mark beside a field's name that opens its explanation. The + // glyph itself is the toolkit's inline icon, 20, and the square keeps two + // pixels round it, which is what makes it a target and not a letter. + GlyphButton = 24 + // markSide is the side of the square of a switch: the toolkit's inline + // icon, the same 20 the glyph above is built round, so a switch and the + // button that explains it are one size and stand in one 24 px box. + markSide = 20 + // DetailWidth is how wide the longer explanation gets when it opens. + // Narrower than the form on purpose: the column is 820 px because that is + // what the form needs, and the same width for a paragraph of prose is about + // 112 characters a line - well past the 45 to 75 that reads easily. A block + // of text with nothing beside it has no reason to be as wide as a row of + // fields. + DetailWidth = 380 + // SlimHeight is how tall a progress track is drawn. The toolkit's bar is + // as tall as the words "100%" and the padding around them, because it + // writes the percentage inside itself - and the line under it already + // ends with that number, so the second copy cost 23 px of a bar the owner + // asked to make smaller. + SlimHeight = 8 + // listShare is how much of the window an open list may cover: half of its + // height, in whole rows, before it scrolls. NN/g puts the ceiling as a rule + // rather than a number - the label and the context stay in view while the + // list is open - and the other half of the window IS the context. Until + // 2026-09-15 this was a count, eight rows, chosen on 2026-08-18 for the + // smallest window this program opens at and then applied to every window: + // measured with guirender that day, the list of twenty-four formats was + // 224 px tall at 800x600, at 1100x1300 and at the owner's 1101x1025 alike, + // so a third of the values showed however tall the window was (O203). Half + // gives ten rows at 600 px, eighteen at 1025 and twenty-three at 1300, and + // at 600 the list still ends above the buttons at the foot. + listShare = 0.5 + // rowPadding is the room above and below a list row's contents. Ours rather + // than the theme's, which is the entire point of that control: the theme's + // inner padding is what a box to type in and a button are also built from, + // so a list cannot be made denser through it without making every control + // on the form denser too. + rowPadding = space1 + // rowGutter is the room in front of a list row's mark, and rowGap the room + // after it and after the icon. Both were 6 until the scale arrived and are + // its second step now, so a row is 4 px wider than it was. + rowGutter = space2 + rowGap = space2 +) diff --git a/internal/gui/parts/typeface.go b/internal/gui/parts/typeface.go new file mode 100644 index 00000000..4db37af0 --- /dev/null +++ b/internal/gui/parts/typeface.go @@ -0,0 +1,44 @@ +package parts + +import ( + "fyne.io/fyne/v2" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/font" +) + +// The window is set in Inter (internal/gui/font). +// +// Until 2026-09-15 it was set in Noto Sans, which is what the toolkit bundles +// as its text face (theme/bundled-fonts.go in fyne v2.8.1 - measured, because +// an earlier note in this project said the window already drew in Inter, and +// what the toolkit embeds of Inter is the symbols file alone). Nothing was +// wrong with Noto Sans as a face. What was wrong is the sentence +// docs/GUI.md section 10.1 records: a window of stock widgets in the stock +// face looks like the library it was built with, and after steps 1 to 5 of +// the rework this was the one thing left on screen that still did. +// +// One resource each rather than one per call: the painter caches the faces +// it shapes with under the resource's name, so the two names below are the +// keys of that cache and must stay distinct from each other and from the +// toolkit's own. +var ( + interRegular = fyne.NewStaticResource("Inter-Regular.ttf", font.Regular) + interBold = fyne.NewStaticResource("Inter-Bold.ttf", font.Bold) +) + +// Font is Inter for the two styles the window draws in, and the toolkit's own +// face for everything else. +// +// The toolkit resolves a style in the order monospace, bold, italic, symbol +// (theme.go, DefaultTheme). The same order is kept here so that a style this +// window never asks for - bold italic, say - falls to exactly the face the +// toolkit would have chosen, rather than to a bold Inter beside an italic Noto. +func (o ours) Font(style fyne.TextStyle) fyne.Resource { + switch { + case style.Monospace || style.Italic || style.Symbol: + return o.Theme.Font(style) + case style.Bold: + return interBold + } + return interRegular +} diff --git a/internal/gui/parts/words.go b/internal/gui/parts/words.go new file mode 100644 index 00000000..5123b3a8 --- /dev/null +++ b/internal/gui/parts/words.go @@ -0,0 +1,94 @@ +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" +) + +// words is one line of text drawn with no room of its own around the ink. +// +// A toolkit label pads itself by the theme's inner padding on every side, and +// that padding is what a form is made of once there are labels in it: every +// distance between a name and the thing under it was the sum of a gap somebody +// chose and six pixels nobody did. Measured on 2026-09-11 as 149 different +// gaps across the stored screens. A canvas text has the size of its letters +// and nothing else, so a gap given to a layout is the gap that reaches the +// screen. +// +// What it gives up is wrapping - a canvas text is one line, always - which is +// right for a name, a title and a mark, and wrong for a sentence. Sentences +// stay toolkit labels, made ink tight by inkTight below. +// +// The colour is read from the palette directly rather than through the +// installed theme, for the reason panelSurface gives: this window answers dark +// whatever the desktop says. +func words(text string, size float32, bold bool, colour fyne.ThemeColorName) *canvas.Text { + t := canvas.NewText(text, PaletteColour(colour, theme.VariantDark)) + t.TextSize = size + t.TextStyle = fyne.TextStyle{Bold: bold} + return t +} + +// inkTight takes the toolkit's own padding off a label, so a sentence that +// wraps stands on the scale like everything else. +// +// A theme override rather than a widget of ours, because the label keeps +// everything a sentence needs - wrapping, the caption size, the colour an +// importance gives it - and the only thing wrong with it is the room it keeps +// around itself. The override is the toolkit's one public door into a +// subtree's theme, and it is used bare rather than wrapped in a type of ours, +// because the driver casts to it. +func inkTight(o fyne.CanvasObject) fyne.CanvasObject { + return container.NewThemeOverride(o, noInnerPadding{Theme()}) +} + +// Flush puts a label a screen keeps hold of on the edge every other word +// stands on. The same override as inkTight, exported for the one label the +// runner writes to for the whole life of a screen - the line a run speaks on +// - which has to be built by the runner and placed by the bar. +func Flush(label *widget.Label) fyne.CanvasObject { return inkTight(label) } + +// noInnerPadding is the window's theme with the room inside a label taken out. +// Only that one size, so a box to type in under the same override would still +// be a box. +type noInnerPadding struct{ fyne.Theme } + +func (n noInnerPadding) Size(name fyne.ThemeSizeName) float32 { + if name == theme.SizeNameInnerPadding { + return 0 + } + return n.Theme.Size(name) +} + +// Padded keeps one distance from the scale between its edge and its content. +// +// The toolkit's padded container reads the theme's padding, which is the +// smallest step - the right distance between two things in a row and the +// wrong one between a panel's edge and what it holds. +func Padded(inset float32, content fyne.CanvasObject) fyne.CanvasObject { + return container.New(padded{inset: inset}, content) +} + +// padded is the layout behind Padded. +type padded struct{ inset float32 } + +func (p padded) MinSize(objects []fyne.CanvasObject) fyne.Size { + size := fyne.NewSize(0, 0) + for _, o := range objects { + min := o.MinSize() + size.Width = fyne.Max(size.Width, min.Width) + size.Height = fyne.Max(size.Height, min.Height) + } + return size.Add(fyne.NewSquareSize(p.inset * 2)) +} + +func (p padded) Layout(objects []fyne.CanvasObject, size fyne.Size) { + inner := size.Subtract(fyne.NewSquareSize(p.inset * 2)) + for _, o := range objects { + o.Resize(fyne.NewSize(fyne.Max(0, inner.Width), fyne.Max(0, inner.Height))) + o.Move(fyne.NewPos(p.inset, p.inset)) + } +} diff --git a/internal/gui/run_cgo.go b/internal/gui/run_cgo.go index 67b77304..803a86c9 100644 --- a/internal/gui/run_cgo.go +++ b/internal/gui/run_cgo.go @@ -12,6 +12,7 @@ import ( "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/dialog" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/catalogue" "github.com/donislawdev/TestingFilesGenerator/internal/gui/icon" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" @@ -202,7 +203,7 @@ func (d desktop) OpenFolder(path string) { // run opens a real window. The only file in this tree that reaches the app // package, and therefore the only one that needs a C compiler. -func run(errOut io.Writer) int { +func run(showCatalogue bool, errOut io.Writer) int { // Said out loud rather than left to be inferred: everything that touches a // widget from the worker goes through fyne.Do, and a static guard checks // it, but the toolkit had no way to know that. Without this it printed @@ -248,7 +249,17 @@ func run(errOut io.Writer) int { a.Settings().SetTheme(parts.Theme()) w := a.NewWindow(text.WindowTitle(version.Version)) host := desktop{w} - window.Open(host) + if showCatalogue { + // The hidden screen of GUI rule 4: every part in every state, for + // whoever builds the window. No host, because nothing on it runs or + // chooses a directory - and nothing about it is remembered either, + // which is why the close callback below is registered for the + // ordinary window alone. It opens at the remembered size all the same, + // because that is the size this person's screen has room for. + w.SetContent(catalogue.Screen()) + } else { + window.Open(host) + } // The size it was closed at, and whether to put it in the middle. The two // answers come together because they are one decision - a window bigger than @@ -263,7 +274,16 @@ func run(errOut io.Writer) int { // Written down as the window goes rather than as it is resized. SetOnClosed // runs after the close intercept, which is where the directory is kept, so // the two land together whichever way the window was shut. - w.SetOnClosed(func() { host.rememberThisSize() }) + // + // The ordinary window only. The catalogue is a developer's screen, 8900 px + // of controls somebody drags tall to read, and until 2026-09-16 closing it + // wrote that size down as the size the ordinary window opens at next time. + // An outside review of the pull request named it. No guard reaches this + // line - it is behind cgo, like the rest of the remembering - so it is + // checked the way the rest was, by a run of the binary. + if !showCatalogue { + w.SetOnClosed(func() { host.rememberThisSize() }) + } w.ShowAndRun() return 0 } diff --git a/internal/gui/run_nocgo.go b/internal/gui/run_nocgo.go index 6949e9f4..d13626e7 100644 --- a/internal/gui/run_nocgo.go +++ b/internal/gui/run_nocgo.go @@ -20,7 +20,7 @@ import ( // to do about it. RUNTIME rather than USAGE because nothing the caller typed // is wrong - the same reasoning docs/GUI.md section 5 applies to the stub this // replaces. -func run(errOut io.Writer) int { +func run(_ bool, errOut io.Writer) int { fmt.Fprintln(errOut, text.NoWindowInThisBuild) return 1 } diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index b470af18..7476a094 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -123,6 +123,10 @@ "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." }, + "DirectoryWithFreeSpace": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Directory}}, {{.Free}}.", + "other": "{{.Directory}} ({{.Free}} free)" + }, "FieldBoundary": { "description": "The name above a box somebody fills in.", "other": "Limit to test" @@ -149,7 +153,7 @@ }, "FieldLabel": { "description": "The name above a box somebody fills in.", - "other": "Write a label inside each file" + "other": "Label in each file" }, "FieldManifest": { "description": "The name above a box somebody fills in.", @@ -192,18 +196,6 @@ "one": "1 file", "other": "{{.Count}} files" }, - "HeadingGenerate": { - "description": "The heading at the top of a screen.", - "other": "Generate files" - }, - "HeadingPreset": { - "description": "The heading at the top of a screen.", - "other": "Build a set for a question" - }, - "HeadingRecipe": { - "description": "The heading at the top of a screen.", - "other": "Run several batches together" - }, "HintBoundary": { "description": "The line under a field name, saying what the field does.", "other": "Three files: one byte under the limit, one on it, one over." @@ -296,10 +288,6 @@ "description": "Shown on the preset screen.", "other": "Typically finds:" }, - "PreviewFreeSpace": { - "description": "Shown after a preview and before anything is written. Carries these values, each of which has to stay spelled exactly that way: {{.Free}}, {{.Directory}}.", - "other": "{{.Free}} free in {{.Directory}}" - }, "PreviewNothingWritten": { "description": "Shown after a preview and before anything is written.", "other": "nothing written yet" @@ -356,6 +344,18 @@ "description": "Shown in the window. Carries one value, {{.Format}}, which has to stay spelled exactly that way.", "other": "Settings for {{.Format}}" }, + "SizeAndBytes": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Human}}, {{.Exact}}.", + "other": "{{.Human}} ({{.Exact}})" + }, + "SizeBetween": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Least}}, {{.Most}}.", + "other": "between {{.Least}} and {{.Most}}" + }, + "SizeFromContents": { + "description": "Shown in the window.", + "other": "decided by the files inside, known after Preview" + }, "SizeWayBoundary": { "description": "Shown in the window.", "other": "Around a limit" @@ -372,6 +372,18 @@ "description": "Shown in the window. Carries one value, {{.Files}}, which has to stay spelled exactly that way.", "other": "Stopped after {{.Files}}. The manifest describes exactly those." }, + "SubtitleGenerate": { + "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", + "other": "Files of one format and one size, as many as you need." + }, + "SubtitlePreset": { + "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", + "other": "Ready-made sets of files, each built to answer one question about the system under test." + }, + "SubtitleRecipe": { + "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", + "other": "Batches of different formats and sizes, generated together in one run." + }, "TabAbout": { "description": "The name of a tab along the top of the window.", "other": "About" @@ -396,6 +408,10 @@ "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Count}}, {{.Reason}}.", "other": "this run asks for {{.Count}} files - {{.Reason}}" }, + "WillGoTo": { + "description": "Shown in the window. Carries one value, {{.Directory}}, which has to stay spelled exactly that way.", + "other": "will go to {{.Directory}}" + }, "WorkingOutTheCost": { "description": "Shown in the window.", "other": "Working out what this would cost..." diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 8f5078d8..ffa9d21a 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -1,14 +1,24 @@ package text import ( - "strconv" "strings" ) -// Headings, one per screen. They name what the screen is for rather than what -// it contains, which is why the preset one is a question. -func HeadingGenerate() string { return say("HeadingGenerate", "Generate files") } -func HeadingPreset() string { return say("HeadingPreset", "Build a set for a question") } +// The sentence under each work screen's title, saying what the screen DOES +// for the person reading it - files of what shape, and what they are for. +// +// The titles said this until 2026-09-15 ("Generate files", "Build a set for a +// question"), when the word on the tab became the title so that a screen has +// one name rather than two. The old titles moved under the new ones first, +// word for word, and the owner allowed them to be rewritten the same day: +// "Generate files" under "Single batch" explained nothing the tab had not. +func SubtitleGenerate() string { + return say("SubtitleGenerate", "Files of one format and one size, as many as you need.") +} + +func SubtitlePreset() string { + return say("SubtitlePreset", "Ready-made sets of files, each built to answer one question about the system under test.") +} // HeadingAbout carries the version, so the screen somebody is told to look at // when reporting a problem says which build they are on. @@ -105,7 +115,7 @@ func FieldTargetID() string { return say("FieldTargetID", "Batch name") } func FieldNameTemplate() string { return say("FieldNameTemplate", "File names") } func FieldOutputDir() string { return say("FieldOutputDir", "Output directory") } func FieldSeed() string { return say("FieldSeed", "Seed") } -func FieldLabel() string { return say("FieldLabel", "Write a label inside each file") } +func FieldLabel() string { return say("FieldLabel", "Label in each file") } func FieldPreset() string { return say("FieldPreset", "Preset") } // The line under each field: what it does, in one line, and nothing else. @@ -306,8 +316,11 @@ func TooManyFiles(count int64, reason error) string { // difference between the two screens is how many batches, not how advanced // the person is. "Advanced" would have said the other screen is for // beginners, which is not true of anybody generating one batch of files. -func HeadingRecipe() string { return say("HeadingRecipe", "Run several batches together") } -func TabRecipe() string { return say("TabRecipe", "Several batches") } +func SubtitleRecipe() string { + return say("SubtitleRecipe", "Batches of different formats and sizes, generated together in one run.") +} + +func TabRecipe() string { return say("TabRecipe", "Several batches") } // BatchHeading names one batch in the list, counted the way the refusals count. // @@ -518,25 +531,6 @@ func OneExplanation(line, detail string) string { return line + " " + detail } -// ExactBytes says what a size somebody typed comes to, counted out. -// -// It exists because "10mb" is two different numbers depending on who is -// reading. This tool counts in 1024s - RECIPE.md section 9, settled and not -// reopened - and everything downstream of the box agrees, but nothing on the -// screen said so, so a person testing a limit their system declares in -// millions had no way to see the difference until the files were on disk. -// -// It states the number rather than arguing the units. Saying "MiB" would be -// answering a question nobody asked with a spelling most of the world does not -// use, and it would still need the count for anyone checking a limit. -// -// "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. -func ExactBytes(n int64) string { - return strconv.FormatInt(n, 10) + " B" -} - // RefusedBeforeWriting is what the foot of the form says when a press was // turned down and every reason for it went onto a box. // diff --git a/internal/gui/text/text.go b/internal/gui/text/text.go index 88db1858..e17edd8c 100644 --- a/internal/gui/text/text.go +++ b/internal/gui/text/text.go @@ -67,33 +67,106 @@ func files(n int) string { return sayN("Files", "1 file", "{{.Count}} files", n, nil) } -// separator divides the facts on a status line. +// separator divides the facts on one line. // -// They are separate facts and were one sentence: how many files, how big, and -// whether anything exists yet ran together with a full stop between them, so -// three unrelated things read as prose and none of them could be found at a -// glance. Divided, the line is scanned rather than read. +// They are separate facts and were one sentence: a folded section's stated +// values ran together with a full stop between them, so unrelated things read +// as prose and none of them could be found at a glance. Divided, the line is +// scanned rather than read. const separator = " · " -// PreviewCost is the line under the buttons after Preview, saying what the run -// would cost and that nothing exists yet. +// RunLine is the line under the buttons while nothing pressed has spoken: +// what the form comes to, worked out from the form as it is typed. // -// It names the kinds of file as well as the count, added on 2026-08-12. On the -// generate screen that is on the screen anyway, and on the preset screen it is -// the answer to a question the screen could not otherwise be asked: a preset -// supplies the format itself unless somebody says otherwise, so "seven files, -// 70 MiB" left out the one fact that says what they are. +// It grew out of the line that named only the destination. G6 says the window +// says what a run will cost before anything is pressed, and until 2026-09-14 +// that answer was behind the Preview button - the line at rest said where the +// files would go and nothing else. Now it says how many, how big, what kind +// and where, and Preview turns the estimate into a measurement. // -// The list is left out entirely when there is nothing to list, rather than -// shown empty. A plan that produces no files is a legal outcome here and it -// already says so in the count. -func PreviewCost(count int, formats []string, total string) string { - line := files(count) +// One line rather than a panel of rows, and that is the owner's verdict on +// the rows: four short values in a wide strip read as a panel with nothing +// in it. A line holds the same four facts in the room the bar already keeps. +// +// The facts are divided the way FoldedSummary divides them, so the line is +// scanned rather than read. The destination comes last and is left off when +// there is none, so a form with the directory cleared still says what it +// comes to. +func RunLine(count int, total string, formats []string, destination string) string { + facts := []string{files(count)} + if total != "" { + facts = append(facts, total) + } if len(formats) > 0 { - line += separator + strings.Join(formats, ", ") + facts = append(facts, Formats(formats)) } - return line + separator + total + separator + - say("PreviewNothingWritten", "nothing written yet") + if destination != "" { + facts = append(facts, WillGoTo(destination)) + } + return strings.Join(facts, separator) +} + +// WillGoTo is the destination, as one fact among the others on the line. +func WillGoTo(dir string) string { + return sayf("WillGoTo", "will go to {{.Directory}}", map[string]any{"Directory": dir}) +} + +// WritingTo is what the line says when the form cannot be added up yet - a +// batch with no name, a count that is not a number. The one fact that is +// still true is where the files would go, and it is the one field that +// decides where somebody else's disk gets written to, so it stays on the +// line on its own rather than the line going blank. +func WritingTo(dir string) string { + return sayf("WritingTo", "Files will go to {{.Directory}}", map[string]any{"Directory": dir}) +} + +// AndNothingWrittenYet follows the line after a preview, which has made +// every number on it exact and put nothing on the disk. It carries its own +// divider, so a caller appends it to a line rather than assembling one. +func AndNothingWrittenYet() string { + return separator + say("PreviewNothingWritten", "nothing written yet") +} + +// Formats is a list of kinds of file, said on one line. Here rather than +// joined at the call site because the mark between two items is something a +// person reads, and one language's comma is another's ideograph. +func Formats(kinds []string) string { return strings.Join(kinds, listSeparator) } + +// listSeparator divides the items of a list said on one line. +const listSeparator = ", " + +// SizeAndBytes is a size a person can read with the exact count after it, +// because a limit under test is exact and "10 MB" does not say whether it is +// 10 000 000 or 10 485 760. The exact count is left off when the readable +// form already is it. +func SizeAndBytes(human, exact string) string { + if human == exact { + return human + } + return sayf("SizeAndBytes", "{{.Human}} ({{.Exact}})", + map[string]any{"Human": human, "Exact": exact}) +} + +// SizeBetween is the total of a form that draws its sizes from a range. The +// draw happens when the run is planned, so before that the honest answer is +// the two ends - and Preview turns it into one number. +func SizeBetween(least, most string) string { + return sayf("SizeBetween", "between {{.Least}} and {{.Most}}", + map[string]any{"Least": least, "Most": most}) +} + +// SizeFromContents is the total of a form holding a container whose size is +// whatever the files inside it come to, which nothing knows until the +// container is built. +func SizeFromContents() string { + return say("SizeFromContents", "decided by the files inside, known after Preview") +} + +// DirectoryWithFreeSpace is the output directory with the room left on its +// disk after it, once a preview has measured that room. +func DirectoryWithFreeSpace(dir, free string) string { + return sayf("DirectoryWithFreeSpace", "{{.Directory}} ({{.Free}} free)", + map[string]any{"Directory": dir, "Free": free}) } // ManifestTooLargeToRead is said when a run will write a record this build @@ -113,25 +186,6 @@ func ManifestTooLargeToRead(size, limit string) string { map[string]any{"Size": size, "Limit": limit}) } -// PreviewFreeSpace follows PreviewCost when the disk could be measured. It is -// a separate fact because a disk we cannot read has to say nothing at all -// rather than invent a number. -func PreviewFreeSpace(dir, free string) string { - return separator + sayf("PreviewFreeSpace", "{{.Free}} free in {{.Directory}}", - map[string]any{"Free": free, "Directory": dir}) -} - -// WritingTo is what the status line says when a run has not said anything yet. -// -// The destination is the one field on these forms that is off the screen when -// the window opens, and it is the only one that decides where somebody else's -// disk gets written to. It sits here because the line is kept clear for a run -// whether or not there is one, so saying this costs no room at all - and a -// preview replaces it with a sentence that names the same directory. -func WritingTo(dir string) string { - return sayf("WritingTo", "Files will go to {{.Directory}}", map[string]any{"Directory": dir}) -} - // WorkingOutTheCost is what a preview says while it is going. // // A preview does disk work - it asks how much room there is and whether any of diff --git a/internal/gui/window/about.go b/internal/gui/window/about.go index 9dbc554b..8949cf47 100644 --- a/internal/gui/window/about.go +++ b/internal/gui/window/about.go @@ -82,7 +82,7 @@ var OpenSize = fyne.NewSize(1000, 1000) // without noticing was the thing worth guarding. func About(h Host) fyne.CanvasObject { sections := []fyne.CanvasObject{ - parts.Prose(text.AboutTagline()), + parts.Indented(parts.Prose(text.AboutTagline())), // In a card like every other block on every other screen, so this reads // as a page of the application rather than as the one screen that was // left as it was. @@ -103,7 +103,7 @@ func About(h Host) fyne.CanvasObject { parts.Prose(text.DetailDonate()), parts.Prose(text.SupportURL)), } sections = append(sections, carried()...) - page := parts.Screen(text.HeadingAbout(version.Version), sections...) + page := parts.Screen(parts.Title(text.HeadingAbout(version.Version)), sections...) // The same bar the work screens carry, holding only the Donate button. // diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index 312388d8..29906bd0 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -197,11 +197,10 @@ func NewGenerate(host Host, links ...fyne.CanvasObject) *Generate { // foot of the form would be cut off at the edge of the viewport. g.body = g.tips.Over(container.NewBorder( nil, - parts.ActionBar(rail(append([]fyne.CanvasObject{donateButton(host)}, links...)...), - g.actions(), g.progress(), g.problem.Object()), + g.footer(rail(append([]fyne.CanvasObject{donateButton(host)}, links...)...)), nil, nil, (g.keepScroll(container.NewVScroll(parts.Screen( - text.HeadingGenerate(), + parts.Titled(text.TabOneTarget(), text.SubtitleGenerate()), g.settingsSection()..., )))), )) @@ -214,9 +213,9 @@ func NewGenerate(host Host, links ...fyne.CanvasObject) *Generate { g.ready = true g.onFormatChosen(g.formatPick.Selected) - // Said last, once the box it reads exists. + // Said last, once every box it reads exists. g.runner.destination = g.OutDir - g.runner.sayDestination() + g.runner.refreshLine() // 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 @@ -288,7 +287,7 @@ func (g *Generate) buildFields() { g.outDir = entry(startingDirectory(), "") g.seed = entry("0", "") - g.label = parts.NewToggle("", nil) + g.label = parts.NewToggle(nil) g.label.SetChecked(true) } @@ -335,16 +334,12 @@ func (g *Generate) settingsSection() []fyne.CanvasObject { g.tips.Say(""), g.formatPick), // Side by side, because each pair is one thought: how big and how // many, then what the group is called and what the files are called. - g.fields.Row( - add(format.SettingSize, text.FieldSize(), text.HintSize(), g.tips.Say(text.DetailSize()), - parts.Numeric(g.size)), - add(engine.SettingCount, text.FieldCount(), "", parts.NoDetail, parts.Numeric(g.count)), - ), - g.fields.Row( - add(engine.SettingID, text.FieldTargetID(), text.HintTargetID(), g.tips.Say(text.DetailTargetID()), g.id), - add(engine.SettingName, text.FieldNameTemplate(), text.HintNameTemplate(), - g.tips.Say(text.DetailNameTemplate()), g.name), - ), + add(format.SettingSize, text.FieldSize(), text.HintSize(), g.tips.Say(text.DetailSize()), + parts.Numeric(g.size)), + add(engine.SettingCount, text.FieldCount(), "", parts.NoDetail, parts.Numeric(g.count)), + add(engine.SettingID, text.FieldTargetID(), text.HintTargetID(), g.tips.Say(text.DetailTargetID()), g.id), + add(engine.SettingName, text.FieldNameTemplate(), text.HintNameTemplate(), + g.tips.Say(text.DetailNameTemplate()), g.name), // The settings the chosen format declares land here, under the ones // every format has. g.propBox, @@ -358,11 +353,9 @@ func (g *Generate) settingsSection() []fyne.CanvasObject { 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), - ), + 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/labels.go b/internal/gui/window/labels.go new file mode 100644 index 00000000..3b0ba05a --- /dev/null +++ b/internal/gui/window/labels.go @@ -0,0 +1,80 @@ +package window + +import ( + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/preset" +) + +// The column of names is as wide as the widest name the window can ever +// show, and it is worked out rather than written down - GUI rule 14 of +// CLAUDE.md: a layout is computed, never measured, because a number picked to +// fit today's words stops fitting at the first translation. +// +// Every name is asked, not only the ones a screen draws at rest: the settings +// a chosen format declares, a chosen damage's parameters and a preset's +// arrive after the screen is built, and a column that widened for them would +// move every control on the screen the moment somebody chose a format with a +// long setting name. So the width is one number for the whole window, from +// every source a name can come from. +// +// What is asked is the same wording the screens draw, through the same +// functions, so a name renamed in the catalogue is measured under its new +// spelling without anything here changing. TestEveryNameOnAScreenFitsTheColumnOfNames +// is what says the list below is complete: it reads the names off the built +// screens and asks whether each fits. + +// labelColumn is the width of the column of names, worked out for each +// screen as it is built. Worked out again rather than kept, on purpose: a +// value kept across screens would need a lock or a once, and concurrency in +// this package is a decision the guards ask about by name. Measuring eighty +// short names costs less than drawing one of them. +func labelColumn() float32 { + return parts.WidestName(everyName()...) +} + +// EveryFieldName is every field name the window can show, from every source +// - for the guard that reads the names off the built screens and asks +// whether each is in this list. +func EveryFieldName() []string { return everyName() } + +// everyName is every field name the window can show, from every source. +func everyName() []string { + names := []string{ + text.FieldFormat(), text.FieldSize(), text.FieldCount(), text.FieldTargetID(), + text.FieldNameTemplate(), text.FieldOutputDir(), text.FieldSeed(), + text.FieldPreset(), text.FieldDamage(), text.FieldSizeRange(), + text.FieldBoundary(), text.FieldGroup(), text.FieldExpected(), + text.FieldReason(), text.FieldManifest(), + // The label switch stands under its name in the column since 2026-09-15, + // so its name is a name on a screen the column has to be wide enough + // for - it was not, while the switch carried its own words. + text.FieldLabel(), + } + for _, id := range format.IDs() { + d, err := format.Get(id) + if err != nil { + continue + } + for _, p := range d.Properties { + names = append(names, text.SettingLabel(p.Name)) + } + } + for _, id := range damage.Names() { + d, err := damage.Get(id) + if err != nil { + continue + } + for _, p := range d.Parameters { + names = append(names, text.SettingLabel(p.Name)) + } + } + for _, p := range preset.All() { + for _, param := range p.Parameters { + names = append(names, text.SettingLabel(param.Name)) + } + } + return names +} diff --git a/internal/gui/window/open.go b/internal/gui/window/open.go index f1df43fd..b31ade0e 100644 --- a/internal/gui/window/open.go +++ b/internal/gui/window/open.go @@ -7,7 +7,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/driver/desktop" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) @@ -40,13 +39,15 @@ func Open(h Host) { // about. Leaving it at the foot would have kept exactly the defect being // fixed, for the one screen somebody reaches least often and would look // 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. - 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))), + // + // The strip is ours since 2026-09-15 - see parts.Tabs for the three things + // the toolkit's could not do, the first of which is stand on the same edge + // as the words under it. + tabs := parts.NewTabs( + &parts.Tab{Text: text.TabOneTarget(), Content: gen.Object()}, + &parts.Tab{Text: text.TabPresets(), Content: pre.Object()}, + &parts.Tab{Text: text.TabRecipe(), Content: rec.Object()}, + &parts.Tab{Text: text.TabAbout(), Content: About(h)}, ) // The output directory follows whoever is looking, and that is a fix for a @@ -87,23 +88,9 @@ func Open(h Host) { // The keyboard starts on the first field of the screen somebody is looking // at, and moves with them. Owner's decision of 2026-08-25. - // - // The mark that says "the keyboard is here" is NOT drawn by this, and that - // is deliberate rather than a gap: this window draws that mark only for - // somebody using the keyboard (O90), so a focus placed by the program is - // silent until a key is pressed. Placing it quietly is what makes the first - // Tab land on the second field rather than the first. - focusFirst := func(name string) { - screen, ok := keyed[name] - if !ok { - return - } - if first := screen.FirstField(); first != nil { - parts.FocusQuietly(h.Canvas(), first) - } - } + focusFirst := firstFieldFocuser(h, keyed) - tabs.OnSelected = func(item *container.TabItem) { + tabs.OnSelected = func(item *parts.Tab, byKeyboard bool) { from, leaving := working[showing] to, arriving := working[item.Text] if leaving && arriving { @@ -118,7 +105,7 @@ func Open(h Host) { // The keyboard follows the person to the screen they moved to. Without // this it stays on a control of the screen they left, which is a Tab // that starts somewhere nobody can see. - focusFirst(item.Text) + focusFirst(item.Text, byKeyboard) } // Closing the window during a run is a cancellation and not a kill, G7. The @@ -142,12 +129,10 @@ func Open(h Host) { // The window still opens on the work rather than on the notice, which is // the owner's decision of 2026-08-05 and is now a property of which tab is // first rather than of which screen is installed. - // The strip reads with one point of focus: the tab somebody is on is in the - // accent colour and the others are quiet. Until 2026-08-20 it was the other - // way round by contrast - the chosen one was the dimmest label there. - h.SetContent(parts.QuietUnlessChosen(tabs)) - // Last, once there is something on the canvas to focus. - focusFirst(showing) + h.SetContent(parts.Tabbed(tabs)) + // Last, once there is something on the canvas to focus. Quietly: nobody + // has pressed a key yet. + focusFirst(showing, false) } // closeCleanly stops whatever is running, writes down where the files were @@ -265,6 +250,42 @@ func wireKeyboard(h Host, keyed map[string]keyboardScreen, showing *string, tabl } +// firstFieldFocuser is the way the keyboard is put on the first field of a +// named screen, quietly or visibly. +// +// The mark that says "the keyboard is here" is NOT drawn unless the keyboard +// is what brought the person there, and that is deliberate rather than a gap: +// this window draws that mark only for somebody using the keyboard (O90), so a +// focus placed by the program after a press is silent until a key is pressed. +// Placing it quietly is what makes the first Tab land on the second field +// rather than the first. Somebody who chose the screen with Enter on the strip +// IS using the keyboard, so for them the mark is drawn at once - the form has +// moved the keyboard, and saying where it went is the whole point, the same +// decision Reveal makes. +// +// A screen the map does not know - About, which has no field - is left alone. +// +// Split out of Open on 2026-09-15, when the second way of arriving took that +// function into the crowded band. The ceiling is a ratchet, so the answer is +// a split and never a higher number. +func firstFieldFocuser(h Host, keyed map[string]keyboardScreen) func(name string, visibly bool) { + return func(name string, visibly bool) { + screen, ok := keyed[name] + if !ok { + return + } + first := screen.FirstField() + if first == nil { + return + } + if visibly { + h.Canvas().Focus(first) + return + } + parts.FocusQuietly(h.Canvas(), first) + } +} + // keyboardScreen is what a screen has to offer for the keyboard to reach it. // // An interface rather than a switch over the three screens, so that a fourth @@ -300,7 +321,7 @@ func FirstScreen(h Host) fyne.CanvasObject { // program fetches nothing and sends nothing, which is what keeps untouchable // rule 8 intact - see the carve out written into it on 2026-08-18. func donateButton(h Host) fyne.CanvasObject { - return widget.NewButton(text.ButtonDonate(), func() { h.OpenLink(text.SupportURL) }) + return parts.NewButton(parts.Quiet, text.ButtonDonate(), func() { h.OpenLink(text.SupportURL) }) } // chooserFor is the output directory box with a way to browse to one. @@ -313,7 +334,7 @@ func donateButton(h Host) fyne.CanvasObject { // The box stays editable. A picker that replaces typing takes away pasting a // path somebody sent you, which is how most of these get filled in. func chooserFor(host Host, box *parts.Entry) fyne.CanvasObject { - choose := widget.NewButton(text.ButtonChoose(), func() { + choose := parts.NewButton(parts.Secondary, text.ButtonChoose(), func() { host.ChooseDirectory(func(dir string) { if dir != "" { box.SetText(dir) diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go index 1fa05dc3..807417f2 100644 --- a/internal/gui/window/preset.go +++ b/internal/gui/window/preset.go @@ -91,11 +91,10 @@ func NewPreset(host Host, links ...fyne.CanvasObject) *Preset { // chooser, because they are the answer to the question that card asks. p.body = p.tips.Over(container.NewBorder( nil, - parts.ActionBar(rail(append([]fyne.CanvasObject{donateButton(host)}, links...)...), - p.actions(), p.progress(), p.problem.Object()), + p.footer(rail(append([]fyne.CanvasObject{donateButton(host)}, links...)...)), nil, nil, (p.keepScroll(container.NewVScroll(parts.Screen( - text.HeadingPreset(), + parts.Titled(text.TabPresets(), text.SubtitlePreset()), parts.Section(text.SectionPreset(), p.fields.Add(settingPreset, text.FieldPreset(), text.HintPreset(), p.tips.Say(text.DetailPreset()), p.pick), @@ -119,9 +118,9 @@ func NewPreset(host Host, links ...fyne.CanvasObject) *Preset { p.pick.SetSelected(ids[0]) } - // Said last, once the box it reads exists. + // Said last, once every box it reads exists. p.runner.destination = p.OutDir - p.runner.sayDestination() + p.runner.refreshLine() // 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 @@ -185,7 +184,7 @@ func (p *Preset) onPresetChosen(id string) { // text and wrapped with it, and each item carried a full label's spacing - // which left more room between the items than around the whole list. if len(chosen.Catches) > 0 { - p.about.Add(parts.Heading(text.PresetCatchesHeading())) + p.about.Add(parts.Subheading(text.PresetCatchesHeading())) p.about.Add(parts.Bullets(chosen.Catches)) } p.about.Refresh() @@ -262,6 +261,13 @@ func (p *Preset) settle() ([]engine.Target, engine.Options, error) { // the cause. var bad []error + // Cleared before the expansion rather than after it, so a form that does + // not expand carries no notes at all. They stayed from the last expansion + // that worked until 2026-09-14, and the refusal for a limit of 512 B was + // followed by "no limit was given" - a note about a form nobody was + // looking at any more, read as a sentence about this one. + p.notes = nil + seed, err := wholeNumber(engine.SettingSeed, text.FieldSeed(), p.seed.Text) if err != nil { bad = append(bad, err) diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go index 2bba102e..c8f95977 100644 --- a/internal/gui/window/recipe.go +++ b/internal/gui/window/recipe.go @@ -5,7 +5,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" @@ -69,7 +68,7 @@ type Recipe struct { // addBtn lives in the bar at the foot rather than in the form, because the // form scrolls and one batch is taller than the window (O112). - addBtn *widget.Button + addBtn *parts.Button outDir *parts.Entry manifest *parts.Entry @@ -106,7 +105,7 @@ type batch struct { // // Only the chosen one is sent - see draft. That is what makes the state the // engine refuses unreachable rather than merely discouraged. - sizeWay *widget.RadioGroup + sizeWay *parts.Segments sizeBoxes map[string]fyne.CanvasObject // folded is whether this batch is put away, and it is a plain bool on the @@ -176,7 +175,7 @@ func NewRecipe(host Host, links ...fyne.CanvasObject) *Recipe { // were the only boxes on any screen where "you may leave this" and "you // must fill this in" looked the same. See the note on newBatch. r.seed.SetPlaceHolder(text.PlaceholderLeftEmpty(strconv.Itoa(recipe.DefaultSeed))) - r.label = parts.NewToggle(text.FieldLabel(), nil) + r.label = parts.NewToggle(nil) r.batchBox = parts.FieldColumn() r.outBox = parts.FieldColumn() @@ -186,15 +185,14 @@ func NewRecipe(host Host, links ...fyne.CanvasObject) *Recipe { // screen what it is does not depend on scrolling to reach - see rebuild. // It is disabled with the rest of the form while a run is going, because // adding a batch mid run would rebuild the form under the run. - r.addBtn = widget.NewButton(text.ButtonAddBatch(), r.addBatch) + r.addBtn = parts.NewButton(parts.Secondary, text.ButtonAddBatch(), r.addBatch) r.runner.alsoDisabled = append(r.runner.alsoDisabled, r.addBtn) 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()), + r.footer(rail(append([]fyne.CanvasObject{donateButton(host), parts.Divider(), r.addBtn}, links...)...)), nil, nil, - (r.keepScroll(container.NewVScroll(parts.Screen(text.HeadingRecipe(), r.batchBox, r.outBox)))), + (r.keepScroll(container.NewVScroll(parts.Screen(parts.Titled(text.TabRecipe(), text.SubtitleRecipe()), r.batchBox, r.outBox)))), )) // The format of the first batch has to be chosen for its declared settings @@ -203,9 +201,9 @@ func NewRecipe(host Host, links ...fyne.CanvasObject) *Recipe { r.batches[0].formatPick.SetSelected(format.IDs()[0]) r.rebuild() - // Said last, once the box it reads exists. + // Said last, once every box it reads exists. r.runner.destination = r.OutDir - r.runner.sayDestination() + r.runner.refreshLine() // 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 @@ -324,6 +322,9 @@ func (r *Recipe) rebuild() { r.batchBox.Refresh() r.outBox.Refresh() + // A batch added, copied or taken away changes what the form comes to, + // and none of those goes through a box somebody typed in. + r.runner.refreshLine() } // batchBlock is one batch as it appears on the screen. @@ -381,11 +382,9 @@ func (r *Recipe) batchBlock(index int, b *batch) fyne.CanvasObject { // the line under the label, so it has to exist even when there is // nothing further to add. r.tips.Say(""), b.formatPick), - r.fields.Row( - add(recipe.KeyID, text.FieldTargetID(), text.HintTargetID(), - r.tips.Say(text.DetailTargetID()), b.id), - add(recipe.KeyCount, text.FieldCount(), "", parts.NoDetail, parts.Numeric(b.count)), - ), + add(recipe.KeyID, text.FieldTargetID(), text.HintTargetID(), + r.tips.Say(text.DetailTargetID()), b.id), + add(recipe.KeyCount, text.FieldCount(), "", parts.NoDetail, parts.Numeric(b.count)), // One way of saying how big, chosen from three, since 2026-08-25. // // They were three boxes side by side with a sentence above them saying @@ -420,10 +419,10 @@ func (r *Recipe) batchBlock(index int, b *batch) fyne.CanvasObject { // makes several batches quick to write in the first place, since batches // usually differ from each other in one setting. head := []fyne.CanvasObject{ - widget.NewButton(text.ButtonDuplicateBatch(), func() { r.duplicateBatch(index) }), + parts.NewButton(parts.Secondary, text.ButtonDuplicateBatch(), func() { r.duplicateBatch(index) }), } if len(r.batches) > 1 { - head = append(head, widget.NewButton(text.ButtonRemoveBatch(), func() { r.removeBatch(index) })) + head = append(head, parts.NewButton(parts.Secondary, text.ButtonRemoveBatch(), func() { r.removeBatch(index) })) } b.fold = parts.NewFolding(text.BatchHeading(index+1), head, rows...) r.wire(b.fold, &b.folded, b.summary) @@ -458,7 +457,7 @@ func (r *Recipe) contentsBlock(index int, b *batch) fyne.CanvasObject { holds = d.Container } - addContents := widget.NewButton(text.ButtonAddContents(), func() { + addContents := parts.NewButton(parts.Secondary, text.ButtonAddContents(), func() { b.contents = append(b.contents, r.newContent()) r.rebuild() }) @@ -468,21 +467,21 @@ func (r *Recipe) contentsBlock(index int, b *batch) fyne.CanvasObject { } return addContents } - rows := []fyne.CanvasObject{parts.Heading(text.ContentsHeading())} + rows := []fyne.CanvasObject{parts.Subheading(text.ContentsHeading())} for j, c := range b.contents { at := func(setting string) string { return recipe.ContentAddress(index+1, j+1, setting) } entry := j rows = append(rows, r.fields.Row( - r.fields.Add(at(recipe.KeyFormat), text.FieldFormat(), "", parts.NoDetail, c.formatPick), - r.fields.Add(at(recipe.KeyCount), text.FieldCount(), "", parts.NoDetail, parts.Numeric(c.count)), - r.fields.Add(at(recipe.KeySize), text.FieldSize(), "", parts.NoDetail, parts.Numeric(c.size)), + r.fields.AddCell(at(recipe.KeyFormat), text.FieldFormat(), "", parts.NoDetail, c.formatPick), + r.fields.AddCell(at(recipe.KeyCount), text.FieldCount(), "", parts.NoDetail, parts.Numeric(c.count)), + r.fields.AddCell(at(recipe.KeySize), text.FieldSize(), "", parts.NoDetail, parts.Numeric(c.size)), // Not a field, so the row would hand it a whole column and the // height of a label and a control together - see parts.BesideFields // for the numbers that came off this very button. parts.BesideFields( - widget.NewButton(text.ButtonRemoveContents(), func() { r.removeContent(b, entry) })), + parts.NewButton(parts.Secondary, text.ButtonRemoveContents(), func() { r.removeContent(b, entry) })), )) } // The button to add another only where another one would be legal. The rows @@ -523,12 +522,10 @@ func (r *Recipe) outputSection() fyne.CanvasObject { return parts.Section(text.SectionOutput(), r.fields.Add(recipe.KeyOutputDir, text.FieldOutputDir(), text.HintOutputDir(), r.tips.Say(text.DetailOutputDir()), chooserFor(r.host, r.outDir)), - r.fields.Row( - r.fields.Add(recipe.KeyOutputManifest, text.FieldManifest(), text.HintManifest(), - r.tips.Say(text.DetailManifest()), r.manifest), - r.fields.Add(recipe.KeySeed, text.FieldSeed(), text.HintSeed(), - r.tips.Say(text.DetailSeed()), parts.Numeric(r.seed)), - ), + r.fields.Add(recipe.KeyOutputManifest, text.FieldManifest(), text.HintManifest(), + r.tips.Say(text.DetailManifest()), r.manifest), + r.fields.Add(recipe.KeySeed, text.FieldSeed(), text.HintSeed(), + r.tips.Say(text.DetailSeed()), parts.Numeric(r.seed)), r.fields.AddToggle(recipe.KeyDefaultsLabel, text.FieldLabel(), "", r.tips.Say(text.DetailLabel()), r.label), ) @@ -642,7 +639,7 @@ func (r *Recipe) settle() ([]engine.Target, engine.Options, error) { if err != nil { return nil, none, err } - rec, err := recipe.Parse(src, text.HeadingRecipe()) + rec, err := recipe.Parse(src, text.TabRecipe()) if err != nil { return nil, none, err } diff --git a/internal/gui/window/recipefolds.go b/internal/gui/window/recipefolds.go index d5a0f43a..a438dd68 100644 --- a/internal/gui/window/recipefolds.go +++ b/internal/gui/window/recipefolds.go @@ -50,24 +50,15 @@ func (r *Recipe) declaredSettings(b *batch, at func(string) string) fyne.CanvasO } var out []fyne.CanvasObject - // The same pairing the single batch screen uses, through the same code. - pair := parts.PairNarrow(r.fields.Row) for i, f := range b.props { // Shaped here as well as on the single batch screen. The two draw the // same declarations through different code, and only one of them was // given the width on the first try - which is how a difference between // two surfaces starts. - object := r.fields.Add(at(recipe.KeyProperties+"."+f.Name), text.SettingLabel(f.Name), + out = append(out, r.fields.Add(at(recipe.KeyProperties+"."+f.Name), text.SettingLabel(f.Name), parts.PropertyDetail(b.declared[i]), r.tips.Say(text.SettingKey(f.Name)), - parts.ShapedFor(b.declared[i], f.Control)) - if parts.Narrow(b.declared[i]) { - pair.Add(object) - continue - } - out = append(out, pair.Rest()...) - out = append(out, object) + parts.ShapedFor(b.declared[i], f.Control))) } - out = append(out, pair.Rest()...) // A rule binding two settings belongs beside them. Two number boxes drawn // from a range alone would offer a pair the run then refuses. if d, err := format.Get(b.formatPick.Selected); err == nil { @@ -96,12 +87,10 @@ func (r *Recipe) manifestNotes(b *batch, add addField) fyne.CanvasObject { parts.Note(text.NoteManifestOnly()), add(recipe.KeyGroup, text.FieldGroup(), text.HintGroup(), r.tips.Say(text.DetailGroup()), b.group), - r.fields.Row( - add(recipe.KeyExpected, text.FieldExpected(), text.HintExpected(), - r.tips.Say(text.DetailExpected()), b.expected), - add(recipe.KeyExpectedReason, text.FieldReason(), text.HintReason(), - r.tips.Say(text.DetailReason()), b.reason), - ), + add(recipe.KeyExpected, text.FieldExpected(), text.HintExpected(), + r.tips.Say(text.DetailExpected()), b.expected), + add(recipe.KeyExpectedReason, text.FieldReason(), text.HintReason(), + r.tips.Say(text.DetailReason()), b.reason), ) r.wire(b.notes, &b.notesFolded, func() string { return b.notesSaid() }) return b.notes.Object() diff --git a/internal/gui/window/recipesize.go b/internal/gui/window/recipesize.go index c89322ab..997369fd 100644 --- a/internal/gui/window/recipesize.go +++ b/internal/gui/window/recipesize.go @@ -8,8 +8,6 @@ package window import ( "fyne.io/fyne/v2" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" @@ -35,16 +33,13 @@ func (r *Recipe) sizeWayFor(b *batch, at func(string) string, // "10mb" and the three used to share one row between them, so a box across // the whole form would be the only wide box holding four characters - and // the eye reads width as how much is expected. - half := func(field fyne.CanvasObject) fyne.CanvasObject { - return r.fields.Row(field, layout.NewSpacer()) - } b.sizeBoxes = map[string]fyne.CanvasObject{ - recipe.KeySize: half(add(recipe.KeySize, text.FieldSize(), text.HintSizeExact(), - r.tips.Say(text.DetailSize()), b.size)), - recipe.KeySizeRange: half(add(recipe.KeySizeRange, text.FieldSizeRange(), text.HintSizeRange(), - r.tips.Say(text.DetailSizeRange()), b.sizeRange)), - recipe.KeyBoundary: half(add(recipe.KeyBoundary, text.FieldBoundary(), text.HintBoundary(), - r.tips.Say(text.DetailBoundary()), b.boundary)), + recipe.KeySize: add(recipe.KeySize, text.FieldSize(), text.HintSizeExact(), + r.tips.Say(text.DetailSize()), parts.Numeric(b.size)), + recipe.KeySizeRange: add(recipe.KeySizeRange, text.FieldSizeRange(), text.HintSizeRange(), + r.tips.Say(text.DetailSizeRange()), parts.Numeric(b.sizeRange)), + recipe.KeyBoundary: add(recipe.KeyBoundary, text.FieldBoundary(), text.HintBoundary(), + r.tips.Say(text.DetailBoundary()), parts.Numeric(b.boundary)), } names := sizeWayNames() @@ -70,7 +65,7 @@ func (r *Recipe) sizeWayFor(b *batch, at func(string) string, for _, key := range sizeWayKeys() { boxes = append(boxes, b.sizeBoxes[key]) } - return parts.Stacked(append([]fyne.CanvasObject{b.sizeWay}, boxes...)...) + return parts.FieldColumn(append([]fyne.CanvasObject{r.fields.Unlabelled(b.sizeWay)}, boxes...)...) } // newSizeWaySwitch is the control itself, built with the batch rather than with @@ -80,15 +75,13 @@ func (r *Recipe) sizeWayFor(b *batch, at func(string) string, // only existed once the screen had been laid out was a switch a copy could not // read - so the copy took the values of the other two ways and then showed the // first one. Found by the guard on its first run. -func newSizeWaySwitch() *widget.RadioGroup { - names := sizeWayNames() - group := widget.NewRadioGroup(names, nil) - group.Horizontal = true - // Required, so there is no fourth state where nothing is chosen and no box - // is shown. One of the three is always the answer. - group.Required = true - group.Selected = names[0] - return group +// +// A segmented switch since 2026-09-15, in place of three radio circles. One of +// the three is always chosen - there is no fourth state where nothing is - so +// nothing here has to ask for "required" the way the radio did: the switch +// opens on the first way and never has none. +func newSizeWaySwitch() *parts.Segments { + return parts.NewSegments(sizeWayNames(), nil) } // The three ways, in one order, named once. The window shows the words and the diff --git a/internal/gui/window/run.go b/internal/gui/window/run.go index f66f6c61..4d2cf1a9 100644 --- a/internal/gui/window/run.go +++ b/internal/gui/window/run.go @@ -77,9 +77,9 @@ type settler func() ([]engine.Target, engine.Options, error) type runner struct { settle settler - previewBtn *widget.Button - generateBtn *widget.Button - cancelBtn *widget.Button + previewBtn *parts.Button + generateBtn *parts.Button + cancelBtn *parts.Button // openBtn shows the directory a finished run wrote into. // // It appears when there is something to open and goes away the moment the @@ -90,7 +90,7 @@ type runner struct { // In the row of actions rather than beside the output box, so the bar keeps // its height and the form does not move - the property // TestTheFormDoesNotMoveWhenARunStarts holds. - openBtn *widget.Button + openBtn *parts.Button // wroteInto is the directory of the run that just finished, kept because // the box on the screen can be edited afterwards and the button has to open // where the files ACTUALLY went. @@ -203,29 +203,19 @@ type runner struct { // destination is where this screen would write, asked for rather than // stored, because the box it comes from is edited after this is wired. - // Nil on a screen that has no such box, and the status line simply stays - // empty there. + // Read for the line even when the rest of the form does not settle, so + // the one box that decides where somebody else's disk gets written to is + // on the line whatever the other boxes say. destination func() string - // resting is true while nothing pressed has spoken. The status line - // carries the destination until then and whatever was pressed afterwards. - // - // One way only, and that is worth knowing before relying on it: nothing - // sets it back. So the destination line is live until the first press and - // never again, and the call to sayDestination in the live check is doing - // something only for that first stretch. - // - // It was tempting to make typing restore it, and that is wrong for a - // measured reason - see sayDestination. A refusal puts its sentence on this - // same line, and the live check runs on the next keystroke, so restoring - // here would wipe the sentence somebody had just been given. - // - // The consequence left standing: after a preview, editing a field leaves a - // cost on the line that was worked out for the old values. The answer to - // that is a summary that is always live rather than a sentence that has to - // be defended - the report calls it the permanent line in the action bar, - // and this whole mechanism goes when that lands. - resting bool + // line is what the status line says while nothing pressed has spoken: + // what the form comes to, live. It replaced a line that carried the + // destination until the first press and never again - a one way flag + // called resting, which meant that after a preview, editing a field left + // a cost on the line worked out for the old values. The line is worked + // out from the form on every change, so there is nothing on it to defend: + // a change of the form puts the summary back over whatever a press said. + line *runLine // notes is what settling had to say out loud, set by settle rather than // worked out here. Silence is banned: a set built around a limit we @@ -243,192 +233,6 @@ type runner struct { // and counting them from the registry alone could never find one. func (r *runner) Fields() *parts.Fields { return r.fields } -// refuse shows a refusal, under the field it is about where it names one. -// -// The choice is the engine's rather than the window's. It sets the setting on -// the error, and this looks it up - the alternative was matching the wording -// here, which is a second copy of rules the engine owns and the copy that -// drifts. -func (r *runner) refuse(err error) { - var loose []string - // The first box a refusal lands on, so the form can be brought to it. A - // refusal that marks a box the person cannot see reads as a button that did - // nothing - see parts.Reveal and O107. - first := "" - for _, one := range spread(err) { - // An interface rather than a case per error type, so a screen shown a - // kind of refusal nobody thought about here still gets it placed. The - // engine, the format registry and the preset package all answer this - // and none of them had to be imported for the question to be asked. - var about interface{ AboutSetting() string } - if errors.As(one, &about) && about.AboutSetting() != "" { - if where := r.placeOf(about.AboutSetting()); r.fields.Mark(where, one) { - if first == "" { - first = where - } - continue - } - } - // About the run rather than about one box, or about a setting this - // screen does not draw. The foot of the form is where those belong. - loose = append(loose, one.Error()) - } - if len(loose) > 0 { - r.problem.Say(strings.Join(loose, "\n\n")) - } - if first == "" { - return - } - // Every problem went onto a box, so the foot of the form says nothing and - // the only sign the press was even received is a red box that may be off - // the screen. Both halves of the answer are here: a sentence where the - // button is, and the form moved to the first box that needs attention. - if len(loose) == 0 { - r.say(text.RefusedBeforeWriting()) - } - // Anything folded away that a refusal is about is opened before the form is - // moved, because a box inside a fold cannot be shown by scrolling to it - - // and a screen that refuses to run while marking nothing anybody can see - // reads as a button that did nothing. This is what keeps the objection of - // 2026-08-18 answered rather than dodged: refusals about a batch that is - // not on the screen were the reason a list with one batch open at a time - // was rejected. - if r.unfold != nil { - for _, marked := range r.fields.Marked() { - r.unfold(marked) - } - } - if field := r.fields.Lookup(first); field != nil { - parts.Reveal(r.scroll, field.Control) - } -} - -// spread opens a refusal that carries several into the ones it carries. -// -// The window used to mark ONE box however many were wrong, because everything -// between the screen and the field registry was singular by type: settle -// returned at the first bad box, refuse took one error, Mark marked one field. -// Reported from the screen on 2026-08-18, and it is the window narrowing what -// the layer below already does - RC7 has the engine refuse a recipe with every -// problem it has rather than the first, on the grounds that fixing a file one -// error per run is the cheapest way to make somebody stop using the tool. The -// same argument applies to a form. -// -// errors.Join is what carries them, so nothing here has to be a new error type -// and a single refusal still arrives as itself. Walked rather than flattened -// once, because a join can hold a join - the preset screen collects its own and -// hands on whatever the recipe parser gave it. -func spread(err error) []error { - if err == nil { - return nil - } - if joined, several := err.(interface{ Unwrap() []error }); several { - var out []error - for _, one := range joined.Unwrap() { - out = append(out, spread(one)...) - } - return out - } - // A join under a single wrapper is still a join. The type assertion above - // only sees the outermost layer, so one fmt.Errorf("%s: %w", ...) anywhere - // on the way here turns five marked boxes back into one paragraph at the - // foot of the form - which is the defect this function exists to prevent. - // - // Nothing wraps a join today: settle returns errors.Join straight out on - // both screens. So this is not fixing anything that is broken, it is - // removing the way it comes back - and it comes back silently, because the - // guards for marking all build their errors with a bare join. - if inner := errors.Unwrap(err); inner != nil { - if _, several := inner.(interface{ Unwrap() []error }); several { - return spread(inner) - } - } - return []error{err} -} - -// recheck says what is wrong with the screen while somebody is still typing. -// -// Asked for from the screen on 2026-08-18: a bad value should turn its box red -// and give the reason straight away, rather than waiting for a button. It runs -// the SAME settle the buttons run, which is the whole of the design - there is -// no second set of rules to write, nothing to keep in step, and a box that can -// be refused is refused here because it is refused there. A field nobody has -// added a rule for needs no rule added. -// -// Two things it does not do. It leaves the foot of the form alone, because a -// complaint about the run rather than about a box is not something to shout -// while somebody is mid-word. And it says nothing about an empty box - see -// Fields.Blank. -// placeOf is where a refusal about one setting belongs on this screen, which is -// the setting itself unless the screen said otherwise - see readdress. -func (r *runner) placeOf(setting string) string { - if r.readdress == nil { - return setting - } - return r.readdress(setting) -} - -// withoutTheTarget is the readdress a screen showing exactly one target uses. -// -// The engine addresses a refusal about a target by its position, because a -// screen with twenty batches cannot place one otherwise. A screen with one -// batch draws its boxes under the bare key - measured 2026-08-25, the single -// batch screen registers size, name and width where the batch screen registers -// targets[1].size, targets[1].name and targets[1].properties.width. So the -// position is the part to drop, and the last segment is what is left. -// -// Settings of the run itself are handed back untouched. output.dir names no -// target, and taking its last segment would leave "dir", which is a box -// nothing draws. -func withoutTheTarget(address string) string { - if !core.AddressNamesATarget(address) { - return address - } - return core.LastSettingSegment(address) -} - -func (r *runner) recheck(setting string) { - // Nothing to check against yet, during the screen being built. - if r.settle == nil { - return - } - // A run owns the screen while it lasts. Its progress and its refusals are - // not to be wiped by a keystroke. - if r.running { - return - } - // Typing in the destination box moves the destination, and the line saying - // where the files go is worth nothing if it names the old one. - // - // This only reaches the line while nothing pressed has spoken - see - // resting. Said here because the call reads as though it always does. - r.sayDestination() - // Only this box, in both directions. What the other boxes were told is - // about values nobody has just changed, and it is still true - including - // 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) - 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 { - r.fields.Mark(setting, one) - return - } - } -} - -// 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. -func (r *runner) clearProblems() { - r.problem.Clear() - r.fields.ClearAll() -} - // say puts a sentence on the status line, above whatever settling had to say. // // A line with nothing on it takes no room, the same rule the error area @@ -445,19 +249,27 @@ func (r *runner) clearProblems() { // a finished run showed "no limit was given, so this set is built around // 10mb..." with "7 files written." out of sight below it. func (r *runner) say(lines ...string) { - said := strings.Join(append(append([]string{}, lines...), r.notes...), "\n") + showOn(r.status, strings.Join(append(append([]string{}, lines...), r.notes...), "\n")) +} + +// showOn puts words on the status line without the notes, or takes the +// line away when handed nothing. The line at rest goes through here: what +// settling had to say out loud belongs with a press, and at rest it made a +// second line that scrolled inside the room kept for one. A function rather +// than a method, because the runner stands at its ceiling of methods. +func showOn(status *widget.Label, said string) { if said == "" { - r.sayDestination() + status.SetText("") + status.Hide() return } - r.resting = false // Back to the ordinary colour unless the caller says otherwise. Anything // coloured is coloured about one run, so it has to be cleared by the next // thing said - otherwise a green line from a finished run stays green over // the progress of the one after it. - r.status.Importance = widget.MediumImportance - r.status.SetText(said) - r.status.Show() + status.Importance = widget.MediumImportance + status.SetText(said) + status.Show() } // toneOfOutcome colours what a finished run said. @@ -501,45 +313,35 @@ func (r *runner) toneOfOutcome(res *engine.Result, runErr error) { r.status.Refresh() } -// sayDestination puts where the files will go on the status line, while there -// is nothing louder to put there. +// refreshLine works out what the form comes to and puts it on the line. // -// The line is kept clear for a run whether or not there is one, so at rest it -// was empty space in the one part of the screen that never scrolls away - and -// the destination is the one field that is off the bottom of every form when -// the window opens. It is also the only field that decides where somebody -// else's disk gets written to, so the cost of not seeing it is not symmetric -// with the cost of not seeing the others (O102). +// From settle rather than from a plan: settle is what the form parses to and +// costs nothing, and planning is what the engine does with it and can cost +// seconds (see onPreview). A form that does not settle falls back to naming +// the destination alone, which is read off its own box because it is the one +// fact worth having whatever the other boxes say. // -// It gives way to anything a run has to say and does not come back, because a -// preview and an outcome are answers to something that was just pressed. A -// preview names the same directory in its own sentence anyway. -func (r *runner) sayDestination() { - // Once something louder has spoken, this says nothing at all - including - // not clearing what was said. Clearing was the first version and it was - // wrong in a way only the stored screens caught: a refusal put its sentence - // at the foot, the next keystroke ran the live check, and the check called - // this, which wiped the sentence somebody had just been given. - if !r.resting { +// Not while a run owns the screen: its progress is not to be overwritten by +// a summary, and the form is frozen then anyway. +func (r *runner) refreshLine() { + if r.settle == nil || r.running { return } - if r.destination == nil { - r.status.SetText("") - r.status.Hide() - return + dir := "" + if r.destination != nil { + dir = r.destination() } - dir := strings.TrimSpace(r.destination()) - if dir == "" { - r.status.SetText("") - r.status.Hide() + targets, opt, err := r.settle() + if err != nil { + showOn(r.status, r.line.fallback(dir)) return } - r.status.SetText(text.WritingTo(dir)) - r.status.Show() + showOn(r.status, r.line.said(summarise(targets), opt.OutDir)) } func newRunner() *runner { - r := &runner{fields: parts.NewFields(), resting: true} + r := &runner{fields: parts.NewFields(), line: &runLine{}} + r.fields.LabelColumn(labelColumn()) // Wired once, here, so that a field added later is covered without anybody // remembering to wire it. See Fields.WhenTypedIn and recheck. r.fields.WhenTypedIn(r.recheck) @@ -560,9 +362,8 @@ func newRunner() *runner { r.status.Hide() r.problem = parts.NewErrorArea() - r.previewBtn = widget.NewButton(text.ButtonPreview(), r.onPreview) - r.generateBtn = widget.NewButton(text.ButtonGenerate(), r.onGenerate) - r.generateBtn.Importance = widget.HighImportance + r.previewBtn = parts.NewButton(parts.Secondary, text.ButtonPreview(), r.onPreview) + r.generateBtn = parts.NewButton(parts.Primary, text.ButtonGenerate(), r.onGenerate) // Three ranks, so the eye lands on the one that does the work: Generate // filled, Preview plain beside it, Cancel receding until there is something // to cancel. They were three identical buttons in a row, which is a choice @@ -578,11 +379,11 @@ 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.NewButton(parts.Secondary, text.ButtonCancel(), r.onCancel) r.cancelBtn.Disable() r.cancelBtn.Hide() - r.openBtn = widget.NewButton(text.ButtonOpenFolder(), func() { + r.openBtn = parts.NewButton(parts.Secondary, text.ButtonOpenFolder(), func() { if r.wroteInto != "" && r.openFolder != nil { r.openFolder(r.wroteInto) } @@ -597,10 +398,21 @@ func rail(items ...fyne.CanvasObject) fyne.CanvasObject { return container.NewHBox(items...) } -// progress is where a run says what it is doing, and it keeps its height +// roomToSpeak is where a run says what it is doing, and it keeps its height // whether or not there is a run. See parts.WithRoomForARun for why. -func (r *runner) progress() fyne.CanvasObject { - return parts.WithRoomForARun(container.NewVBox(r.bar, r.status)) +// +// The refusal about the run as a whole is in here too since 2026-09-14. It +// stood outside the reserved room until then, so a refusal grew the bar and +// moved the form - the same movement the room exists to prevent, allowed for +// one kind of message because it was the kind nobody had measured. +func roomToSpeak(bar *parts.Progress, status *widget.Label, problem *parts.ErrorArea) fyne.CanvasObject { + return parts.WithRoomForARun(container.NewVBox(bar, parts.Flush(status), problem.Object())) +} + +// footer is the bar at the foot of a screen: the buttons, and under them the +// room a run speaks in - which at rest carries what the form comes to. +func (r *runner) footer(rail fyne.CanvasObject) fyne.CanvasObject { + return parts.ActionBar(rail, r.actions(), roomToSpeak(r.bar, r.status, r.problem)) } // onPreview says what the run would cost and writes nothing. @@ -664,30 +476,63 @@ func (r *runner) onPreview() { // Do rather than DoAndWait, for the same reason startRun gives: the // interface thread must never be left waiting on a worker. r.holdBeforeFinishing() - fyne.Do(func() { r.previewFinished(nil, nil, opt, planErr) }) + fyne.Do(func() { r.previewFinished(nil, nil, opt, diskRoom{}, planErr) }) close(done) return } res, runErr := engine.Run(ctx, planned, opt) + // The disk is asked here, on the worker, for the reason the whole + // preview is: on a network share the answer can take as long as the + // share takes, and the interface thread is the one thing that must + // not wait for it. It used to be asked after crossing back. + room := roomOn(opt.OutDir) r.holdBeforeFinishing() - fyne.Do(func() { r.previewFinished(res, planned, opt, runErr) }) + fyne.Do(func() { r.previewFinished(res, planned, opt, room, runErr) }) close(done) }() } +// diskRoom is what a worker found out about the room under a directory. +// known is false when the disk could not be asked, and then nothing is said +// rather than a number invented - a disk we cannot measure is not a full one. +type diskRoom struct { + free int64 + known bool +} + +// roomOn asks the disk under a directory how much room it has. Disk work, +// so it belongs on a worker and never on the interface thread. +func roomOn(dir string) diskRoom { + if dir == "" { + return diskRoom{} + } + free, err := core.AvailableBytes(dir) + if err != nil { + return diskRoom{} + } + return diskRoom{free: free, known: true} +} + // previewFinished is the end of a preview, back on the interface thread. // // The result is carried across as well as the plan, and that is what lets a // preview warn about a record too big to read back. A dry run builds the whole // document - see manifestReachNote - so the answer is there for the asking // rather than something the window would have to work out for itself. -func (r *runner) previewFinished(res *engine.Result, planned []engine.PlannedFile, opt engine.Options, runErr error) { +func (r *runner) previewFinished(res *engine.Result, planned []engine.PlannedFile, opt engine.Options, room diskRoom, runErr error) { r.setBusy(false, false) if runErr != nil { r.refuse(runErr) return } - r.say(append([]string{previewText(planned, opt.OutDir)}, manifestReachNote(res)...)...) + // The line goes exact: the plan has drawn every size a range left open, + // and the disk has been asked. What it adds is the one thing the numbers + // cannot say, which is that none of it exists yet. + if room.known { + r.line.measured(opt.OutDir, room.free) + } + said := r.line.said(exactly(planned), opt.OutDir) + text.AndNothingWrittenYet() + r.say(append([]string{said}, manifestReachNote(res)...)...) } // formatsOf is what kinds of file the run would produce, each named once. @@ -815,11 +660,14 @@ func (r *runner) startRun(targets []engine.Target, opt engine.Options) { // it is disk work and the interface thread is the one thing that must // not wait on a disk. saveErr := saveManifest(res, opt) + // The room left on the disk is the room left AFTER the files, which + // is not the number a preview measured before them. + room := roomOn(opt.OutDir) // Do rather than DoAndWait. The interface thread may already be inside // stop, waiting on the channel closed below, and a worker waiting for // that thread to run something would be both of them waiting. r.holdBeforeFinishing() - fyne.Do(func() { r.runFinished(res, runErr, saveErr) }) + fyne.Do(func() { r.runFinished(res, runErr, saveErr, room) }) close(done) }() } @@ -828,8 +676,11 @@ func (r *runner) startRun(targets []engine.Target, opt engine.Options) { // // Note what it does not do: clear stop. That is deliberate and the reason is at // the declaration of the field. -func (r *runner) runFinished(res *engine.Result, runErr, saveErr error) { +func (r *runner) runFinished(res *engine.Result, runErr, saveErr error, room diskRoom) { r.setRunning(false) + if room.known && r.wroteInto != "" { + r.line.measured(r.wroteInto, room.free) + } switch { case runErr != nil: diff --git a/internal/gui/window/runactions.go b/internal/gui/window/runactions.go index cef16ccd..34f2b340 100644 --- a/internal/gui/window/runactions.go +++ b/internal/gui/window/runactions.go @@ -4,9 +4,9 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" "github.com/donislawdev/TestingFilesGenerator/internal/engine" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" ) // The buttons that start, stop and follow a run. @@ -54,7 +54,7 @@ func (r *runner) PressCancel() { pressIfLive(r.cancelBtn) } // Hidden as well as disabled, because Cancel is BOTH while nothing is running - // and a shortcut that worked on a button nobody can see would be a way to reach // a state the screen does not offer. -func pressIfLive(b *widget.Button) { +func pressIfLive(b *parts.Button) { if b == nil || b.Disabled() || !b.Visible() || b.OnTapped == nil { return } diff --git a/internal/gui/window/runline.go b/internal/gui/window/runline.go new file mode 100644 index 00000000..b5f41cf4 --- /dev/null +++ b/internal/gui/window/runline.go @@ -0,0 +1,71 @@ +package window + +import ( + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// The line at the foot of a form that says what the form comes to. +// +// It answers G6 before anything is pressed: how many files, how many bytes, +// what kind and where, read off the form as it is typed. The bar used to say +// one of those four - the directory - on a line that gave way to the first +// thing a run said and never came back (see the runner's history of +// resting). The line is worked out again on every change of the form, so +// what a run says stands in place of a summary that is still true rather +// than in place of one that was. +// +// A line and not a panel of rows. Rows were built first, on 2026-09-14, and +// the owner's verdict on the render was that four short values in a wide +// strip read as a panel with nothing in it. The same four facts fit on the +// line the bar already keeps clear for a run, and cost the form no height. + +// runLine turns what the form comes to into the sentence under the buttons, +// and remembers what a worker found out about the disk. +type runLine struct { + // freeIn is the directory a preview or a run measured, and free is the + // room it found there. Said only while the directory on the form is + // still that one: a number measured for one disk says nothing about the + // disk somebody has typed since. + freeIn string + free int64 + freeKnown bool +} + +// said is the line for a form that settles. +func (l *runLine) said(sum summary, dir string) string { + return text.RunLine(int(sum.files), sum.totalText(), sum.formats, l.where(dir)) +} + +// fallback is the line for a form that does not settle: where the files +// would go, which is read off its own box and is true whatever the other +// boxes say. Empty when even that is not known, so the line takes no room. +func (l *runLine) fallback(dir string) string { + dir = strings.TrimSpace(dir) + if dir == "" { + return "" + } + return text.WritingTo(l.where(dir)) +} + +// where is the directory, with the room left on its disk when that has +// been measured for this very directory. +func (l *runLine) where(dir string) string { + dir = strings.TrimSpace(dir) + if dir != "" && l.freeKnown && dir == l.freeIn { + return text.DirectoryWithFreeSpace(dir, core.HumanBytes(l.free)) + } + return dir +} + +// measured records what a preview or a run found out about the disk under a +// directory. Recorded rather than read here, because reading it is disk work +// and this runs on the interface thread - the worker that went to the disk +// hands the answer over. +func (l *runLine) measured(dir string, free int64) { + l.freeIn = strings.TrimSpace(dir) + l.free = free + l.freeKnown = true +} diff --git a/internal/gui/window/runrefuse.go b/internal/gui/window/runrefuse.go new file mode 100644 index 00000000..6edac3e7 --- /dev/null +++ b/internal/gui/window/runrefuse.go @@ -0,0 +1,206 @@ +package window + +import ( + "errors" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// Where a refusal lands: under the box it is about, or at the foot of the +// form when it is about the run as a whole. +// +// Split out of run.go on 2026-09-14, when the strip at the foot of the form +// took that file past its ceiling. The ceiling is a ratchet, so the answer is +// a split and never a higher number. The split is by subject, and this +// subject was chosen over the preview because it holds no goroutine and no +// channel: the files that may be concurrent are declared by name, in the +// guards and in the workflow that runs the race detector, and moving a +// goroutine into a new file is a change to that list. + +// refuse shows a refusal, under the field it is about where it names one. +// +// The choice is the engine's rather than the window's. It sets the setting on +// the error, and this looks it up - the alternative was matching the wording +// here, which is a second copy of rules the engine owns and the copy that +// drifts. +func (r *runner) refuse(err error) { + var loose []string + // The first box a refusal lands on, so the form can be brought to it. A + // refusal that marks a box the person cannot see reads as a button that did + // nothing - see parts.Reveal and O107. + first := "" + for _, one := range spread(err) { + // An interface rather than a case per error type, so a screen shown a + // kind of refusal nobody thought about here still gets it placed. The + // engine, the format registry and the preset package all answer this + // and none of them had to be imported for the question to be asked. + var about interface{ AboutSetting() string } + if errors.As(one, &about) && about.AboutSetting() != "" { + if where := r.placeOf(about.AboutSetting()); r.fields.Mark(where, one) { + if first == "" { + first = where + } + continue + } + } + // About the run rather than about one box, or about a setting this + // screen does not draw. The foot of the form is where those belong. + loose = append(loose, one.Error()) + } + if len(loose) > 0 { + r.problem.Say(strings.Join(loose, "\n\n")) + } + if first == "" { + return + } + // Every problem went onto a box, so the foot of the form says nothing and + // the only sign the press was even received is a red box that may be off + // the screen. Both halves of the answer are here: a sentence where the + // button is, and the form moved to the first box that needs attention. + if len(loose) == 0 { + r.say(text.RefusedBeforeWriting()) + } + // Anything folded away that a refusal is about is opened before the form is + // moved, because a box inside a fold cannot be shown by scrolling to it - + // and a screen that refuses to run while marking nothing anybody can see + // reads as a button that did nothing. This is what keeps the objection of + // 2026-08-18 answered rather than dodged: refusals about a batch that is + // not on the screen were the reason a list with one batch open at a time + // was rejected. + if r.unfold != nil { + for _, marked := range r.fields.Marked() { + r.unfold(marked) + } + } + if field := r.fields.Lookup(first); field != nil { + parts.Reveal(r.scroll, field.Control) + } +} + +// spread opens a refusal that carries several into the ones it carries. +// +// The window used to mark ONE box however many were wrong, because everything +// between the screen and the field registry was singular by type: settle +// returned at the first bad box, refuse took one error, Mark marked one field. +// Reported from the screen on 2026-08-18, and it is the window narrowing what +// the layer below already does - RC7 has the engine refuse a recipe with every +// problem it has rather than the first, on the grounds that fixing a file one +// error per run is the cheapest way to make somebody stop using the tool. The +// same argument applies to a form. +// +// errors.Join is what carries them, so nothing here has to be a new error type +// and a single refusal still arrives as itself. Walked rather than flattened +// once, because a join can hold a join - the preset screen collects its own and +// hands on whatever the recipe parser gave it. +func spread(err error) []error { + if err == nil { + return nil + } + if joined, several := err.(interface{ Unwrap() []error }); several { + var out []error + for _, one := range joined.Unwrap() { + out = append(out, spread(one)...) + } + return out + } + // A join under a single wrapper is still a join. The type assertion above + // only sees the outermost layer, so one fmt.Errorf("%s: %w", ...) anywhere + // on the way here turns five marked boxes back into one paragraph at the + // foot of the form - which is the defect this function exists to prevent. + // + // Nothing wraps a join today: settle returns errors.Join straight out on + // both screens. So this is not fixing anything that is broken, it is + // removing the way it comes back - and it comes back silently, because the + // guards for marking all build their errors with a bare join. + if inner := errors.Unwrap(err); inner != nil { + if _, several := inner.(interface{ Unwrap() []error }); several { + return spread(inner) + } + } + return []error{err} +} + +// recheck says what is wrong with the screen while somebody is still typing. +// +// Asked for from the screen on 2026-08-18: a bad value should turn its box red +// and give the reason straight away, rather than waiting for a button. It runs +// the SAME settle the buttons run, which is the whole of the design - there is +// no second set of rules to write, nothing to keep in step, and a box that can +// be refused is refused here because it is refused there. A field nobody has +// added a rule for needs no rule added. +// +// Two things it does not do. It leaves the foot of the form alone, because a +// complaint about the run rather than about a box is not something to shout +// while somebody is mid-word. And it says nothing about an empty box - see +// Fields.Blank. +// placeOf is where a refusal about one setting belongs on this screen, which is +// the setting itself unless the screen said otherwise - see readdress. +func (r *runner) placeOf(setting string) string { + if r.readdress == nil { + return setting + } + return r.readdress(setting) +} + +// withoutTheTarget is the readdress a screen showing exactly one target uses. +// +// The engine addresses a refusal about a target by its position, because a +// screen with twenty batches cannot place one otherwise. A screen with one +// batch draws its boxes under the bare key - measured 2026-08-25, the single +// batch screen registers size, name and width where the batch screen registers +// targets[1].size, targets[1].name and targets[1].properties.width. So the +// position is the part to drop, and the last segment is what is left. +// +// Settings of the run itself are handed back untouched. output.dir names no +// target, and taking its last segment would leave "dir", which is a box +// nothing draws. +func withoutTheTarget(address string) string { + if !core.AddressNamesATarget(address) { + return address + } + return core.LastSettingSegment(address) +} + +func (r *runner) recheck(setting string) { + // Nothing to check against yet, during the screen being built. + if r.settle == nil { + return + } + // A run owns the screen while it lasts. Its progress and its refusals are + // not to be wiped by a keystroke. + if r.running { + return + } + // Whatever changed, the line says what the form comes to now - over an + // outcome or a preview, which described a form that no longer exists. + // Before the early return below, because a box emptied to be retyped + // changes the count as surely as a box filled in. + r.refreshLine() + // Only this box, in both directions. What the other boxes were told is + // about values nobody has just changed, and it is still true - including + // 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) + 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 { + r.fields.Mark(setting, one) + return + } + } +} + +// 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. +func (r *runner) clearProblems() { + r.problem.Clear() + r.fields.ClearAll() +} diff --git a/internal/gui/window/runreport.go b/internal/gui/window/runreport.go index baecbd47..6b2330cb 100644 --- a/internal/gui/window/runreport.go +++ b/internal/gui/window/runreport.go @@ -18,20 +18,6 @@ import ( // number. The split is by subject: everything here turns what the engine // reports into the sentence under the buttons, and nothing here drives a run. -// previewText is the cost, before anything exists. G6: how many files, what -// kind, how many bytes, and how much room there is for them. -func previewText(planned []engine.PlannedFile, outDir string) string { - total := engine.TotalBytes(planned) - line := text.PreviewCost(len(planned), formatsOf(planned), core.HumanBytes(total)) - - // A disk we cannot measure is not the same as a disk that is full, so a - // failure to read it says nothing rather than inventing a number. - if free, err := core.AvailableBytes(outDir); err == nil { - line += text.PreviewFreeSpace(outDir, core.HumanBytes(free)) - } - return line -} - // manifestReachNote is the window's half of the warning the command line prints // before the first byte. // diff --git a/internal/gui/window/runsummary.go b/internal/gui/window/runsummary.go new file mode 100644 index 00000000..8788edb0 --- /dev/null +++ b/internal/gui/window/runsummary.go @@ -0,0 +1,162 @@ +package window + +import ( + "math" + "sort" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/engine" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// What a form comes to, worked out from the targets it settles into and from +// nothing else - no disk, no planning. +// +// Planning is the wrong tool for a number that changes on every keystroke: +// planning a picture encodes it, and two thousand PNGs cost 16 to 23 seconds +// of it (see onPreview). The arithmetic here costs what adding a few numbers +// costs, so it can be redone every time a box changes. What it cannot know +// it says it cannot know, rather than guessing: a size drawn from a range is +// drawn when the run is planned, and a container sized by its contents is +// sized when it is built. +// +// A type that knows nothing about the toolkit, on purpose - GUI rule 15 of +// CLAUDE.md. It can be asked without a window, and the guard that asks it +// does exactly that. + +// summary is what a set of targets adds up to. +type summary struct { + // files is how many files the targets ask for, across every batch. + files int64 + // least and most bound the bytes the run will occupy. Equal when every + // target states its sizes, apart when one draws them from a range. + least, most int64 + // certainty says how far the two bounds can be trusted. + certainty certainty + // formats is every format the targets name, each once, sorted - the same + // rule formatsOf applies to a plan, and for the same reason: the order + // targets were typed in is not something to show as if it meant anything. + formats []string +} + +// certainty is how much a summary can say about its total. +type certainty int + +const ( + // stated: every size is on the form, so the total is exact. + stated certainty = iota + // drawn: at least one target draws its sizes from a range, so the total + // is between two numbers until the run is planned. + drawn + // fromContents: at least one container is sized by what it holds, which + // nothing knows before it is built. + fromContents + // beyondCounting: the sizes add up to more than a number can hold. The + // run will be refused for it (core.AddSizes) - the strip does not invent + // a smaller number in the meantime. + beyondCounting +) + +// summarise adds the targets up. +func summarise(targets []engine.Target) summary { + s := summary{certainty: stated} + seen := map[string]bool{} + for _, t := range targets { + s.files += int64(len(t.Sizes)) + if t.Format != "" && !seen[t.Format] { + seen[t.Format] = true + s.formats = append(s.formats, t.Format) + } + s.add(t) + } + sort.Strings(s.formats) + return s +} + +// add folds one target's sizes into the bounds. +func (s *summary) add(t engine.Target) { + switch { + case t.SizeFromContents: + s.lower(fromContents) + case t.Range.Used: + s.lower(drawn) + n := int64(len(t.Sizes)) + s.least = s.grow(s.least, times(n, t.Range.Min)) + s.most = s.grow(s.most, times(n, t.Range.Max)) + default: + for _, bytes := range t.Sizes { + s.least = s.grow(s.least, bytes) + s.most = s.grow(s.most, bytes) + } + } +} + +// grow adds to a running total, and marks the summary when the sum has left +// the range a number can hold. The same arithmetic the engine refuses with, +// so the strip and the refusal agree about where counting stops. +func (s *summary) grow(total, bytes int64) int64 { + if bytes < 0 { + s.lower(beyondCounting) + return total + } + sum, err := core.AddSizes(total, bytes) + if err != nil { + s.lower(beyondCounting) + return total + } + return sum +} + +// times is n files of one size, or a number too large to hold, which grow +// then refuses. Written out because n*bytes wraps silently. +func times(n, bytes int64) int64 { + if n <= 0 || bytes <= 0 { + return 0 + } + if bytes > math.MaxInt64/n { + return -1 + } + return n * bytes +} + +// lower moves the certainty down, never up - a form with one range and one +// stated size is a form with a range. +func (s *summary) lower(to certainty) { + if to > s.certainty { + s.certainty = to + } +} + +// totalText is the total as the strip says it. +// +// Empty when the total cannot be counted. Nothing is invented in its place: +// the run is refused for exactly this, and that refusal has the four parts a +// message here would not have room for. +func (s summary) totalText() string { + switch s.certainty { + case fromContents: + return text.SizeFromContents() + case beyondCounting: + return "" + case drawn: + if s.least != s.most { + return text.SizeBetween(core.HumanBytes(s.least), core.HumanBytes(s.most)) + } + case stated: + // Every size is on the form, so the exact total below is the answer. + } + return text.SizeAndBytes(core.HumanBytes(s.least), core.ExactBytes(s.least)) +} + +// exactly is the summary of a plan: the sizes are drawn by now, so the two +// bounds meet and the total is what the disk will hold. +func exactly(planned []engine.PlannedFile) summary { + total := engine.TotalBytes(planned) + return summary{ + files: int64(len(planned)), + least: total, + most: total, + certainty: stated, + formats: formatsOf(planned), + } +} diff --git a/internal/legal/assets.go b/internal/legal/assets.go index c574f054..8be33e53 100644 --- a/internal/legal/assets.go +++ b/internal/legal/assets.go @@ -26,13 +26,22 @@ var assets = []Asset{ Note: "The text of the window, in four styles. The licence requires its notice to travel with the font, which is why it is quoted in THIRD-PARTY-NOTICES.md in full.", }, { - Name: "Inter", + Name: "Inter Symbols", Package: "fyne.io/fyne/v2/theme", Module: "fyne.io/fyne/v2", Files: []string{"font/InterSymbols-Regular.ttf"}, SPDX: "OFL-1.1", 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.", + Note: "Symbols only - the toolkit's file, not the face the window is set in. 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", + Package: ourModule + "/internal/gui/font", + Module: ourModule, + Files: []string{"Inter-Regular.ttf", "Inter-Bold.ttf"}, + SPDX: "OFL-1.1", + Copyright: "Copyright 2016 The Inter Project Authors", + Note: "The text of the window, in two weights: the static Regular and Bold instances from the v4.1 release of github.com/rsms/inter, whose files call themselves version 4.001. The first entry for bytes a package of this project embeds itself rather than through the toolkit, since 2026-09-15. The copyright line is read from the files (name table, record 0) and the licence from record 13 and from LICENSE.txt in the release. No Reserved Font Name is declared in either.", }, { Name: "DejaVu Sans Mono for Powerline", diff --git a/internal/legal/carried.go b/internal/legal/carried.go index 7be2c76c..d5e610c5 100644 --- a/internal/legal/carried.go +++ b/internal/legal/carried.go @@ -110,7 +110,7 @@ func Carried(info *debug.BuildInfo) []Item { } items = append(items, moduleItem(dep.Path, dep.Version, reviewed)) } - items = append(items, embeddedItems(linked)...) + items = append(items, embeddedItems(linked, carrying)...) sort.SliceStable(items, func(i, j int) bool { if items[i].Embedded != items[j].Embedded { @@ -152,6 +152,16 @@ func Reviewed() []Item { return items } +// carries is the question embeddedItems asks of one entry: by package for a +// package of ours, by module for everything else. See embeddedItems for why +// there are two. +func carries(asset Asset, linkedModules, ours map[string]bool) bool { + if asset.Ours() { + return ours[asset.Package] + } + return linkedModules[asset.Module] +} + // CarriedHere is Carried asked of the binary that is running. func CarriedHere() []Item { info, ok := debug.ReadBuildInfo() @@ -180,13 +190,19 @@ 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. -func embeddedItems(linked map[string]bool) []Item { +// embeddedItems are the registry entries whose bytes are in this build. +// +// Two questions, because there are two kinds of entry. A file inside somebody +// else's module travels with the package that embeds it, and a build's own +// record names modules rather than packages, so for those the question is +// whether the module was linked. A file one of OUR packages embeds - Inter, +// since 2026-09-15 - cannot be answered that way, because both binaries are +// one module: for those the package itself has to be named in ours, and it is +// the package that says so (Carrying) or, for a document, go list. +func embeddedItems(linkedModules, ours map[string]bool) []Item { var items []Item for _, asset := range assets { - if !linked[asset.Module] { + if !carries(asset, linkedModules, ours) { continue } items = append(items, Item{ diff --git a/internal/legal/cmd/sbom/main.go b/internal/legal/cmd/sbom/main.go index 705d3f82..33f171c7 100644 --- a/internal/legal/cmd/sbom/main.go +++ b/internal/legal/cmd/sbom/main.go @@ -78,13 +78,14 @@ func describeBinaries() ([]legal.Binary, error) { {"tfg", "./cmd/tfg"}, {"tfg-gui", "./cmd/tfg-gui"}, } { - versions, err := linked(target.path) + versions, packages, err := linked(target.path) if err != nil { return nil, err } binaries = append(binaries, legal.Binary{ Name: target.name, Modules: versions, + Packages: packages, GoVersion: runtime.Version(), }) } @@ -92,7 +93,9 @@ func describeBinaries() ([]legal.Binary, error) { } // linked is what one target links on EVERY system this project releases for, -// rather than on the one running the generator. +// rather than on the one running the generator: the modules with their +// versions, and the packages of our own module - the second answer is what +// tells the window's font from the command line binary, since 2026-09-15. // // Measured while writing this, on the generator's own first output: asked on // Windows alone the window came back with twenty-seven modules instead of @@ -100,21 +103,25 @@ func describeBinaries() ([]legal.Binary, error) { // document generated on this machine would have shipped without naming it - // which is the same trap THIRD-PARTY-NOTICES.md records having fallen into, // in almost the same words. -func linked(target string) (map[string]string, error) { +func linked(target string) (map[string]string, map[string]bool, error) { versions := map[string]string{} + packages := map[string]bool{} for _, goos := range []string{"windows", "linux", "darwin"} { - found, err := linkedOn(target, goos) + found, ours, err := linkedOn(target, goos) if err != nil { - return nil, err + return nil, nil, err } if err := merge(versions, found, goos); err != nil { - return nil, err + return nil, nil, err + } + for pkg := range ours { + packages[pkg] = true } } if len(versions) == 0 { - return nil, fmt.Errorf("%s reported no modules at all, which cannot be right", target) + return nil, nil, fmt.Errorf("%s reported no modules at all, which cannot be right", target) } - return versions, nil + return versions, packages, nil } // merge folds one system's answer into the set, refusing a disagreement rather @@ -133,26 +140,35 @@ func merge(into, found map[string]string, goos string) error { // linkedOn asks one system. CGO is on because with it off the toolkit hides // behind build constraints and the window lists as the stub that has no window. -func linkedOn(target, goos string) (map[string]string, error) { +func linkedOn(target, goos string) (map[string]string, map[string]bool, 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() if err != nil { - return nil, fmt.Errorf("asking what %s links on %s: %w", target, goos, err) + return nil, nil, fmt.Errorf("asking what %s links on %s: %w", target, goos, err) } versions := map[string]string{} + ours := map[string]bool{} for _, line := range strings.Split(string(out), "\n") { - path, moduleVersion, found := strings.Cut(strings.TrimSpace(line), "@") - if !found || strings.Contains(path, "donislawdev") { + module, pkg, _ := strings.Cut(strings.TrimSpace(line), "|") + path, moduleVersion, found := strings.Cut(module, "@") + if !found { + continue + } + // Our own module has no version to report and is not a dependency, + // so it is not a row of the module table. Its packages are kept + // instead, because the registry asks about them. + if strings.Contains(path, "donislawdev") { + ours[pkg] = true continue } versions[path] = moduleVersion } - return versions, nil + return versions, ours, nil } func fail(err error) { diff --git a/internal/legal/legal.go b/internal/legal/legal.go index 0652871e..1f558981 100644 --- a/internal/legal/legal.go +++ b/internal/legal/legal.go @@ -23,6 +23,35 @@ package legal import "strings" +// ourModule is this project, spelled as go list spells it. It is here because +// the registry took its first entry for bytes that a package of OUR OWN embeds +// on 2026-09-15 (the Inter font under internal/gui/font), and such an entry is +// answered differently from the rest: whether a build carries it cannot be +// read off the build's list of modules, because both binaries are this one +// module. A guard checks the spelling against go list -m. +const ourModule = "github.com/donislawdev/TestingFilesGenerator" + +// carrying is the set of our own packages that embed somebody else's bytes and +// are linked into the running binary. +// +// Filled by those packages themselves, from an init - so the answer comes from +// the build, the way debug.ReadBuildInfo answers for modules: a package's init +// runs exactly when the package is linked, and neither binary has to be told +// which of them it holds. Written only during initialisation, by one goroutine, +// and read afterwards, which is why there is no lock on it. +var carrying = map[string]bool{} + +// Carrying is how a package of ours that embeds somebody else's bytes says it +// is in this binary. Called from that package's init and from nowhere else. A +// guard asks that every such package in the registry does call it, because a +// package that forgot would ship its bytes and drop off the licence command +// without a word. +func Carrying(pkg string) { carrying[pkg] = true } + +// LinkedHere reports whether one of our own packages announced itself in the +// running binary. For a guard: the product reads carrying directly. +func LinkedHere(pkg string) bool { return carrying[pkg] } + // A Module is one module that a binary of this project links. type Module struct { // Path is the module path as go list reports it. The Go runtime and @@ -90,6 +119,16 @@ func Modules() []Module { return modules } // Assets returns the reviewed list of embedded files. func Assets() []Asset { return assets } +// Ours reports whether the bytes are embedded by a package of this project +// rather than by somebody else's module. Such an entry is answered by package +// rather than by module wherever a build is asked what it carries - see +// embeddedItems - and a guard holds the command line binary to linking none. +func (a Asset) Ours() bool { return a.Module == ourModule } + +// OurModule is this project's module path as the registry spells it, for the +// guard that checks the spelling against go list. +func OurModule() string { return ourModule } + // Covers reports whether this entry accounts for an embedded path, spelled the // way go list spells it. func (a Asset) Covers(embedded string) bool { diff --git a/internal/legal/spdx.go b/internal/legal/spdx.go index 17c63908..b91fb5b1 100644 --- a/internal/legal/spdx.go +++ b/internal/legal/spdx.go @@ -54,6 +54,13 @@ type Binary struct { // added from GoVersion. Modules map[string]string + // Packages is which of OUR OWN packages this binary links, by import path. + // Only one kind can matter to the registry: a package of ours that embeds + // somebody else's bytes. Both binaries are one module, so Modules cannot + // say whether the window's font is in the command line binary, and this + // can. Read from go list, the same way as Modules. + Packages map[string]bool + // GoVersion is the toolchain the binary was built with, which is also the // version of the runtime and standard library inside it. GoVersion string @@ -196,7 +203,7 @@ func carriedBy(binary Binary) []Item { } items = append(items, moduleItem(path, binary.Modules[path], reviewed)) } - return append(items, embeddedItems(linkedSet(binary.Modules))...) + return append(items, embeddedItems(linkedSet(binary.Modules), binary.Packages)...) } func linkedSet(versions map[string]string) map[string]bool {