diff --git a/CHANGELOG.md b/CHANGELOG.md index 8038b04d..fbbdddbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,18 +16,36 @@ because it turns other people's test suites red. ### Changed +- **The window's look, after a review of every screen.** An open list and the + explanation beside a field stand on a card with an edge and a shade instead + of a flat grey block, and a list opened under its box shrinks to what the + filter left. Every list shows its tick at the end of the row. Controls + switched off for a run are never drawn brighter than at rest, and a ticked + switch keeps its tick while it is off - it showed an empty square for the + length of every run. `Donate` carries a red heart, and it and `Add a batch` + stand on the form's left edge. In a narrow window the run buttons move right + instead of making the window wider, so it can be narrowed further. Two + refusals in one row stand one under the other from the row's edge rather + than as a staircase. `Remove` is written in red, the rails beside groups of + settings are one grey, `Choose...` is as tall as the box beside it, and the + About screen's sentence sits under its title like the other screens'. + - **The list of formats is grouped by kind and can be filtered.** The open list stands under headings - Archives, Documents, Pictures, Sound, Text and data - each saying how many formats are under it, and a box at its top - narrows it to the formats whose name holds what is typed, or whose kind - has a word that starts with it (`gz` finds `targz`, `pict` every picture, - `data` every text and data format). The letters that - matched are drawn in bold, the arrows step over the headings, and typing - at the shut `Format` menu opens the list with those letters in the box, so - `jxl` typed there ends on `jxl`. The shut menu draws the kind of the format - it holds, and archives are drawn as a folder rather than as three bars that - looked like text. The command line lists the formats in one alphabetical - order as before. + narrows it to the formats whose identifier holds what is typed, or whose + kind or full name has a word that starts with it (`gz` finds `targz`, + `pict` every picture, `data` every text and data format, `excel` finds + `xlsx`). Every format stands beside its full name, `jxl` beside JPEG XL, so + the open list is wider than the `Format` box, which keeps its width. The + letters that matched are drawn in bold, in the identifier and in the name, + the arrows step over the headings, and typing at the shut `Format` menu + opens the list with those letters in the box, so `jxl` typed there ends on + `jxl`. When nothing matches, the list says `No format matches - clear the + box to see all`. The shut menu draws the kind of the format it holds, and + archives are drawn as a folder rather than as three bars that looked like + text. The command line lists the formats in one alphabetical order as + before. - **The window lays its forms out in columns.** A field now takes as many columns of the form as its value needs and no more, so `Format`, `Size`, @@ -282,6 +300,13 @@ because it turns other people's test suites red. ### Added +- **Every format has its full name.** `tfg formats` has a `NAME` column + (`jxl` is JPEG XL, `png` Portable Network Graphics), `tfg formats jxl` + gives it on a `name` line, and `tfg formats --json` carries it under the new + key `name`. No key already there changes, and no generated file changes. + The formats table on the website has the same column. Names are proper + names and stay in English on every page. + - **The window says where the manifest went, and opens it.** A finished run used to say `3 files written.` and nothing else, while the same run from the command line printed the path of the manifest beside the count. The line now diff --git a/internal/cli/formats.go b/internal/cli/formats.go index ca0be412..7c7dd048 100644 --- a/internal/cli/formats.go +++ b/internal/cli/formats.go @@ -16,7 +16,11 @@ import ( // sense at all - how faithful the file will be, whether it repeats to the // byte, and how small it can go. type formatEntry struct { - ID string `json:"id"` + ID string `json:"id"` + // Name is what the format is called, beside the identifier a recipe + // uses. Added on 2026-09-24, which widens this output and changes the + // meaning of no key already in it. + Name string `json:"name"` Extension string `json:"extension"` Fidelity string `json:"fidelity"` Determinism string `json:"determinism"` @@ -96,6 +100,7 @@ func entryFor(d format.Descriptor) formatEntry { } return formatEntry{ ID: d.ID, Extension: d.Extension, + Name: d.Name, Fidelity: string(d.Fidelity), Determinism: string(d.Determinism), MinBytes: d.MinBytes, SmallestAccepted: smallestAccepted(d), Padding: d.Padding.Name, PaddingCap: d.Padding.Capacity, @@ -112,6 +117,7 @@ func entryFor(d format.Descriptor) formatEntry { func describeOne(d format.Descriptor, out io.Writer) { fmt.Fprintf(out, "%s - %s fidelity, %s deterministic, minimum %s\n", d.ID, d.Fidelity, d.Determinism, core.ExactBytes(smallestAccepted(d))) + fmt.Fprintf(out, " name %s\n", d.Name) fmt.Fprintf(out, " extension %s\n", d.Extension) fmt.Fprintf(out, " padding %s\n", d.Padding.Name) fmt.Fprintf(out, " label %s\n", d.Label) @@ -203,14 +209,27 @@ Flags: } return renderJSON(list, out, errOut) } + printTable(out) + return ExitOK +} - fmt.Fprintf(out, "%-8s %-10s %-12s %-10s %s\n", "FORMAT", "FIDELITY", "DETERMINISM", "MINIMUM", "PADDING CHANNEL") +// printTable is the list a person reads: one row a format. +// +// The name column is as wide as the longest name rather than a number written +// here, so the next longer name keeps every column after it in line. Counted +// in bytes, which is characters: a name is ASCII, held by +// TestEveryFormatDeclaresTheFullSet. +func printTable(out io.Writer) { + named := len("NAME") for _, d := range format.All() { - fmt.Fprintf(out, "%-8s %-10s %-12s %-10d %s\n", - d.ID, d.Fidelity, d.Determinism, smallestAccepted(d), d.Padding.Name) + named = max(named, len(d.Name)) + } + fmt.Fprintf(out, "%-8s %-*s %-10s %-12s %-10s %s\n", "FORMAT", named, "NAME", "FIDELITY", "DETERMINISM", "MINIMUM", "PADDING CHANNEL") + for _, d := range format.All() { + fmt.Fprintf(out, "%-8s %-*s %-10s %-12s %-10d %s\n", + d.ID, named, d.Name, d.Fidelity, d.Determinism, smallestAccepted(d), d.Padding.Name) } fmt.Fprint(out, "\nRun \"tfg formats \" for what one format accepts.\n") - return ExitOK } func renderJSON(v any, out, errOut io.Writer) int { diff --git a/internal/format/avif/avif.go b/internal/format/avif/avif.go index 6fbe955f..cafff914 100644 --- a/internal/format/avif/avif.go +++ b/internal/format/avif/avif.go @@ -106,6 +106,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "avif", + Name: "AV1 Image File Format", Extension: ".avif", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/bmp/bmp.go b/internal/format/bmp/bmp.go index ad46f96b..140b03eb 100644 --- a/internal/format/bmp/bmp.go +++ b/internal/format/bmp/bmp.go @@ -59,6 +59,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "bmp", + Name: "Windows Bitmap", Extension: ".bmp", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/csvfile/csv.go b/internal/format/csvfile/csv.go index 067d8953..e6267b84 100644 --- a/internal/format/csvfile/csv.go +++ b/internal/format/csvfile/csv.go @@ -103,6 +103,7 @@ func fixedWidth(d dialect) int64 { func init() { format.Register(format.Descriptor{ ID: "csv", + Name: "Comma-Separated Values", Extension: ".csv", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/docx/docx.go b/internal/format/docx/docx.go index d0ca123e..40a78092 100644 --- a/internal/format/docx/docx.go +++ b/internal/format/docx/docx.go @@ -44,6 +44,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "docx", + Name: "Word (Office Open XML)", Extension: ".docx", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/format.go b/internal/format/format.go index 68a92caf..6c22d54b 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -287,7 +287,21 @@ func (j JointLimit) per() int64 { // Descriptor is everything a format announces about itself. A format missing // any of it fails the registry test rather than shipping half implemented. type Descriptor struct { - ID string + ID string + + // Name is what the format is called where it is known by a name - JPEG XL + // for jxl, Portable Network Graphics for png - so that somebody who does + // not recognise the identifier can still find the format. A proper name, + // in English and never translated, which is why it can live here rather + // than in a language file. It is shown beside the identifier and never + // replaces it: the identifier is what a recipe and a manifest carry. + // + // Nothing written into a file, a manifest or a recipe reads it, so adding + // or rewording one changes no byte a run produces (D11). The one place it + // is a contract is the "name" key of "tfg formats --json", added on + // 2026-09-24 as a widening. Recorded in docs/FORMAT-NAMES-2026-09-24.md. + Name string + Extension string Fidelity Fidelity Determinism Determinism diff --git a/internal/format/gif/gif.go b/internal/format/gif/gif.go index adcaf7c7..35d63cde 100644 --- a/internal/format/gif/gif.go +++ b/internal/format/gif/gif.go @@ -101,6 +101,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "gif", + Name: "Graphics Interchange Format", Extension: ".gif", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/htmlfile/html.go b/internal/format/htmlfile/html.go index b6af1f54..b2f4c50f 100644 --- a/internal/format/htmlfile/html.go +++ b/internal/format/htmlfile/html.go @@ -117,6 +117,7 @@ func blocksFor(shape string) blocks { func init() { format.Register(format.Descriptor{ ID: "html", + Name: "HyperText Markup Language", Extension: ".html", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/ico/ico.go b/internal/format/ico/ico.go index 4af1f759..b2a3b882 100644 --- a/internal/format/ico/ico.go +++ b/internal/format/ico/ico.go @@ -58,6 +58,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "ico", + Name: "Windows Icon", Extension: ".ico", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/jpg/jpg.go b/internal/format/jpg/jpg.go index 20e6344e..815cecc9 100644 --- a/internal/format/jpg/jpg.go +++ b/internal/format/jpg/jpg.go @@ -70,6 +70,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "jpg", + Name: "JPEG", Extension: ".jpg", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/jsonfile/json.go b/internal/format/jsonfile/json.go index 152578b4..3bb3040e 100644 --- a/internal/format/jsonfile/json.go +++ b/internal/format/jsonfile/json.go @@ -61,6 +61,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "json", + Name: "JavaScript Object Notation", Extension: ".json", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/jxl/jxl.go b/internal/format/jxl/jxl.go index ae84e71e..34c3c503 100644 --- a/internal/format/jxl/jxl.go +++ b/internal/format/jxl/jxl.go @@ -139,6 +139,7 @@ var fileType = [20]byte{ func init() { format.Register(format.Descriptor{ ID: "jxl", + Name: "JPEG XL", Extension: ".jxl", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/logfile/log.go b/internal/format/logfile/log.go index 594a44b6..70d27e56 100644 --- a/internal/format/logfile/log.go +++ b/internal/format/logfile/log.go @@ -48,6 +48,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "log", + Name: "Server and application log", Extension: ".log", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/md/md.go b/internal/format/md/md.go index fc74873f..63869846 100644 --- a/internal/format/md/md.go +++ b/internal/format/md/md.go @@ -44,6 +44,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "md", + Name: "Markdown", Extension: ".md", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/pdf/pdf.go b/internal/format/pdf/pdf.go index 0eb84cf1..50c696ec 100644 --- a/internal/format/pdf/pdf.go +++ b/internal/format/pdf/pdf.go @@ -53,6 +53,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "pdf", + Name: "Portable Document Format", Extension: ".pdf", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/png/png.go b/internal/format/png/png.go index a764bb2a..7ad54fc6 100644 --- a/internal/format/png/png.go +++ b/internal/format/png/png.go @@ -73,6 +73,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "png", + Name: "Portable Network Graphics", Extension: ".png", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/pptx/pptx.go b/internal/format/pptx/pptx.go index 45ef413c..090c3a0d 100644 --- a/internal/format/pptx/pptx.go +++ b/internal/format/pptx/pptx.go @@ -54,6 +54,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "pptx", + Name: "PowerPoint (Office Open XML)", Extension: ".pptx", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/svgfile/svg.go b/internal/format/svgfile/svg.go index 41644bd2..ff6882f0 100644 --- a/internal/format/svgfile/svg.go +++ b/internal/format/svgfile/svg.go @@ -188,6 +188,7 @@ func fit(extent, room int) int { return min(extent, room) } func init() { format.Register(format.Descriptor{ ID: "svg", + Name: "Scalable Vector Graphics", Extension: ".svg", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/targz/targz.go b/internal/format/targz/targz.go index 554e29d1..624823b0 100644 --- a/internal/format/targz/targz.go +++ b/internal/format/targz/targz.go @@ -93,6 +93,7 @@ var fixedTime = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) func init() { format.Register(format.Descriptor{ ID: "targz", + Name: "tar + gzip", Extension: ".tar.gz", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/tiff/tiff.go b/internal/format/tiff/tiff.go index 52877cb7..9cb596c1 100644 --- a/internal/format/tiff/tiff.go +++ b/internal/format/tiff/tiff.go @@ -104,6 +104,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "tiff", + Name: "Tagged Image File Format", Extension: ".tiff", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/tomlfile/toml.go b/internal/format/tomlfile/toml.go index 25d070fa..c2bf8171 100644 --- a/internal/format/tomlfile/toml.go +++ b/internal/format/tomlfile/toml.go @@ -73,6 +73,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "toml", + Name: "TOML", Extension: ".toml", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/txt/txt.go b/internal/format/txt/txt.go index f8beecc4..7b9f0916 100644 --- a/internal/format/txt/txt.go +++ b/internal/format/txt/txt.go @@ -34,6 +34,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "txt", + Name: "Plain text", Extension: ".txt", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/wav/wav.go b/internal/format/wav/wav.go index 67d627ea..3d0f8e6d 100644 --- a/internal/format/wav/wav.go +++ b/internal/format/wav/wav.go @@ -79,6 +79,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "wav", + Name: "Waveform Audio", Extension: ".wav", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/webp/webp.go b/internal/format/webp/webp.go index e6ef60c0..dacf6821 100644 --- a/internal/format/webp/webp.go +++ b/internal/format/webp/webp.go @@ -79,6 +79,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "webp", + Name: "WebP", Extension: ".webp", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/xlsx/xlsx.go b/internal/format/xlsx/xlsx.go index 7da19897..f02b7b65 100644 --- a/internal/format/xlsx/xlsx.go +++ b/internal/format/xlsx/xlsx.go @@ -65,6 +65,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "xlsx", + Name: "Excel (Office Open XML)", Extension: ".xlsx", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/xmlfile/xml.go b/internal/format/xmlfile/xml.go index 3b1550db..9e32e82d 100644 --- a/internal/format/xmlfile/xml.go +++ b/internal/format/xmlfile/xml.go @@ -86,6 +86,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "xml", + Name: "Extensible Markup Language", Extension: ".xml", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/yamlfile/yaml.go b/internal/format/yamlfile/yaml.go index bd80edd2..08331f01 100644 --- a/internal/format/yamlfile/yaml.go +++ b/internal/format/yamlfile/yaml.go @@ -102,6 +102,7 @@ const ( func init() { format.Register(format.Descriptor{ ID: "yaml", + Name: "YAML", Extension: ".yaml", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/zip/zip.go b/internal/format/zip/zip.go index 423ad739..1f43c771 100644 --- a/internal/format/zip/zip.go +++ b/internal/format/zip/zip.go @@ -61,6 +61,7 @@ var fixedTime = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) func init() { format.Register(format.Descriptor{ ID: "zip", + Name: "ZIP", Extension: ".zip", Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/guard/actionrail_test.go b/internal/guard/actionrail_test.go deleted file mode 100644 index ab155da9..00000000 --- a/internal/guard/actionrail_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package guard - -import ( - "testing" - - "fyne.io/fyne/v2" - - "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" - "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" - "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" -) - -// The buttons that are not about the run stand at the edge of the bar, and stay -// there when the window is made wider. -// -// Asked for by the owner on 2026-08-19, looking at the built window: Donate was -// a margin in from the left and he wanted it as far left as it goes. Until then -// the whole left hand group lived inside the form's column - laid over the row -// the run buttons are centred in - so it started where the form started, which -// at the opening size is 78 px in. -// -// Measured at two widths rather than compared against a number, and that is the -// whole design of this guard. A pixel it was written down against would be a -// copy of the layout's own arithmetic and would drift the first time a padding -// changed. The behaviour that actually differs between the two layouts is how -// the button answers a wider window: pinned to the edge it does not move, and -// held in a centred column it slides right by half of what the window gained. -// At 1600 px the old layout put it 300 px further right. -// -// The three work screens. About had the bar too until 2026-09-23, holding -// Donate and nothing else, and it was taken off that screen on the owner's -// word from the running window: its Donate stands in the Support card now, -// inside the page, where standing with the form's column is what it should do. -// TestTheDonateButtonIsOnEveryScreen still asks that it is there. -func TestWhatIsNotAboutTheRunStandsAtTheEdgeOfTheBar(t *testing.T) { - for _, tab := range []string{ - text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), - } { - t.Run(tab, func(t *testing.T) { - content, w := screenInAWindow(t, tab) - - donate := buttonNamed(content, text.ButtonDonate()) - if donate == nil { - t.Fatalf("this screen has no %q button, so this guard read the wrong tree", - text.ButtonDonate()) - } - - atOpening := fyne.CurrentApp().Driver().AbsolutePositionForObject(donate).X - - wider := fyne.NewSize(window.LargestOpening.Width+600, window.LargestOpening.Height) - w.Resize(wider) - content.Refresh() - w.Resize(wider) - - whenWider := fyne.CurrentApp().Driver().AbsolutePositionForObject(donate).X - - if whenWider != atOpening { - t.Errorf("%q sits %.0f px from the left at %.0f px wide and %.0f px from the left "+ - "at %.0f px wide, so it is riding the middle of the window rather than "+ - "standing at the edge of the bar.\n"+ - "What to do: it belongs in the rail argument of parts.ActionBar, which is laid "+ - "outside the form's column. Put back inside that column it moves with it.", - text.ButtonDonate(), atOpening, window.LargestOpening.Width, whenWider, wider.Width) - } - - // The bar's own padding is the only thing that should stand between - // the button and the edge. Without this, a rail nailed to a fixed - // offset far from the edge would pass the test above. - if room := float32(parts.ColumnWidth) / 2; atOpening > room { - t.Errorf("%q starts %.0f px from the left edge, which is further in than half a "+ - "form column (%.0f px) - it does not read as standing at the edge.", - text.ButtonDonate(), atOpening, room) - } - }) - } -} diff --git a/internal/guard/cataloguewords_test.go b/internal/guard/cataloguewords_test.go new file mode 100644 index 00000000..64752db8 --- /dev/null +++ b/internal/guard/cataloguewords_test.go @@ -0,0 +1,96 @@ +package guard + +import ( + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/test" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/catalogue" +) + +// TestNoWordsInTheCatalogueRunPastTheEdgeOfItsSections lays the catalogue out +// at the width its stored picture is taken at and reads where every line of +// words it draws ends. +// +// A caption in a row beside something else is handed no width of its own, so +// it has nothing to wrap to and draws its whole line from where it starts. +// The palette's sentence for menuBackground grew on #136 and ran 82.9 px past +// the edge of its section, onto the page - in the stored picture, where +// nobody looked, and found by an outside review (docs/REVIEW-136-2026-09-24.md). +// No guard read where the words of the catalogue end, only whether they were +// there. +// +// The edge is the column the sections stand in, so a line that runs past a +// narrower box inside a section and stops short of the section's own edge is +// not seen here. Words inside something that scrolls are left out: they are +// cut to its bounds, and a long value in a box is a state the catalogue +// shows on purpose. +func TestNoWordsInTheCatalogueRunPastTheEdgeOfItsSections(t *testing.T) { + ourTheme(t) + page := catalogue.Page() + w := test.NewTempWindow(t, page) + // Twice at each size for the reason renderScene gives: a wrapping line + // knows its width only on the second pass. + for _, size := range []fyne.Size{ + fyne.NewSize(referenceWidth, referenceHeight), + fyne.NewSize(referenceWidth, page.MinSize().Height), + } { + w.Resize(size) + w.Resize(size) + } + + sections, ok := page.(*fyne.Container) + if !ok || len(sections.Objects) != 1 { + t.Fatalf("the catalogue page is a %T, not the one column parts.Screen stands its sections in", page) + } + column := sections.Objects[0] + if column.Size().Width >= page.Size().Width { + t.Fatalf("the column of sections is %.1f px wide on a page of %.1f, so its edge is the page's and "+ + "a line running past a section would not be seen", column.Size().Width, page.Size().Width) + } + // Counted from the same place the walk below counts from: the page's own + // position in the window, which the walk adds first. + edge := page.Position().X + column.Position().X + column.Size().Width + + lines := 0 + wordsEndingAt(page, 0, func(words *canvas.Text, ends float32) { + lines++ + if ends > edge+0.5 { + t.Errorf("%q ends at x=%.1f, %.1f px past the edge of the sections at x=%.1f", + words.Text, ends, ends-edge, edge) + } + }) + // The catalogue draws every part in every state with a caption over each, + // so a count this low is a walk that did not reach the words. + if lines < 100 { + t.Fatalf("only %d line(s) of words were found in the catalogue, so it was not read", lines) + } +} + +// wordsEndingAt walks what is drawn under o, which stands at x, and hands +// every visible line of words to visit with where on the page it ends. It +// does not go into anything that scrolls, since that cuts what it holds. +func wordsEndingAt(o fyne.CanvasObject, x float32, visit func(*canvas.Text, float32)) { + if o == nil || !o.Visible() { + return + } + x += o.Position().X + switch v := o.(type) { + case *canvas.Text: + if v.Text != "" { + visit(v, x+v.MinSize().Width) + } + case *container.Scroll: + case *fyne.Container: + for _, child := range v.Objects { + wordsEndingAt(child, x, visit) + } + case fyne.Widget: + for _, child := range test.WidgetRenderer(v).Objects() { + wordsEndingAt(child, x, visit) + } + } +} diff --git a/internal/guard/detailpopup_test.go b/internal/guard/detailpopup_test.go index 0286c55d..eaa6b763 100644 --- a/internal/guard/detailpopup_test.go +++ b/internal/guard/detailpopup_test.go @@ -234,18 +234,16 @@ func namedOnScreen(o fyne.CanvasObject) string { return "" } -// The explanation floats on the surface an open list does, not on a panel's. +// The explanation floats on the card an open list does, not on a panel's +// surface. // // Reported by the owner from the running window on 2026-09-21: the tooltips // are hard to read because of their background. Measured on the shot: the box // was drawn in the panel colour, and it opens over a panel - so it had no // edge anywhere, and the sentence lay straight over the form covering the row -// beneath it. The palette's answer to "what floats over the form" was already -// in use by the list a menu drops down, and this holds the two to one surface. -// -// Held against the palette by name rather than against "not the panel": a box -// in any third colour would be told from the panel and still be a second -// floating surface nobody chose. +// beneath it. Since then it stands on what the list a menu drops down stands +// on, and since 2026-09-24 that is the card the owner chose from three drawn +// side by side (floatsAsACard) - both had been reported as a plain grey block. func TestTheExplanationFloatsOnTheSurfaceAnOpenListDoes(t *testing.T) { app := test.NewApp() defer test.NewApp() @@ -263,37 +261,43 @@ func TestTheExplanationFloatsOnTheSurfaceAnOpenListDoes(t *testing.T) { t.Fatal("hovering the button put nothing on the sheet, so there is no box to measure") } - // Two rectangles since 2026-09-21: the shade the box casts, then the - // surface it stands on. The owner's report from the running window was - // that a flat box with no edge read as a random rectangle, so the - // surface wears a line and a shade shows below it. Each is asked for by - // what it is rather than by its place in the tree. - var surface, shade *canvas.Rectangle - want := parts.PaletteColour(theme.ColorNameMenuBackground, theme.VariantDark) - walk(box, func(o fyne.CanvasObject) { + floatsAsACard(t, box, "the explanation") +} + +// floatsAsACard asks something drawn over the form for the card it stands on: +// a face in the surface of a box to type in, with a box's edge and a panel's +// corner, and a translucent shade that shows below the face. Each rectangle is +// asked for by what it is rather than by its place in the tree, and the edge +// and the shade are what tell it from the form now that the face is a box's +// colour - the owner's choice of 2026-09-24 over a lighter face with neither. +func floatsAsACard(t *testing.T, root fyne.CanvasObject, what string) { + t.Helper() + dark := theme.VariantDark + var face, shade *canvas.Rectangle + walk(root, func(o fyne.CanvasObject) { rect, is := o.(*canvas.Rectangle) if !is { return } - if rect.FillColor == want && surface == nil { - surface = rect + if sameColour(rect.FillColor, parts.PaletteColour(theme.ColorNameInputBackground, dark)) && rect.StrokeWidth > 0 && face == nil { + face = rect } else if _, _, _, a := rect.FillColor.RGBA(); a > 0 && a < 0xFFFF && shade == nil { shade = rect } }) - if surface == nil { - t.Fatal("the explanation's box draws no rectangle in the colour of an open list, so it stands on nothing that floats") + if face == nil { + t.Fatalf("%s draws no face in the surface of a box to type in with an edge round it, so it stands on nothing that floats", what) } - if surface.CornerRadius != parts.RadiusField { - t.Errorf("the explanation's corner is %.0f and a floating control's is %d", surface.CornerRadius, parts.RadiusField) + if !sameColour(face.StrokeColor, parts.PaletteColour(theme.ColorNameInputBorder, dark)) { + t.Errorf("%s's edge is %v and a box's is %v", what, face.StrokeColor, parts.PaletteColour(theme.ColorNameInputBorder, dark)) } - if surface.StrokeWidth == 0 { - t.Error("the explanation's surface has no line round it, which is the random rectangle the owner saw") + if face.CornerRadius != parts.RadiusPanel { + t.Errorf("%s's corner is %.0f and the card's is %d", what, face.CornerRadius, parts.RadiusPanel) } if shade == nil { - t.Error("the explanation casts no shade, so nothing says it lies over the form rather than in it") - } else if shade.Position().Y <= surface.Position().Y { - t.Errorf("the shade sits at y=%.0f and the surface at y=%.0f - a shade that is not below the box it belongs to reads as a smudge", - shade.Position().Y, surface.Position().Y) + t.Errorf("%s casts no shade, so nothing but a thin line says it lies over the form rather than in it", what) + } else if shade.Position().Y <= face.Position().Y { + t.Errorf("%s's shade sits at y=%.0f and its face at y=%.0f - a shade that is not below what casts it reads as a smudge", + what, shade.Position().Y, face.Position().Y) } } diff --git a/internal/guard/embeddedassets_test.go b/internal/guard/embeddedassets_test.go index adbae7ec..b1effc9f 100644 --- a/internal/guard/embeddedassets_test.go +++ b/internal/guard/embeddedassets_test.go @@ -134,6 +134,9 @@ func accountForPackage(t *testing.T, pkg string, files []string, seen, matched, 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, + // Drawn from two circles and a point, the geometry written beside it in + // heart.go. The owner's choice over an icon set's heart. docs/LICENSING.md. + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts heart.svg": true, // The window's own words. "github.com/donislawdev/TestingFilesGenerator/internal/gui/text locale/en.json": true, } diff --git a/internal/guard/formatlist_test.go b/internal/guard/formatlist_test.go index 620b5bf7..c7865ecb 100644 --- a/internal/guard/formatlist_test.go +++ b/internal/guard/formatlist_test.go @@ -401,7 +401,15 @@ func TestTheSpaceThatOpensTheFormatListIsNotTypedIntoItsFilter(t *testing.T) { } } -// TestThePartOfAValueThatMatchedIsDrawnInBold reads a row's drawn words. +// TestThePartOfAValueThatMatchedIsDrawnInBold reads a row's drawn words: what +// matched in the value, and since 2026-09-24 what matched at the start of a +// word of the name beside it - targz is named "tar + gzip", and gzip starts +// with gz. +// +// In the order the row draws them, with bold marked, so bold landing on the +// wrong piece cannot pass for bold landing on the right one: until the names +// arrived this joined the bold pieces and the plain ones separately, which a +// name drawn wholly in bold would also have satisfied. func TestThePartOfAValueThatMatchedIsDrawnInBold(t *testing.T) { _, _, list, filter := openFormatList(t) typeInto(filter, "gz") @@ -409,20 +417,21 @@ func TestThePartOfAValueThatMatchedIsDrawnInBold(t *testing.T) { if row == nil { t.Fatal("gz was typed and no row is drawing targz") } - var bold, plain []string + var drawn []string for _, o := range test.WidgetRenderer(row).Objects() { words, ok := o.(*canvas.Text) if !ok || !words.Visible() || words.Text == "" { continue } if words.TextStyle.Bold { - bold = append(bold, words.Text) + drawn = append(drawn, "*"+words.Text+"*") } else { - plain = append(plain, words.Text) + drawn = append(drawn, words.Text) } } - if strings.Join(bold, "") != "gz" || strings.Join(plain, "") != "tar" { - t.Errorf("targz with gz typed draws %v in bold and %v plain, where gz is what matched", bold, plain) + want := []string{"tar", "*gz*", "tar + ", "*gz*", "ip"} + if strings.Join(drawn, "|") != strings.Join(want, "|") { + t.Errorf("targz with gz typed draws %q, bold between stars, and it should draw %q: gz is what matched, in the value and at the start of gzip in its name", drawn, want) } } diff --git a/internal/guard/formatnamelist_test.go b/internal/guard/formatnamelist_test.go new file mode 100644 index 00000000..fc9c9e65 --- /dev/null +++ b/internal/guard/formatnamelist_test.go @@ -0,0 +1,235 @@ +package guard + +import ( + "slices" + "strings" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// The list of formats names every format beside its identifier, since +// 2026-09-24 - "jxl" says nothing to somebody who has not met it, "JPEG XL" +// does. Decided by the owner, with the list wider than the box it drops from +// and the box itself as narrow as before. Recorded in +// docs/FORMAT-NAMES-2026-09-24.md. + +// drawnWords is every visible piece of text a row draws, in its order. +func drawnWords(row *parts.ListRow) []*canvas.Text { + var out []*canvas.Text + for _, o := range test.WidgetRenderer(row).Objects() { + if words, ok := o.(*canvas.Text); ok && words.Visible() && words.Text != "" { + out = append(out, words) + } + } + return out +} + +// TestTheFormatListNamesEveryFormatBesideItsIdentifier reads the rows as +// drawn: every value row carries the registry's name for its value, the names +// start on one line down the list, clear of the widest identifier, and they +// are drawn a step quieter than the identifier - the identifier is what lands +// in the box. +func TestTheFormatListNamesEveryFormatBesideItsIdentifier(t *testing.T) { + _, _, list, _ := openFormatList(t) + quiet := parts.PaletteColour(parts.ColorNameLabel, theme.VariantDark) + loud := parts.Theme().Color(theme.ColorNameForeground, theme.VariantDark) + + var column float32 = -1 + named := 0 + for _, row := range list.DrawnRows() { + if row.Heading() { + continue + } + d, err := format.Get(row.Label()) + if err != nil { + t.Fatalf("the format list draws %q, which is not a registered format", row.Label()) + } + words := drawnWords(row) + if len(words) != 2 || words[0].Text != d.ID || words[1].Text != d.Name { + texts := make([]string, 0, len(words)) + for _, w := range words { + texts = append(texts, w.Text) + } + t.Errorf("the row for %s draws %q, where it should draw the identifier and then %q", d.ID, texts, d.Name) + continue + } + named++ + value, name := words[0], words[1] + if column < 0 { + column = name.Position().X + } + if name.Position().X != column { + t.Errorf("the name of %s starts at x=%.1f and the first name at x=%.1f - the names are not one column", + d.ID, name.Position().X, column) + } + if ends := value.Position().X + fyne.MeasureText(value.Text, value.TextSize, fyne.TextStyle{Bold: true}).Width; name.Position().X <= ends { + t.Errorf("the name of %s starts at x=%.1f, inside the room its identifier takes in bold, which ends at x=%.1f", + d.ID, name.Position().X, ends) + } + if name.Color != quiet || value.Color != loud { + t.Errorf("the row for %s draws its identifier in %v and its name in %v - the name should be the quieter ink of a field's name (%v), the identifier the ink of a value (%v)", + d.ID, value.Color, name.Color, quiet, loud) + } + } + if named == 0 { + t.Fatal("no row of the open format list drew a name, so nothing about the names was checked") + } +} + +// TestTheFormatListIsWiderThanItsBoxAndCutsNoName opens the list in the +// narrowest window each screen with a list of formats allows: the list is +// wider than the box it drops from, every row fits in it, and all of it is +// inside the window. +// +// The narrowest window because that is where a list wider than its box has +// the least room beside it. Screens rather than one: the format box stands in +// a different place on each. +func TestTheFormatListIsWiderThanItsBoxAndCutsNoName(t *testing.T) { + for _, tab := range []string{text.TabOneTarget(), text.TabRecipe()} { + t.Run(tab, func(t *testing.T) { + content, w := screenInAWindow(t, tab) + width := w.Content().MinSize().Width + if w.Padded() { + width += 2 * theme.Padding() + } + w.Resize(fyne.NewSize(width, referenceHeight)) + content.Refresh() + w.Resize(fyne.NewSize(width, referenceHeight)) + + menu := chooserUnder(t, content, text.FieldFormat()) + menu.Tapped(&fyne.PointEvent{}) + list := menu.Opened() + pop := popUpIn(w.Canvas().Overlays().Top()) + if list == nil || pop == nil { + t.Fatal("pressing the format menu put no list on the canvas") + } + box := menu.Size().Width + if pop.Size().Width <= box { + t.Errorf("the list is %.1f px wide under a box %.1f px wide - the names beside the formats need it wider", + pop.Size().Width, box) + } + left, right := pop.Position().X, pop.Position().X+pop.Size().Width + if left < 0 || right > w.Canvas().Size().Width { + t.Errorf("the list runs from x=%.1f to x=%.1f in a window %.1f px wide", left, right, w.Canvas().Size().Width) + } + rows := 0 + for _, row := range list.DrawnRows() { + rows++ + if need := row.MinSize().Width; row.Size().Width < need-0.5 { + t.Errorf("the row for %q is %.1f px wide and needs %.1f, so its words are cut off", + row.Label(), row.Size().Width, need) + } + } + if rows == 0 { + t.Fatal("the open list drew no rows, so none could be measured") + } + }) + } +} + +// TestAListWiderThanTheRoomBesideItsBoxMovesLeft asks the arithmetic directly. +// Every box with a list wider than itself stands in a form's first column +// today, so no screen reaches the move and a screen level guard would be +// green without having been there. +func TestAListWiderThanTheRoomBesideItsBoxMovesLeft(t *testing.T) { + const canvasWidth, box, wide = 800, 180, 340 + + if left, width := parts.ColumnForList(canvasWidth, 20, box, wide); left != 20 || width != wide { + t.Errorf("a list with room beside its box went to x=%.1f at %.1f px, where it belongs at x=20 at %d", left, width, wide) + } + + left, width := parts.ColumnForList(canvasWidth, 600, box, wide) + switch { + case width != wide: + t.Errorf("a list with room in the window was cut from %d to %.1f px", wide, width) + case left >= 600: + t.Errorf("a list %d px wide under a box at x=600 stayed at x=%.1f and ends at %.1f in a window %d px wide", + wide, left, left+width, canvasWidth) + case left+width >= canvasWidth: + t.Errorf("a list moved left ends at x=%.1f, on the edge of a window %d px wide rather than inside it", left+width, canvasWidth) + } + + // No wider than its box: it stands where it always has, even with the box + // closer to the edge than the gap a wider list keeps. + for _, at := range []float32{600, canvasWidth - box} { + if left, width := parts.ColumnForList(canvasWidth, at, box, box); left != at || width != box { + t.Errorf("a list as wide as its box at x=%.1f went to x=%.1f at %.1f px", at, left, width) + } + } + + // Wider than the whole window: cut to it, with the gap kept on both sides. + const narrow = 300 + if left, width := parts.ColumnForList(narrow, 10, box, wide); left <= 0 || left+width >= narrow || width < box { + t.Errorf("a list %d px wide in a window %d px wide went from x=%.1f to x=%.1f", wide, narrow, left, left+width) + } +} + +// TestTheFilterFindsAFormatByAWordOfItsName types words of names. A word of +// the name counts from its START, the rule the headings follow: "a" keeps avif +// for AV1, and does not keep gif for the a inside Graphics - anywhere in the +// name, one letter would keep most of the list. Words part at anything that is +// not a letter or a digit, so "office" finds "(Office" and "separated" finds +// "Comma-Separated". +func TestTheFilterFindsAFormatByAWordOfItsName(t *testing.T) { + _, _, list, filter := openFormatList(t) + for typed, want := range map[string][]string{ + "excel": {"xlsx"}, + "office": {"docx", "pptx", "xlsx"}, + "separated": {"csv"}, + "vector": {"svg"}, + } { + typeInto(filter, typed) + got := valuesOf(list) + slices.Sort(got) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("%q typed keeps %v, where the names say %v", typed, got, want) + } + } + typeInto(filter, "a") + got := valuesOf(list) + if !slices.Contains(got, "avif") { + t.Errorf("a typed does not keep avif, whose name starts with AV1: %v", got) + } + if slices.Contains(got, "gif") { + t.Errorf("a typed keeps gif, for the a inside Graphics Interchange Format - a name counts from the start of its words: %v", got) + } +} + +// TestTheListSaysWhatToDoWhenNothingMatches types what no format holds. The +// one row left is the sentence from the text package, which says what to do as +// well as what happened (review of #127), drawn as a sentence - the size of +// one and not bold - rather than as the heading of a group with nothing under +// it, and not cut off by the list it stands in. +func TestTheListSaysWhatToDoWhenNothingMatches(t *testing.T) { + _, _, list, filter := openFormatList(t) + typeInto(filter, "zz") + rows := list.DrawnRows() + if len(rows) != 1 || !rows[0].Heading() || rows[0].Label() != text.ListNothingMatches() { + labels := make([]string, 0, len(rows)) + for _, r := range rows { + labels = append(labels, r.Label()) + } + t.Fatalf("zz typed leaves %q, where it should leave the one sentence %q", labels, text.ListNothingMatches()) + } + words := drawnWords(rows[0]) + if len(words) != 1 { + t.Fatalf("the sentence is drawn as %d pieces of text", len(words)) + } + sentence := words[0] + if sentence.TextStyle.Bold || sentence.TextSize != parts.Theme().Size(theme.SizeNameText) { + t.Errorf("the sentence is drawn at %.0f px, bold %v - the look of a heading rather than of a sentence", + sentence.TextSize, sentence.TextStyle.Bold) + } + if need := fyne.MeasureText(sentence.Text, sentence.TextSize, sentence.TextStyle).Width; sentence.Position().X+need > rows[0].Size().Width { + t.Errorf("the sentence needs %.1f px from x=%.1f in a row %.1f px wide, so it is cut off", + need, sentence.Position().X, rows[0].Size().Width) + } +} diff --git a/internal/guard/formatnames_test.go b/internal/guard/formatnames_test.go new file mode 100644 index 00000000..7f12e899 --- /dev/null +++ b/internal/guard/formatnames_test.go @@ -0,0 +1,83 @@ +package guard + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" +) + +// A format's name is shown by every command that lists formats, and shown +// where a reader looks for it. +// +// The name went into the registry on 2026-09-24 so that the window and the +// command line say one thing (D1): somebody who meets "jxl" in either can learn +// it is JPEG XL. A name the registry carries and a surface drops is the window +// knowing something the command line does not, which is what putting it in the +// registry rather than beside the window's list was meant to prevent. +// +// The table is checked by COLUMN rather than by the name appearing somewhere +// in its row. "ZIP" or "YAML" appearing anywhere would pass a containment +// check whether or not the column held them. +func TestEveryFormatIsNamedWhereverTheCommandLineListsIt(t *testing.T) { + all := format.All() + if len(all) == 0 { + t.Fatal("no format is registered - this guard would pass without checking anything") + } + + code, table, errOut := run(t, "formats") + if code != cli.ExitOK { + t.Fatalf("tfg formats: exit %d: %s", code, errOut) + } + lines := strings.Split(table, "\n") + column := strings.Index(lines[0], "NAME") + if column < 0 { + t.Fatalf("the table has no NAME column:\n%s", lines[0]) + } + rows := map[string]string{} + for _, line := range lines[1:] { + if id, _, found := strings.Cut(line, " "); found { + rows[id] = line + } + } + for _, d := range all { + row, found := rows[d.ID] + switch { + case !found: + t.Errorf("tfg formats has no row for %s", d.ID) + case len(row) < column || !strings.HasPrefix(row[column:], d.Name+" "): + t.Errorf("the row for %s does not carry %q under NAME:\n%s\n%s", d.ID, d.Name, lines[0], row) + } + } + + code, listed, errOut := run(t, "formats", "--json") + if code != cli.ExitOK { + t.Fatalf("tfg formats --json: exit %d: %s", code, errOut) + } + var entries []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(listed), &entries); err != nil { + t.Fatalf("tfg formats --json is not a list: %v", err) + } + named := map[string]string{} + for _, e := range entries { + named[e.ID] = e.Name + } + for _, d := range all { + if named[d.ID] != d.Name { + t.Errorf("tfg formats --json names %s %q, the registry %q", d.ID, named[d.ID], d.Name) + } + code, one, errOut := run(t, "formats", d.ID) + if code != cli.ExitOK { + t.Fatalf("tfg formats %s: exit %d: %s", d.ID, code, errOut) + } + if !strings.Contains(one, "\n name "+d.Name+"\n") { + t.Errorf("tfg formats %s does not give the name %q on a line of its own:\n%s", d.ID, d.Name, one) + } + } +} diff --git a/internal/guard/formcolumns_test.go b/internal/guard/formcolumns_test.go index d3e74d30..9f603e77 100644 --- a/internal/guard/formcolumns_test.go +++ b/internal/guard/formcolumns_test.go @@ -211,9 +211,16 @@ func TestARefusalAboutWhatIsInTheDirectoryStandsUnderItAndOpensIt(t *testing.T) } // TestARefusalStandsUnderItsRowAcrossIt asks where the sentence goes: under -// the field it is about, starting on that field's edge, and reaching past the -// field's own column - measured in the real window, the same sentence broke -// into three lines inside a 185 px column and pushed the form down. +// the row of the field it is about, starting on the row's left edge, and +// reaching past the field's own column - measured in the real window, the same +// sentence broke into three lines inside a 185 px column and pushed the form +// down. +// +// The row's edge rather than the field's since 2026-09-24. Started on its own +// field's edge, a refusal about the second field of a row stood a column in +// from one about the first, and two of them read as a staircase (review +// UI-011). The size box is the second field of its row, so this asks the +// case that moved: the sentence about it starts under Format, not under Size. func TestARefusalStandsUnderItsRowAcrossIt(t *testing.T) { ourTheme(t) generate := window.NewGenerate(newFakeHost(t)) @@ -236,8 +243,16 @@ func TestARefusalStandsUnderItsRowAcrossIt(t *testing.T) { if said.Y < box.Y+box.Height { t.Errorf("the refusal starts at y=%.1f, above the bottom of its box at y=%.1f", said.Y, box.Y+box.Height) } - if off := said.X - box.X; off > 1 || off < -1 { - t.Errorf("the refusal starts at x=%.1f and its box at x=%.1f - it belongs under the field it is about", said.X, box.X) + first, ok := objectBox(screen, chooserUnder(t, screen, text.FieldFormat())) + if !ok { + t.Fatal("the format box, the first of the size box's row, is not laid out") + } + if first.X >= box.X { + t.Fatalf("the format box at x=%.1f is not to the left of the size box at x=%.1f, so this guard is not asking about a field that moved", first.X, box.X) + } + if off := said.X - first.X; off > 1 || off < -1 { + t.Errorf("the refusal starts at x=%.1f and its row at x=%.1f - every refusal of a row starts on the row's edge, or two of them stand as a staircase", + said.X, first.X) } if said.Width <= box.Width { t.Errorf("the refusal is %.1f px wide and its box %.1f - it is laid inside the field's column, not across the row", @@ -303,26 +318,21 @@ func TestAFoldedSectionOpensForARefusalAboutItsField(t *testing.T) { } } -// TestAGroupOfSettingsIsFramedWithARailOfItsKind asks each kind of group for -// the colour of the rail down its left edge: the accent for a format's -// settings, the warning colour for a damage's, the colour of a name for notes. -// The rail is what tells the two groups apart once both are open - the -// owner's report was that nothing did. -func TestAGroupOfSettingsIsFramedWithARailOfItsKind(t *testing.T) { - for _, kind := range []struct { - name string - kind parts.GroupKind - ink fyne.ThemeColorName - }{ - {"a format's settings", parts.GroupSettings, theme.ColorNamePrimary}, - {"a damage's settings", parts.GroupDamage, theme.ColorNameWarning}, - {"notes for the manifest", parts.GroupNotes, parts.ColorNameLabel}, - } { - group := parts.NewInnerFoldingOf(kind.kind, "Settings", parts.Prose("inside")).Object() - want := parts.PaletteColour(kind.ink, theme.VariantDark) - if !drawsFill(group, want) { - t.Errorf("%s: no rail drawn in %s", kind.name, kind.ink) - } +// TestAGroupOfSettingsIsFramedWithTheOneNeutralRail asks a group of settings +// for the rail down its left edge: drawn, and in the neutral colour of a +// field's name - the one colour every rail has had since 2026-09-24. Until +// then the rail carried the kind of the group, the primary colour for a +// format's settings among them, and that blue read as "this one is chosen" +// beside the main button and the keyboard's mark (review UI-009, the owner +// chose grey for all). So the primary colour is asked for as well, and must be +// absent. +func TestAGroupOfSettingsIsFramedWithTheOneNeutralRail(t *testing.T) { + group := parts.NewInnerFolding("Settings", parts.Prose("inside")).Object() + if !drawsFill(group, parts.PaletteColour(parts.ColorNameLabel, theme.VariantDark)) { + t.Error("a group of settings draws no rail in the colour of a field's name") + } + if drawsFill(group, parts.PaletteColour(theme.ColorNamePrimary, theme.VariantDark)) { + t.Error("a group of settings draws something in the primary colour, the colour of the main button and the keyboard's mark") } } diff --git a/internal/guard/guitext_test.go b/internal/guard/guitext_test.go index c6c84913..81c0646e 100644 --- a/internal/guard/guitext_test.go +++ b/internal/guard/guitext_test.go @@ -64,6 +64,7 @@ var notWords = map[string]string{ `"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", + `"heart.svg"`: "the name the toolkit files the heart 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 " + diff --git a/internal/guard/listheight_test.go b/internal/guard/listheight_test.go new file mode 100644 index 00000000..17e67081 --- /dev/null +++ b/internal/guard/listheight_test.go @@ -0,0 +1,75 @@ +package guard + +import ( + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/test" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// A list that opened downward is as tall as what its filter left, and one +// that opened upward keeps one height. The owner's report of 2026-09-24 from +// the running window was one format standing over a slab of empty grey: the +// list kept the height of all twenty six while somebody typed "jxl". Downward +// the box, the filter and every row stay put and only the foot moves. Upward +// the filter box at the top would move under the hands typing into it, which +// is why that list still does not. + +// TestAListOpenedDownwardShrinksToWhatTheFilterLeft types into the format +// list on the first screen, which opens under its box. +func TestAListOpenedDownwardShrinksToWhatTheFilterLeft(t *testing.T) { + cv, menu, list, filter := openFormatList(t) + pop := popUpIn(cv.Overlays().Top()) + if pop == nil { + t.Fatal("the list is not on the canvas") + } + box := fyne.CurrentApp().Driver().AbsolutePositionForObject(menu) + if pop.Position().Y < box.Y+menu.Size().Height-0.5 { + t.Fatalf("the format list opened at y=%.1f, above the foot of its box at y=%.1f - this guard is about a list that opened downward", + pop.Position().Y, box.Y+menu.Size().Height) + } + top, full := pop.Position().Y, pop.Size().Height + + typeInto(filter, "jxl") + want := list.HeadHeight() + 2*parts.ListRowHeight() // the heading and jxl + if got := pop.Size().Height; got > want+0.5 || got < want-0.5 { + t.Errorf("jxl typed leaves a heading and one format, %.1f px of list, and the list is %.1f px tall", want, got) + } + if pop.Position().Y != top { + t.Errorf("the list's top moved from y=%.1f to y=%.1f while it shrank - the filter box moved under the hands", top, pop.Position().Y) + } + + typeInto(filter, "") + if got := pop.Size().Height; got != full { + t.Errorf("the filter emptied, and the list is %.1f px tall where it opened at %.1f", got, full) + } +} + +// TestAListOpenedUpwardKeepsOneHeight puts the menu at the foot of a short +// window, where its list opens upward, and types into its filter. +func TestAListOpenedUpwardKeepsOneHeight(t *testing.T) { + ourTheme(t) + menu := parts.NewChooser(format.IDs(), nil) + w := test.NewTempWindow(t, container.NewBorder(nil, menu, nil, nil, container.NewVBox())) + w.Resize(fyne.NewSize(500, 400)) + menu.Tapped(&fyne.PointEvent{}) + list := menu.Opened() + pop := popUpIn(w.Canvas().Overlays().Top()) + if list == nil || pop == nil || list.Filter() == nil { + t.Fatal("the press opened no list with a filter") + } + box := fyne.CurrentApp().Driver().AbsolutePositionForObject(menu) + if pop.Position().Y >= box.Y { + t.Fatalf("the list opened at y=%.1f under a box at y=%.1f - this guard is about a list that opened upward", pop.Position().Y, box.Y) + } + top, height := pop.Position().Y, pop.Size().Height + typeInto(list.Filter(), "jxl") + if pop.Position().Y != top || pop.Size().Height != height { + t.Errorf("typing moved an upward list from y=%.1f, %.1f px tall, to y=%.1f, %.1f px - its filter box moved under the hands", + top, height, pop.Position().Y, pop.Size().Height) + } +} diff --git a/internal/guard/listwords_test.go b/internal/guard/listwords_test.go index a12026b8..fe8d9c97 100644 --- a/internal/guard/listwords_test.go +++ b/internal/guard/listwords_test.go @@ -14,8 +14,13 @@ import ( ) // The words in an open list without pictures start where the word in the box -// does, with the tick at the far end of the row - and a list WITH pictures -// keeps its tick in front, the picture next and the words after it. +// does, a list WITH pictures keeps a column in front of the picture and the +// words after it, and every row has its tick at the far end. +// +// The tick was in front on the list with pictures until 2026-09-24, when the +// owner asked for it on one side in every list (review UI-005) with the two +// reports below in front of him. Its column in front stays, empty, because +// that column is what the second report was about. // // Reported by the owner from the running window on 2026-09-16: the list of // formats looked right and the lists of outcomes and rules looked like words @@ -78,16 +83,21 @@ func TestTheWordsInAnOpenListStartWhereTheWordInTheBoxDoes(t *testing.T) { continue } words, tick, picture := piecesOfARow(t, row) + // The tick after everything the row writes, in both shapes - the + // owner's one side, 2026-09-24. Measured against where the words + // END as drawn, the name's included, not against the slot a text + // is given: the last piece of a row takes what is left of it. + if ends := endOfWords(row); tick.Position().X < ends { + t.Errorf("%s: the tick of row %q stands at %.1f, in front of words that end at %.1f - the tick stands at the end of every row", + tc.field, row.Label(), tick.Position().X, ends) + } if tc.pictured { - // Tick, picture, words: each starts where the one before it - // ends, a gap later, and the first of them at the gutter. - if tick.Position().X != parts.RowGutter() { - t.Errorf("%s: the tick of row %q stands at %.1f rather than at the gutter (%.1f) - the column that kept the picture and the word off the edge is gone", - tc.field, row.Label(), tick.Position().X, parts.RowGutter()) - } - if picture.Position().X <= tick.Position().X+tick.Size().Width { - t.Errorf("%s: the picture of row %q stands at %.1f, not after the tick's column ending at %.1f", - tc.field, row.Label(), picture.Position().X, tick.Position().X+tick.Size().Width) + // An empty column at the gutter, then the picture, then the + // words - so the picture and the word stand where they stood + // when the tick filled that column (the report of 2026-09-21). + if column := parts.RowGutter() + tick.Size().Width; picture.Position().X < column { + t.Errorf("%s: the picture of row %q stands at %.1f, inside the column kept in front of it, which ends at %.1f - the picture and the word moved a column to the left", + tc.field, row.Label(), picture.Position().X, column) } if words.Position().X <= picture.Position().X+picture.Size().Width { t.Errorf("%s: the words of row %q start at %.1f, not after the picture ending at %.1f", @@ -101,10 +111,6 @@ func TestTheWordsInAnOpenListStartWhereTheWordInTheBoxDoes(t *testing.T) { } t.Logf("%s: row %q words at %.1f, the box's word at %.1f (the toolkit's inset, logged and not held)", tc.field, row.Label(), drv.AbsolutePositionForObject(words).X, boxWord) - if tick.Position().X < words.Position().X+words.Size().Width { - t.Errorf("%s: the tick of row %q stands at %.1f, in front of words ending at %.1f - the column it keeps pushes every list's words off the box's word", - tc.field, row.Label(), tick.Position().X, words.Position().X+words.Size().Width) - } } list.TypedKey(&fyne.KeyEvent{Name: fyne.KeyEscape}) } @@ -130,6 +136,18 @@ func wordsInTheBox(t *testing.T, menu *parts.Chooser) *canvas.Text { return nil } +// endOfWords is where the last visible piece of text in a row ends as drawn: +// its position plus the width of its words, not of the slot it was given. +func endOfWords(row *parts.ListRow) float32 { + var end float32 + for _, o := range test.WidgetRenderer(row).Objects() { + if words, ok := o.(*canvas.Text); ok && words.Visible() && words.Text != "" { + end = max(end, words.Position().X+fyne.MeasureText(words.Text, words.TextSize, words.TextStyle).Width) + } + } + return end +} + // piecesOfARow is what one row of a list draws: its words, its tick and its // picture, the last two told apart by what they show - the tick is the // toolkit's confirm icon, and the picture is whatever kind the row has. diff --git a/internal/guard/lookreview_test.go b/internal/guard/lookreview_test.go new file mode 100644 index 00000000..87651fd4 --- /dev/null +++ b/internal/guard/lookreview_test.go @@ -0,0 +1,256 @@ +package guard + +import ( + "image" + "image/color" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// Guards for points of the look review of 2026-09-24 +// (docs/GUI-LOOK-REVIEW-2026-09-24.md) that nothing else holds. Each one reads +// what is drawn, because each of them was a thing correctly written in the code +// and wrong on the screen (GUI rule 10). + +// TestAButtonBesideABoxIsDrawnAsTallAsTheBox lays a box to type in and a button +// at the same height and reads the rows each one paints. Until 2026-09-24 the +// button's edge was stroked across its bounds and the box's inside them, so +// Choose stood a pixel taller than the directory box at each end (UI-012). +func TestAButtonBesideABoxIsDrawnAsTallAsTheBox(t *testing.T) { + ourTheme(t) + e := parts.NewEntry() + e.SetText("/tfg/out") + boxed, _ := parts.WithRing(e) + b := parts.NewButton(parts.Secondary, "Choose...", func() {}) + w := test.NewTempWindow(t, container.NewWithoutLayout(boxed, b)) + height := boxed.MinSize().Height + boxed.Resize(fyne.NewSize(140, height)) + boxed.Move(fyne.NewPos(10, 10)) + b.Resize(fyne.NewSize(100, height)) + b.Move(fyne.NewPos(170, 10)) + w.Resize(fyne.NewSize(300, 60)) + picture := w.Canvas().Capture() + + page := parts.PaletteColour(theme.ColorNameBackground, theme.VariantDark) + rows := func(x int) (first, last int) { + first, last = -1, -1 + for y := 0; y < picture.Bounds().Dy(); y++ { + if !sameColour(picture.At(x, y), page) { + if first < 0 { + first = y + } + last = y + } + } + return first, last + } + boxTop, boxBottom := rows(60) + buttonTop, buttonBottom := rows(220) + if boxTop < 0 || buttonTop < 0 { + t.Fatal("the box or the button drew nothing where it stands, so nothing was compared") + } + if boxTop != buttonTop || boxBottom != buttonBottom { + t.Errorf("laid out at one height, the box is drawn over rows %d to %d and the button over %d to %d", + boxTop, boxBottom, buttonTop, buttonBottom) + } +} + +// TestDonateDrawsARedHeartThatGoesOutWhenOff asks both Donate buttons - the +// bar's and the About card's - for the heart, and a Donate button drawn alone +// for red on the screen: there at rest, gone when it is switched off, since a +// control off is quieter than at rest (UI-015, the owner's choice of red). +func TestDonateDrawsARedHeartThatGoesOutWhenOff(t *testing.T) { + for _, tab := range []string{text.TabOneTarget(), text.TabAbout()} { + content, _ := screenInAWindow(t, tab) + donate := buttonNamed(content, text.ButtonDonate()) + if donate == nil { + t.Fatalf("the %s screen has no %q button", tab, text.ButtonDonate()) + } + if donate.Icon != parts.HeartIcon() { + t.Errorf("%q on the %s screen carries no heart", text.ButtonDonate(), tab) + } + } + + ourTheme(t) + b := parts.NewButton(parts.Quiet, "Donate", func() {}).WithHeart() + w := test.NewTempWindow(t, container.NewWithoutLayout(b)) + b.Resize(b.MinSize()) + w.Resize(b.MinSize().Add(fyne.NewSquareSize(20))) + red := parts.PaletteColour(theme.ColorNameError, theme.VariantDark) + if n := pixelsNear(w.Canvas().Capture(), red); n < 20 { + t.Errorf("a Donate button with its heart draws %d pixels of the red the heart is filled with", n) + } + b.Disable() + if n := pixelsNear(w.Canvas().Capture(), red); n != 0 { + t.Errorf("switched off, a Donate button still draws %d pixels of red", n) + } +} + +// TestTheBarsRailStandsOnTheFormsLeftEdge reads where Donate's ink starts - +// the heart, the first thing it draws - and where the form's first field name +// does. The rail stood at the bar's own left edge from the owner's decision of +// 2026-08-19 until the owner reversed it on 2026-09-24 (UI-006): one left edge +// for everything in the bar that is not centred, and the form's. +// +// The ink and not the button's box: a quiet button draws a surface only under +// the pointer, so its box is invisible and its words start the room round +// them further in. The first version of this guard measured the box, passed, +// and the render showed the heart 16 px right of the line under it. +func TestTheBarsRailStandsOnTheFormsLeftEdge(t *testing.T) { + for _, tab := range []string{text.TabOneTarget(), text.TabRecipe()} { + content, _ := screenInAWindow(t, tab) + donate := buttonNamed(content, text.ButtonDonate()) + if donate == nil { + t.Fatalf("the %s screen has no %q button", tab, text.ButtonDonate()) + } + rail, ok := objectBox(content, donate) + name, found := labelBox(content, text.FieldFormat()) + if !ok || !found { + t.Fatalf("on the %s screen, %q or the name %q is not laid out", tab, text.ButtonDonate(), text.FieldFormat()) + } + ink := float32(-1) + for _, o := range test.WidgetRenderer(donate).Objects() { + if picture, is := o.(*canvas.Image); is && picture.Visible() { + ink = rail.X + picture.Position().X + } + } + if ink < 0 { + t.Fatalf("%q on the %s screen draws no heart, so where its ink starts cannot be read", text.ButtonDonate(), tab) + } + if off := ink - name.X; off > 1 || off < -1 { + t.Errorf("on the %s screen the ink of %q starts at x=%.1f and the form's names at x=%.1f", tab, text.ButtonDonate(), ink, name.X) + } + } +} + +// TestABarAtItsNarrowestHoldsTheRailAndTheButtonsSideBySide lays an action bar +// out alone at the width it asks for, and reads the rail and the run buttons. +// +// Alone, because on a screen the form under the bar is wider than the bar +// needs - measured on 2026-09-24, the smallest window is 501 px and the form +// decides it - so a bar that asked for too little would still be laid out wide +// enough, and a guard of the screen would pass while the bar's own arithmetic +// was wrong. Since the run buttons give way to the right of centre rather than +// widening the bar, the bar's least width is the rail, a gap and the buttons +// once, and at that width the buttons stand clear of the rail and inside the +// bar. +func TestABarAtItsNarrowestHoldsTheRailAndTheButtonsSideBySide(t *testing.T) { + ourTheme(t) + rail := container.NewHBox(parts.NewButton(parts.Quiet, "Donate", func() {}).WithHeart().InTheBar()) + preview := parts.NewButton(parts.Secondary, "Preview", func() {}).InTheBar() + generate := parts.NewButton(parts.Primary, "Generate", func() {}).InTheBar() + bar := parts.ActionBar(rail, parts.ButtonRow(preview, generate)) + w := test.NewTempWindow(t, container.NewWithoutLayout(bar)) + least := bar.MinSize() + bar.Resize(least) + w.Resize(least.Add(fyne.NewSquareSize(40))) + + drv := fyne.CurrentApp().Driver() + barLeft := drv.AbsolutePositionForObject(bar).X + railEnds := drv.AbsolutePositionForObject(rail).X + rail.MinSize().Width + rowStarts := drv.AbsolutePositionForObject(preview).X + rowEnds := drv.AbsolutePositionForObject(generate).X + generate.Size().Width + if rowStarts < railEnds { + t.Errorf("at its least width of %.1f px the bar's run buttons start at x=%.1f, under a rail that ends at x=%.1f", + least.Width, rowStarts, railEnds) + } + if rowEnds > barLeft+least.Width { + t.Errorf("at its least width of %.1f px the bar's run buttons end at x=%.1f, past its right edge at x=%.1f", + least.Width, rowEnds, barLeft+least.Width) + } +} + +// A rail can grow while the bar keeps its size - its words change - and then +// the toolkit lays out again only what holds the rail, at the size each +// already has (fyne v2.8.1 internal/driver/common/canvas.go, updateLayout, +// called from EnsureMinSize every frame). The row of run buttons is not among +// them, so it kept the room cleared for the shorter rail: 16.6 px of overlap, +// found from an outside review of #136 (docs/REVIEW-136-2026-09-24.md). +// +// The test driver runs no such pass, so the guard runs it: every container +// from the rail's button up to the bar, innermost first, at its own size. +func TestARailThatGrowsInPlaceMovesTheRunButtonsOnWithIt(t *testing.T) { + ourTheme(t) + donate := parts.NewButton(parts.Quiet, "Donate", func() {}).InTheBar() + rail := container.NewHBox(donate) + preview := parts.NewButton(parts.Secondary, "Preview", func() {}).InTheBar() + bar := parts.ActionBar(rail, parts.ButtonRow(preview, parts.NewButton(parts.Primary, "Generate", func() {}).InTheBar())) + w := test.NewTempWindow(t, container.NewWithoutLayout(bar)) + // Wide enough for the longer rail, so the bar keeps its size when it grows. + donate.SetText("Donate more") + wide := bar.MinSize() + donate.SetText("Donate") + bar.Resize(wide) + w.Resize(wide.Add(fyne.NewSquareSize(40))) + + drv := fyne.CurrentApp().Driver() + rowBefore := drv.AbsolutePositionForObject(preview).X + donate.SetText("Donate more") + chain := containersHolding(bar, donate) + if len(chain) == 0 { + t.Fatal("the rail is not inside the bar it was handed to") + } + for i := len(chain) - 1; i >= 0; i-- { + if chain[i].Layout != nil { + chain[i].Layout.Layout(chain[i].Objects, chain[i].Size()) + } + } + if bar.Size() != wide { + t.Fatalf("the bar went from %v to %v, so this is a resize and not the case in question", wide, bar.Size()) + } + railEnds := drv.AbsolutePositionForObject(rail).X + rail.MinSize().Width + if railEnds <= rowBefore { + t.Fatalf("the longer rail ends at x=%.1f, short of where the buttons stood (x=%.1f) - "+ + "the words did not grow it far enough to ask the question", railEnds, rowBefore) + } + if rowStarts := drv.AbsolutePositionForObject(preview).X; rowStarts < railEnds { + t.Errorf("the rail grew in place to end at x=%.1f and the run buttons still start at x=%.1f, under it", + railEnds, rowStarts) + } +} + +// containersHolding is the path of containers from root down to the one that +// holds target, outermost first, or nothing when target is not under root. +func containersHolding(root, target fyne.CanvasObject) []*fyne.Container { + c, ok := root.(*fyne.Container) + if !ok { + return nil + } + for _, child := range c.Objects { + if child == target { + return []*fyne.Container{c} + } + if below := containersHolding(child, target); below != nil { + return append([]*fyne.Container{c}, below...) + } + } + return nil +} + +// pixelsNear counts the pixels of a picture within a small distance of one +// colour - the edge of a shape is blended, its middle is the colour itself. +func pixelsNear(picture image.Image, want color.Color) int { + wr, wg, wb, _ := want.RGBA() + near := func(a, b uint32) bool { + d := int(a>>8) - int(b>>8) + return d > -24 && d < 24 + } + n := 0 + for y := picture.Bounds().Min.Y; y < picture.Bounds().Max.Y; y++ { + for x := picture.Bounds().Min.X; x < picture.Bounds().Max.X; x++ { + r, g, b, _ := picture.At(x, y).RGBA() + if near(r, wr) && near(g, wg) && near(b, wb) { + n++ + } + } + } + return n +} diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index 937f500b..1257fde7 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -76,7 +76,6 @@ var notProvenByMutation = map[string]bool{ "TestCommandLineIsAsciiOnly": true, "TestDryRunWritesNothingAtAll": true, "TestEveryEndingUsesACodeFromTheTable": true, - "TestEveryFormatDeclaresTheFullSet": true, "TestGeneratingTwiceGivesTheSameBytes": true, "TestLayeringHoldsForEveryPackage": true, "TestNoNetworkImports": true, diff --git a/internal/guard/openlist_test.go b/internal/guard/openlist_test.go index 75e076c4..b95b5ae5 100644 --- a/internal/guard/openlist_test.go +++ b/internal/guard/openlist_test.go @@ -21,46 +21,25 @@ import ( // defaults to. Three surfaces within four L* of each other, one of them // floating. // -// The threshold is the same shape as the one for the stacked surfaces below the -// page: a menu is the thing furthest from everything, so it has to clear the -// highest surface it opens over rather than merely differ from the page. +// Until 2026-09-24 this was held by lightness: the list stood on the lightest +// surface of the palette with no edge and no shade, and had to clear the +// highest surface it opens over by half of what separates the page from a box. +// The owner's report from the running window was that it looked like a plain +// grey block, and of three looks drawn side by side he chose a card: a box's +// surface, a box's edge, a panel's corner and a shade below it. So the list is +// told from the form by its edge and its shade now, and this asks for those on +// the list as drawn - the palette colour it used to measure is one the list no +// longer paints, and a guard of it would have gone on passing about nothing. func TestAnOpenListIsToldFromTheFormBehindIt(t *testing.T) { - for _, variant := range []struct { - name string - v fyne.ThemeVariant - }{{"dark", theme.VariantDark}, {"light", theme.VariantLight}} { - page := parts.PaletteColour(theme.ColorNameBackground, variant.v) - panel := parts.PaletteColour(parts.ColorNamePanel, variant.v) - input := parts.PaletteColour(theme.ColorNameInputBackground, variant.v) - menu := parts.PaletteColour(theme.ColorNameMenuBackground, variant.v) + _, _, list, _ := openFormatList(t) + floatsAsACard(t, test.WidgetRenderer(list).Objects()[0], "an open list") - // The furthest surface it can open over. On the dark palette that is an - // input box, on the light one it is the page - which is why this is - // asked as "the highest of them" rather than named. - highest, from := panel, "the panel" - if lightnessGap(input, page) > lightnessGap(panel, page) { - highest, from = input, "an input box" - } - - gap := lightnessGap(menu, highest) - // Half of what separates the page from an input box. The stack below is - // held to a third each, and a thing that floats has to do better than a - // thing that lies flat. - least := lightnessGap(input, page) / 2 - if gap < least { - t.Errorf("%s: an open list is %.1f L* from %s it opens over, and %.1f is the least that reads as floating", - variant.name, gap, from, least) - } - - // And what is written on it stays readable. A surface that moved - // without its text being re-measured is the defect the palette guard - // caught on its first day. - if got := contrast(parts.PaletteColour(theme.ColorNameForeground, variant.v), menu); got < 4.5 { - t.Errorf("%s: the values in an open list are %.2f:1 on it, under the 4.5 a reader needs", - variant.name, got) - } - t.Logf("%s: an open list is %.1f L* above %s, values on it at %.2f:1", - variant.name, gap, from, contrast(parts.PaletteColour(theme.ColorNameForeground, variant.v), menu)) + // And what is written on it stays readable. A surface that moved without + // its text being re-measured is the defect the palette guard caught on its + // first day. + face := parts.PaletteColour(theme.ColorNameInputBackground, theme.VariantDark) + if got := contrast(parts.PaletteColour(theme.ColorNameForeground, theme.VariantDark), face); got < 4.5 { + t.Errorf("the values in an open list are %.2f:1 on it, under the 4.5 a reader needs", got) } } diff --git a/internal/guard/registry_test.go b/internal/guard/registry_test.go index ef7c0014..84540cdf 100644 --- a/internal/guard/registry_test.go +++ b/internal/guard/registry_test.go @@ -32,6 +32,7 @@ func TestEveryFormatDeclaresTheFullSet(t *testing.T) { if d.ID == "" { t.Error("no id") } + nameShowsAsItIs(t, d.Name) if !strings.HasPrefix(d.Extension, ".") { t.Errorf("extension %q does not start with a dot", d.Extension) } @@ -91,6 +92,35 @@ func TestEveryFormatDeclaresTheFullSet(t *testing.T) { } } +// nameShowsAsItIs asks what a format's name has to be to be shown: present, +// because an empty one would leave a row in the window with a gap where the +// name goes and a column of the format table blank, and printable as it is. +// +// ASCII because "tfg formats" prints it and the command line is ASCII +// (asciiRequired), and that also keeps lowering it the same length, which is +// what lets the window mark the typed letters in bold. The punctuation rule +// because it is text a person reads, and the gate that enforces the rule reads +// string literals only in the command line packages - the names live in the +// format packages, where it never looks. +func nameShowsAsItIs(t *testing.T, name string) { + t.Helper() + if name == "" { + t.Error("no name - the window and \"tfg formats\" show one beside every identifier") + return + } + if strings.TrimSpace(name) != name { + t.Errorf("the name %q has space at an edge, which the columns it stands in would show", name) + } + for _, r := range name { + if r < ' ' || r > '~' { + t.Errorf("the name %q holds %q, and the command line prints only ASCII", name, r) + } + } + for _, fault := range proseFaults(name, false) { + t.Errorf("the name %q holds %s", name, fault) + } +} + // The registry is the source of the confirmed minimum and the format document // keeps an approximate table beside it. This is what stops the two drifting. // diff --git a/internal/guard/sectionsurface_test.go b/internal/guard/sectionsurface_test.go index 9dc01154..0365f5ed 100644 --- a/internal/guard/sectionsurface_test.go +++ b/internal/guard/sectionsurface_test.go @@ -169,7 +169,7 @@ func TestEachSurfaceIsToldFromTheOneUnderIt(t *testing.T) { surface fyne.ThemeColorName where string }{ - {theme.ColorNameForeground, theme.ColorNameMenuBackground, "a row of an open list"}, + {theme.ColorNameForeground, theme.ColorNameInputBackground, "a row of an open list, on the card a list floats on"}, {parts.ColorNameLabel, parts.ColorNamePanel, "a panel"}, {theme.ColorNameDisabled, theme.ColorNameInputBackground, "a box to type in"}, {theme.ColorNamePlaceHolder, theme.ColorNameInputBackground, "a box to type in"}, diff --git a/internal/guard/site_test.go b/internal/guard/site_test.go index ea86b7b3..f2564178 100644 --- a/internal/guard/site_test.go +++ b/internal/guard/site_test.go @@ -108,6 +108,7 @@ func factsFromTheProgram(t *testing.T) site.Facts { } formats = append(formats, site.Format{ ID: d.ID, + Name: d.Name, Extension: d.Extension, Fidelity: string(d.Fidelity), Determinism: string(d.Determinism), diff --git a/internal/guard/switchedoff_test.go b/internal/guard/switchedoff_test.go new file mode 100644 index 00000000..af07a458 --- /dev/null +++ b/internal/guard/switchedoff_test.go @@ -0,0 +1,157 @@ +package guard + +import ( + "image" + "image/color" + "math" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" +) + +// A control switched off is never drawn brighter than the same control at +// rest. The form is switched off for the length of every run, so this is what +// a person sees each time they press Generate. +// +// It was, in three controls at once, found by measuring the catalogue on +// 2026-09-24 (docs/GUI-LOOK-REVIEW-2026-09-24.md, UI-001 to UI-003): the quiet +// button and the i beside a field name rest in the hint's ink and went to the +// brighter disabled ink when off, the switch's edge went from the edge of a +// box to that same bright ink, and the toolkit draws a switched off box's +// border in it too. A frozen form read as more there than a live one. +// +// Measured on the pixels, because every one of the three was a colour +// correctly named in the code and wrong on the screen (GUI rule 10). A box to +// type in is measured along its top edge only: its value is the brightest +// thing in it on or off, and a comparison of the whole box would be decided by +// the words and blind to the edge. +func TestAControlSwitchedOffIsNeverBrighterThanAtRest(t *testing.T) { + app := test.NewApp() + app.Settings().SetTheme(parts.Theme()) + t.Cleanup(func() { test.NewApp() }) + + type control struct { + name string + build func() (fyne.CanvasObject, fyne.Disableable) + edge bool + } + for _, c := range []control{ + {"a quiet button", func() (fyne.CanvasObject, fyne.Disableable) { + b := parts.NewButton(parts.Quiet, "Donate", func() {}) + return b, b + }, false}, + {"the i beside a field name", func() (fyne.CanvasObject, fyne.Disableable) { + b := parts.NewGlyphButton(theme.InfoIcon(), func() {}) + return b, b + }, false}, + {"a switch", func() (fyne.CanvasObject, fyne.Disableable) { + s := parts.NewToggle(nil) + return s, s + }, false}, + {"a box to type in", func() (fyne.CanvasObject, fyne.Disableable) { + e := parts.NewEntry() + e.SetText("2048") + boxed, _ := parts.WithRing(e) + return boxed, e + }, true}, + } { + t.Run(c.name, func(t *testing.T) { + obj, off := c.build() + rest := brightestOf(t, obj, c.edge) + off.Disable() + switched := brightestOf(t, obj, c.edge) + if switched > rest+0.002 { + t.Errorf("%s switched off is drawn at a relative luminance of %.3f and at rest at %.3f - off reads as more there than on", + c.name, switched, rest) + } + }) + } +} + +// TestASwitchThatIsOffStillShowsItsValue compares a ticked switch and an +// unticked one, both switched off. Until 2026-09-24 a switch that was off hid +// its tick whatever it held, so "Label in each file" ticked read as unticked +// for the length of every run. +func TestASwitchThatIsOffStillShowsItsValue(t *testing.T) { + app := test.NewApp() + app.Settings().SetTheme(parts.Theme()) + t.Cleanup(func() { test.NewApp() }) + + picture := func(on bool) image.Image { + s := parts.NewToggle(nil) + s.SetChecked(on) + s.Disable() + w := test.NewWindow(container.NewWithoutLayout(s)) + t.Cleanup(w.Close) + s.Resize(s.MinSize()) + w.Resize(fyne.NewSize(60, 60)) + return w.Canvas().Capture() + } + ticked, unticked := picture(true), picture(false) + differ := 0 + for y := 0; y < parts.GlyphButton; y++ { + for x := 0; x < parts.GlyphButton; x++ { + if ticked.At(x, y) != unticked.At(x, y) { + differ++ + } + } + } + if differ < parts.GlyphButton { + t.Errorf("a ticked switch and an unticked one, both switched off, differ in %d pixels - a run's frozen form does not show what it was frozen with", differ) + } +} + +// brightestOf draws a control alone on the page and gives the relative +// luminance of its brightest pixel - along its top edge only, away from the +// rounded corners, when edge is set. +func brightestOf(t *testing.T, obj fyne.CanvasObject, edge bool) float64 { + t.Helper() + w := test.NewWindow(container.NewWithoutLayout(obj)) + defer w.Close() + size := obj.MinSize().Max(fyne.NewSize(parts.NumericWidth, 0)) + obj.Resize(size) + obj.Move(fyne.NewPos(8, 8)) + w.Resize(size.Add(fyne.NewSize(16, 16))) + picture := w.Canvas().Capture() + + band := image.Rect(8, 8, 8+int(size.Width), 8+int(size.Height)) + if edge { + // The toolkit draws a box's border inside the box rather than on its + // edge - measured on 2026-09-24 at 4 px in - so the band is the room + // between the box's edge and where its words can start, and it keeps + // clear of the rounded corners. + inset := int(parts.RadiusField + parts.ControlInset) + band = image.Rect(8+inset, 8, 8+int(size.Width)-inset, 8+int(parts.ControlInset)) + } + if band.Empty() { + t.Fatalf("the control is %.0fx%.0f, which leaves nothing to measure", size.Width, size.Height) + } + var brightest float64 + for y := band.Min.Y; y < band.Max.Y; y++ { + for x := band.Min.X; x < band.Max.X; x++ { + brightest = math.Max(brightest, luminanceOf(picture.At(x, y))) + } + } + if brightest == luminanceOf(parts.PaletteColour(theme.ColorNameBackground, theme.VariantDark)) { + t.Fatal("nothing but the page was drawn where the control stands, so this guard is measuring nothing") + } + return brightest +} + +// luminanceOf is the relative luminance WCAG defines, of one colour. +func luminanceOf(c color.Color) float64 { + r, g, b, _ := c.RGBA() + channel := func(v uint32) float64 { + s := float64(v) / 0xffff + if s <= 0.03928 { + return s / 12.92 + } + return math.Pow((s+0.055)/1.055, 2.4) + } + return 0.2126*channel(r) + 0.7152*channel(g) + 0.0722*channel(b) +} diff --git a/internal/guard/testdata/screens/about.png b/internal/guard/testdata/screens/about.png index f9e4bfde..894c2d62 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 50012c6e..c3d230d8 100644 --- a/internal/guard/testdata/screens/about.xml +++ b/internal/guard/testdata/screens/about.xml @@ -30,27 +30,27 @@ - - - - - - - Testing Files Generator 0.3.0 - - - - - - - - - Generate test files, and know how the system under test should react to them. + + + + + + + + Testing Files Generator 0.3.0 + + + + + + Generate test files, and know how the system under test should react to them. + + - + @@ -94,7 +94,7 @@ - + @@ -124,7 +124,7 @@ - + @@ -138,10 +138,11 @@ - - - - Donate + + + + + Donate @@ -155,7 +156,7 @@ - + @@ -881,7 +882,7 @@ - + @@ -1078,7 +1079,7 @@ - + @@ -1122,8 +1123,8 @@ - - + + diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index 4ddc0deb..9ad4df7c 100644 Binary files a/internal/guard/testdata/screens/catalogue.png and b/internal/guard/testdata/screens/catalogue.png differ diff --git a/internal/guard/testdata/screens/catalogue.xml b/internal/guard/testdata/screens/catalogue.xml index 048e0afa..12416e84 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -1,7 +1,7 @@ - + - - + + @@ -20,12 +20,12 @@ - - - - + + + + Button - + @@ -101,7 +101,7 @@ - + Generate @@ -117,7 +117,7 @@ - + Generate @@ -133,7 +133,7 @@ - + Generate @@ -149,7 +149,7 @@ - + Generate @@ -165,7 +165,7 @@ - + Generate @@ -246,7 +246,7 @@ - Generate + Generate @@ -261,12 +261,95 @@ - + Write a label inside each generated file, including the ones that are far too small to hold it - + + + + + quiet, with the heart + + + + + + + + + Donate + + + + + + + + secondary, with the heart + + + + + + + + + Donate + + + + + + + + quiet, with the heart, disabled + + + + + + + + + Donate + + + + + + + + removing + + + + + + + + Remove + + + + + + + + removing, disabled + + + + + + + + Remove + + + + @@ -282,7 +365,7 @@ - + @@ -298,7 +381,7 @@ - + @@ -314,7 +397,7 @@ - + @@ -326,7 +409,7 @@ - + @@ -334,7 +417,7 @@ - + @@ -350,16 +433,19 @@ - - - - - (Select one) - - - + + + + + + (Select one) + + + + - + + @@ -373,16 +459,19 @@ - - - - - avif - - - + + + + + + avif + + + + - + + @@ -448,16 +537,19 @@ - - - - - (Select one) - - - + + + + + + (Select one) + + + + - + + @@ -471,16 +563,19 @@ - - - - - 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 + + + + - + + @@ -494,17 +589,20 @@ - - - - - zip - - - + + + + + + zip + + + + + - - + + @@ -512,7 +610,7 @@ - + @@ -592,7 +690,7 @@ - + @@ -626,12 +724,12 @@ - - - - + + + + Toggle - + @@ -702,7 +800,24 @@ - disabled + on, disabled + + + + + + + + + + + + + + + + + off, disabled @@ -710,7 +825,7 @@ - + @@ -718,7 +833,7 @@ - + @@ -858,7 +973,7 @@ - + @@ -876,7 +991,10 @@ - + + + + @@ -911,7 +1029,10 @@ - + + + + @@ -945,7 +1066,10 @@ - + + + + @@ -1024,69 +1148,72 @@ - + + + + - avif + avif - bmp + bmp - csv + csv - + - docx + docx - gif + gif - html + html - ico + ico - jpg + jpg - json + json - jxl + jxl - log + log - md + md @@ -1107,7 +1234,10 @@ - + + + + @@ -1135,184 +1265,213 @@ - - - - - - - - - - Archives · 2 + + + + + + + + + + + + + Archives · 2 - - + + - targz + targz + tar + gzip - - + + - zip + zip + ZIP - - - Documents · 4 + + + Documents · 4 - - + + - docx + docx + Word (Office Open XML) - - + + - pdf + pdf + Portable Document Format - - + + - pptx + pptx + PowerPoint (Office Open XML) - - + + - xlsx + xlsx + Excel (Office Open XML) - - - Pictures · 10 + + + Pictures · 10 - - + + - avif + avif + AV1 Image File Format - - + + - bmp + bmp + Windows Bitmap - - + + - gif + gif + Graphics Interchange Format - - + + - ico + ico + Windows Icon - - + + - jpg + jpg + JPEG - - + + - jxl + jxl + JPEG XL - - - + + + - png + png + Portable Network Graphics - - + + - svg + svg + Scalable Vector Graphics - - + + - tiff + tiff + Tagged Image File Format - - + + - webp + webp + WebP - - - Sound · 1 + + + Sound · 1 - - + + - wav + wav + Waveform Audio - - - Text and data · 9 + + + Text and data · 9 - - + + - csv + csv + Comma-Separated Values - - + + - html + html + HyperText Markup Language - - + + - json + json + JavaScript Object Notation - - + + - log + log + Server and application log - - + + - md + md + Markdown - - + + - toml + toml + TOML - - + + - txt + txt + Plain text - - + + - xml + xml + Extensible Markup Language - - + + - yaml + yaml + YAML - - + + - + - - - - - - - + + + + + + + type to filter - + @@ -1334,122 +1493,156 @@ - - - - - - - - - - Archives · 1 + + + + + + + + + + + + + Archives · 1 - - + + zi p - + + ZIP - - - Documents · 2 + + + Documents · 2 - - + + p - df + df + + P + ortable Document Format - - + + p - ptx + ptx + + P + owerPoint (Office Open XML) - - - Pictures · 10 + + + Pictures · 10 - - + + - avif + avif + AV1 Image File Format - - + + bm p - + + Windows Bitmap - - + + - gif + gif + Graphics Interchange Format - - + + - ico + ico + Windows Icon - - + + j p - g + g + JPEG - - + + - jxl + jxl + JPEG XL - - - + + + p - ng + ng + + P + ortable Network Graphics - - + + - svg + svg + Scalable Vector Graphics - - + + - tiff + tiff + Tagged Image File Format - - + + web p - + + WebP + + + + Text and data · 1 + + + + + txt + + P + lain text - - + + - - - + + + - - - - - - - + + + + + + + p @@ -1471,26 +1664,29 @@ - - - - - - - - - - Nothing matches + + + + + + + + + + + + + No format matches - clear the box to see all - - - - - - - + + + + + + + zz @@ -1507,7 +1703,7 @@ - + @@ -1681,7 +1877,7 @@ - + @@ -1812,7 +2008,7 @@ - + @@ -1992,7 +2188,7 @@ - + Remove @@ -2037,7 +2233,7 @@ - + Remove @@ -2080,7 +2276,7 @@ - + Choose... @@ -2123,7 +2319,7 @@ - + @@ -2357,7 +2553,7 @@ - + @@ -2487,7 +2683,7 @@ - + @@ -2580,9 +2776,9 @@ - + - + @@ -2601,7 +2797,7 @@ - + @@ -2672,7 +2868,7 @@ - + Use the smallest size, 311 B @@ -2682,7 +2878,7 @@ - + @@ -2731,12 +2927,12 @@ - - - - + + + + Folding - + @@ -2879,7 +3075,7 @@ - + @@ -2931,7 +3127,7 @@ - + @@ -2962,111 +3158,7 @@ - - - - - inner, a damage's settings - - - - - - - - - - - - - - - - - - - - - - - - Settings for zero-head - - - - - - - - - - - - - - inside the inner fold - - - - - - - - - - - - - - inner, notes for the manifest - - - - - - - - - - - - - - - - - - - - - - - - Notes for the manifest - - - - - - - - - - - - - - inside the inner fold - - - - - - - - - - + @@ -3113,7 +3205,7 @@ - + @@ -3160,7 +3252,7 @@ - + @@ -3207,7 +3299,7 @@ - + @@ -3256,7 +3348,7 @@ - + @@ -3596,12 +3688,12 @@ - - - - + + + + Section - + @@ -3751,12 +3843,12 @@ - - + + - + Preview @@ -3766,15 +3858,17 @@ - - - - - - - - Donate - + + + + + + + Donate + + + + @@ -3919,7 +4013,7 @@ - + @@ -3927,13 +4021,13 @@ - - - - + + + + File configuration - - + + Size @@ -3968,7 +4062,7 @@ - + How many files @@ -3993,18 +4087,17 @@ - - - - - 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 - + Seed @@ -4035,7 +4128,7 @@ - + @@ -4106,7 +4199,7 @@ - + @@ -4176,7 +4269,7 @@ - + @@ -4297,7 +4390,7 @@ - menuBackground - what an open list floats on + menuBackground - the toolkit's own menus - the one a right press opens in a box. Our lists float on a card @@ -4349,7 +4442,7 @@ - #E6E6E8 - 5.74:1 on an open list + #E6E6E8 - 7.29:1 on a button's face @@ -4760,7 +4853,7 @@ - + @@ -4881,7 +4974,7 @@ - menuBackground - what an open list floats on + menuBackground - the toolkit's own menus - the one a right press opens in a box. Our lists float on a card @@ -4933,7 +5026,7 @@ - #1A1A1C - 17.38:1 on an open list + #1A1A1C - 10.78:1 on a button's face @@ -5344,13 +5437,13 @@ - - - - + + + + Not drawn, and why - - + + • @@ -5441,17 +5534,7 @@ - - • - - - - GroupKind - an enum: what a group of settings is about, every value drawn under Folding as the colour of its rail - - - - - + • @@ -5467,7 +5550,7 @@ - + diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png index 0aac1dfd..747b28da 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 ea60d4f5..a992ad94 100644 --- a/internal/guard/testdata/screens/generate-chosen-by-key.xml +++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml @@ -265,7 +265,7 @@ - + @@ -358,7 +358,7 @@ - + Choose... @@ -432,13 +432,13 @@ - - - + + + - + Preview @@ -461,15 +461,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate-chosen.png b/internal/guard/testdata/screens/generate-chosen.png index 45b9efa9..2616cfaf 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 778cc5f9..11eeead6 100644 --- a/internal/guard/testdata/screens/generate-chosen.xml +++ b/internal/guard/testdata/screens/generate-chosen.xml @@ -265,7 +265,7 @@ - + @@ -358,7 +358,7 @@ - + Choose... @@ -432,13 +432,13 @@ - - - + + + - + Preview @@ -461,15 +461,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate-empty.png b/internal/guard/testdata/screens/generate-empty.png index 324100b2..a04a2332 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 66eafadc..e61f4dea 100644 --- a/internal/guard/testdata/screens/generate-empty.xml +++ b/internal/guard/testdata/screens/generate-empty.xml @@ -171,10 +171,10 @@ - - - - + + + + target "files" asks for 0 files. Ask for at least one @@ -275,7 +275,7 @@ - + @@ -368,7 +368,7 @@ - + Choose... @@ -414,7 +414,7 @@ - + @@ -442,13 +442,13 @@ - - - + + + - + Preview @@ -471,15 +471,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png index 8bdffd16..2395d12e 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 03f03a63..fc1de54b 100644 --- a/internal/guard/testdata/screens/generate-focused.xml +++ b/internal/guard/testdata/screens/generate-focused.xml @@ -266,7 +266,7 @@ - + @@ -359,7 +359,7 @@ - + Choose... @@ -433,13 +433,13 @@ - - - + + + - + Preview @@ -462,15 +462,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate-hovered.png b/internal/guard/testdata/screens/generate-hovered.png index 48334c85..f1e8a534 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 cbcc47d5..c4ae8fb7 100644 --- a/internal/guard/testdata/screens/generate-hovered.xml +++ b/internal/guard/testdata/screens/generate-hovered.xml @@ -265,7 +265,7 @@ - + @@ -358,7 +358,7 @@ - + Choose... @@ -432,13 +432,13 @@ - - - + + + - + Preview @@ -461,15 +461,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + @@ -479,9 +482,9 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index 0f5a4fc9..b57bb848 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 6e557f0f..dbcc2562 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -265,7 +265,7 @@ - + @@ -358,7 +358,7 @@ - + Choose... @@ -432,13 +432,13 @@ - - - + + + - + Preview @@ -461,15 +461,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + @@ -483,185 +486,214 @@ - - - - - - - - - - - Archives · 2 - - - + + + + + + + + + + + + + + Archives · 2 + + + - targz + targz + tar + gzip - - + + - zip + zip + ZIP - - - Documents · 4 + + + Documents · 4 - - + + - docx + docx + Word (Office Open XML) - - + + - pdf + pdf + Portable Document Format - - + + - pptx + pptx + PowerPoint (Office Open XML) - - + + - xlsx + xlsx + Excel (Office Open XML) - - - Pictures · 10 + + + Pictures · 10 - - - + + + - avif + avif + AV1 Image File Format - - + + - bmp + bmp + Windows Bitmap - - + + - gif + gif + Graphics Interchange Format - - + + - ico + ico + Windows Icon - - + + - jpg + jpg + JPEG - - + + - jxl + jxl + JPEG XL - - + + - png + png + Portable Network Graphics - - + + - svg + svg + Scalable Vector Graphics - - + + - tiff + tiff + Tagged Image File Format - - + + - webp + webp + WebP - - - Sound · 1 + + + Sound · 1 - - + + - wav + wav + Waveform Audio - - - Text and data · 9 + + + Text and data · 9 - - + + - csv + csv + Comma-Separated Values - - + + - html + html + HyperText Markup Language - - + + - json + json + JavaScript Object Notation - - + + - log + log + Server and application log - - + + - md + md + Markdown - - + + - toml + toml + TOML - - + + - txt + txt + Plain text - - + + - xml + xml + Extensible Markup Language - - + + - yaml + yaml + YAML - - + + - + - - - - - - - + + + + + + + type to filter - + diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index 5861cdab..d7423353 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 aaed0638..0d1c3dea 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -265,7 +265,7 @@ - + @@ -358,7 +358,7 @@ - + Choose... @@ -432,13 +432,13 @@ - - - + + + - + Preview @@ -461,15 +461,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + @@ -483,185 +486,214 @@ - - - - - - - - - - - Archives · 2 - - - + + + + + + + + + + + + + + Archives · 2 + + + - targz + targz + tar + gzip - - + + - zip + zip + ZIP - - - Documents · 4 + + + Documents · 4 - - + + - docx + docx + Word (Office Open XML) - - + + - pdf + pdf + Portable Document Format - - + + - pptx + pptx + PowerPoint (Office Open XML) - - + + - xlsx + xlsx + Excel (Office Open XML) - - - Pictures · 10 + + + Pictures · 10 - - - + + + - avif + avif + AV1 Image File Format - - + + - bmp + bmp + Windows Bitmap - - + + - gif + gif + Graphics Interchange Format - - + + - ico + ico + Windows Icon - - + + - jpg + jpg + JPEG - - + + - jxl + jxl + JPEG XL - - + + - png + png + Portable Network Graphics - - + + - svg + svg + Scalable Vector Graphics - - + + - tiff + tiff + Tagged Image File Format - - + + - webp + webp + WebP - - - Sound · 1 + + + Sound · 1 - - + + - wav + wav + Waveform Audio - - - Text and data · 9 + + + Text and data · 9 - - + + - csv + csv + Comma-Separated Values - - + + - html + html + HyperText Markup Language - - + + - json + json + JavaScript Object Notation - - + + - log + log + Server and application log - - + + - md + md + Markdown - - + + - toml + toml + TOML - - + + - txt + txt + Plain text - - + + - xml + xml + Extensible Markup Language - - + + - yaml + yaml + YAML - - + + - + - - - - - - - + + + + + + + type to filter - + diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index 26d7d95e..eb264844 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 d91504e9..3455ff18 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -265,7 +265,7 @@ - + @@ -358,7 +358,7 @@ - + Choose... @@ -432,13 +432,13 @@ - - - + + + - + Preview @@ -461,15 +461,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + @@ -483,185 +486,214 @@ - - - - - - - - - - - Archives · 2 - - - + + + + + + + + + + + + + + Archives · 2 + + + - targz + targz + tar + gzip - - + + - zip + zip + ZIP - - - Documents · 4 + + + Documents · 4 - - + + - docx + docx + Word (Office Open XML) - - + + - pdf + pdf + Portable Document Format - - + + - pptx + pptx + PowerPoint (Office Open XML) - - + + - xlsx + xlsx + Excel (Office Open XML) - - - Pictures · 10 + + + Pictures · 10 - - - + + + - avif + avif + AV1 Image File Format - - + + - bmp + bmp + Windows Bitmap - - + + - gif + gif + Graphics Interchange Format - - + + - ico + ico + Windows Icon - - + + - jpg + jpg + JPEG - - + + - jxl + jxl + JPEG XL - - + + - png + png + Portable Network Graphics - - + + - svg + svg + Scalable Vector Graphics - - + + - tiff + tiff + Tagged Image File Format - - + + - webp + webp + WebP - - - Sound · 1 + + + Sound · 1 - - + + - wav + wav + Waveform Audio - - - Text and data · 9 + + + Text and data · 9 - - + + - csv + csv + Comma-Separated Values - - + + - html + html + HyperText Markup Language - - + + - json + json + JavaScript Object Notation - - + + - log + log + Server and application log - - + + - md + md + Markdown - - + + - toml + toml + TOML - - + + - txt + txt + Plain text - - + + - xml + xml + Extensible Markup Language - - + + - yaml + yaml + YAML - - + + - + - - - - - - - + + + + + + + type to filter - + diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png index 3d41439a..c7339582 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 a7483f77..92e51b3f 100644 --- a/internal/guard/testdata/screens/generate-refused-both.xml +++ b/internal/guard/testdata/screens/generate-refused-both.xml @@ -52,10 +52,10 @@ - - - - + + + + @@ -78,8 +78,8 @@ - - + + Format @@ -107,7 +107,7 @@ - + Size @@ -145,17 +145,17 @@ - - - - + + + + Size "abc" has no number: write something like 10mb or 1048576 - + How many files @@ -180,18 +180,17 @@ - - - - - How many files is "many", which is not a whole number. - Write the digits out, such as 1 or 500 + + + + + How many files is "many", which is not a whole number. Write the digits out, such as 1 or 500 - + Damage @@ -218,7 +217,7 @@ - + Batch name @@ -249,7 +248,7 @@ - + File names @@ -280,12 +279,12 @@ - + - + @@ -322,7 +321,7 @@ - + @@ -378,7 +377,7 @@ - + Choose... @@ -452,13 +451,13 @@ - - - + + + - + Preview @@ -481,15 +480,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png index aab00864..2d90a20d 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 ad0c6f2e..204b1b8f 100644 --- a/internal/guard/testdata/screens/generate-refused-setting.xml +++ b/internal/guard/testdata/screens/generate-refused-setting.xml @@ -265,7 +265,7 @@ - + @@ -436,7 +436,7 @@ - + Choose... @@ -482,7 +482,7 @@ - + @@ -510,13 +510,13 @@ - - - + + + - + Preview @@ -539,15 +539,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png index 9befb0b2..1e7be301 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 f3d432d3..470072f5 100644 --- a/internal/guard/testdata/screens/generate-refused.xml +++ b/internal/guard/testdata/screens/generate-refused.xml @@ -52,10 +52,10 @@ - - - - + + + + @@ -78,8 +78,8 @@ - - + + Format @@ -107,7 +107,7 @@ - + Size @@ -145,27 +145,26 @@ - - - - - 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 + + + + + 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 - + - + Use the smallest size, 311 B - + How many files @@ -191,7 +190,7 @@ - + Damage @@ -218,7 +217,7 @@ - + Batch name @@ -249,7 +248,7 @@ - + File names @@ -280,12 +279,12 @@ - + - + @@ -322,7 +321,7 @@ - + @@ -378,7 +377,7 @@ - + Choose... @@ -424,7 +423,7 @@ - + @@ -452,13 +451,13 @@ - - - + + + - + Preview @@ -481,15 +480,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png index 17a29ddd..edde73ca 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 3451cb07..56842a99 100644 --- a/internal/guard/testdata/screens/generate-switch-by-key.xml +++ b/internal/guard/testdata/screens/generate-switch-by-key.xml @@ -265,7 +265,7 @@ - + @@ -358,7 +358,7 @@ - + Choose... @@ -432,13 +432,13 @@ - - - + + + - + Preview @@ -461,15 +461,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate-typed.png b/internal/guard/testdata/screens/generate-typed.png index 6040746e..42c70beb 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 92444fbe..94e405dd 100644 --- a/internal/guard/testdata/screens/generate-typed.xml +++ b/internal/guard/testdata/screens/generate-typed.xml @@ -144,10 +144,10 @@ - - - - + + + + Size "abc" has no number: write something like 10mb or 1048576 @@ -274,7 +274,7 @@ - + @@ -367,7 +367,7 @@ - + Choose... @@ -441,13 +441,13 @@ - - - + + + - + Preview @@ -470,15 +470,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate-unchecked.png b/internal/guard/testdata/screens/generate-unchecked.png index b23799ad..8fbb17e8 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 51135a49..ba0e56f0 100644 --- a/internal/guard/testdata/screens/generate-unchecked.xml +++ b/internal/guard/testdata/screens/generate-unchecked.xml @@ -265,7 +265,7 @@ - + @@ -358,7 +358,7 @@ - + Choose... @@ -431,13 +431,13 @@ - - - + + + - + Preview @@ -460,15 +460,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png index c8eb13dc..57c0292a 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 54ebb5ff..1661e87c 100644 --- a/internal/guard/testdata/screens/generate.xml +++ b/internal/guard/testdata/screens/generate.xml @@ -265,7 +265,7 @@ - + @@ -358,7 +358,7 @@ - + Choose... @@ -432,13 +432,13 @@ - - - + + + - + Preview @@ -461,15 +461,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/preset-many-settings.png b/internal/guard/testdata/screens/preset-many-settings.png index 5e99fde2..a572512b 100644 Binary files a/internal/guard/testdata/screens/preset-many-settings.png and b/internal/guard/testdata/screens/preset-many-settings.png differ diff --git a/internal/guard/testdata/screens/preset-many-settings.xml b/internal/guard/testdata/screens/preset-many-settings.xml index 01454810..f83bef8e 100644 --- a/internal/guard/testdata/screens/preset-many-settings.xml +++ b/internal/guard/testdata/screens/preset-many-settings.xml @@ -435,7 +435,7 @@ - + Choose... @@ -486,13 +486,13 @@ - - - + + + - + Preview @@ -515,15 +515,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png index 070f40fc..bcd89ca4 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 3dc1f4c5..660cfcfc 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -344,7 +344,7 @@ - + Choose... @@ -395,13 +395,13 @@ - - - + + + - + Preview @@ -424,15 +424,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + @@ -446,185 +449,214 @@ - - - - - - - - - - - Archives · 2 - - - + + + + + + + + + + + + + + Archives · 2 + + + - targz + targz + tar + gzip - - + + - zip + zip + ZIP - - - Documents · 4 + + + Documents · 4 - - + + - docx + docx + Word (Office Open XML) - - - + + + - pdf + pdf + Portable Document Format - - + + - pptx + pptx + PowerPoint (Office Open XML) - - + + - xlsx + xlsx + Excel (Office Open XML) - - - Pictures · 10 + + + Pictures · 10 - - + + - avif + avif + AV1 Image File Format - - + + - bmp + bmp + Windows Bitmap - - + + - gif + gif + Graphics Interchange Format - - + + - ico + ico + Windows Icon - - + + - jpg + jpg + JPEG - - + + - jxl + jxl + JPEG XL - - + + - png + png + Portable Network Graphics - - + + - svg + svg + Scalable Vector Graphics - - + + - tiff + tiff + Tagged Image File Format - - + + - webp + webp + WebP - - - Sound · 1 + + + Sound · 1 - - + + - wav + wav + Waveform Audio - - - Text and data · 9 + + + Text and data · 9 - - + + - csv + csv + Comma-Separated Values - - + + - html + html + HyperText Markup Language - - + + - json + json + JavaScript Object Notation - - + + - log + log + Server and application log - - + + - md + md + Markdown - - + + - toml + toml + TOML - - + + - txt + txt + Plain text - - + + - xml + xml + Extensible Markup Language - - + + - yaml + yaml + YAML - - + + - + - - - - - - - + + + + + + + type to filter - + diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png index 0363b634..3f7a4d75 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 af811acc..197581d7 100644 --- a/internal/guard/testdata/screens/preset-menu.xml +++ b/internal/guard/testdata/screens/preset-menu.xml @@ -288,7 +288,7 @@ - + Choose... @@ -339,13 +339,13 @@ - - - + + + - + Preview @@ -368,15 +368,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + @@ -394,7 +397,10 @@ - + + + + diff --git a/internal/guard/testdata/screens/preset-refused.png b/internal/guard/testdata/screens/preset-refused.png index 9e5d48dc..07d43e1d 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 f3d2d053..31ec0787 100644 --- a/internal/guard/testdata/screens/preset-refused.xml +++ b/internal/guard/testdata/screens/preset-refused.xml @@ -353,7 +353,7 @@ - + Choose... @@ -404,13 +404,13 @@ - - - + + + - + Preview @@ -433,15 +433,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/preset.png b/internal/guard/testdata/screens/preset.png index 1d76902f..945b44d7 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 8f609178..3116e0f4 100644 --- a/internal/guard/testdata/screens/preset.xml +++ b/internal/guard/testdata/screens/preset.xml @@ -288,7 +288,7 @@ - + Choose... @@ -339,13 +339,13 @@ - - - + + + - + Preview @@ -368,15 +368,18 @@ - - - - - - - - Donate - + + + + + + + + Donate + + + + diff --git a/internal/guard/testdata/screens/recipe-contents.png b/internal/guard/testdata/screens/recipe-contents.png index c4881f76..7861de54 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 e1a2ea2e..af3764dc 100644 --- a/internal/guard/testdata/screens/recipe-contents.xml +++ b/internal/guard/testdata/screens/recipe-contents.xml @@ -142,7 +142,7 @@ - + Duplicate @@ -330,7 +330,7 @@ - + @@ -474,8 +474,8 @@ - - Remove + + Remove @@ -484,7 +484,7 @@ - + Add files inside @@ -551,7 +551,7 @@ - + Choose... @@ -657,13 +657,13 @@ - - - + + + - + Preview @@ -686,22 +686,25 @@ - - - - - - - - Donate - - + + + + + + + + Donate + + + + + + + Add a batch + + + - - - - Add a batch - diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.png b/internal/guard/testdata/screens/recipe-on-a-preset.png index c836a260..b1c55e55 100644 Binary files a/internal/guard/testdata/screens/recipe-on-a-preset.png and b/internal/guard/testdata/screens/recipe-on-a-preset.png differ diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.xml b/internal/guard/testdata/screens/recipe-on-a-preset.xml index a23c2e45..7c9893a7 100644 --- a/internal/guard/testdata/screens/recipe-on-a-preset.xml +++ b/internal/guard/testdata/screens/recipe-on-a-preset.xml @@ -201,13 +201,13 @@ - + Duplicate - - Remove + + Remove @@ -394,7 +394,7 @@ - + @@ -521,7 +521,7 @@ - + Choose... @@ -627,13 +627,13 @@ - - - + + + - + Preview @@ -656,22 +656,25 @@ - - - - - - - - Donate - - + + + + + + + + Donate + + + + + + + Add a batch + + + - - - - 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 cd317951..d419798c 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 16f6acf5..3a3ca063 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 @@ -142,13 +142,13 @@ - + Duplicate - - Remove + + Remove @@ -317,12 +317,12 @@ - - - - - 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. + + + + + 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. @@ -364,7 +364,7 @@ - + @@ -457,13 +457,13 @@ - + Duplicate - - Remove + + Remove @@ -650,7 +650,7 @@ - + @@ -777,7 +777,7 @@ - + Choose... @@ -891,13 +891,13 @@ - - - + + + - + Preview @@ -920,22 +920,25 @@ - - - - - - - - Donate - - + + + + + + + + Donate + + + + + + + Add a batch + + + - - - - Add a batch - diff --git a/internal/guard/testdata/screens/recipe-refused.png b/internal/guard/testdata/screens/recipe-refused.png index 8d256ae7..dea2f00f 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 856c46e2..b2e55909 100644 --- a/internal/guard/testdata/screens/recipe-refused.xml +++ b/internal/guard/testdata/screens/recipe-refused.xml @@ -142,7 +142,7 @@ - + Duplicate @@ -312,12 +312,12 @@ - - - - - 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. + + + + + 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. @@ -359,7 +359,7 @@ - + @@ -486,7 +486,7 @@ - + Choose... @@ -592,13 +592,13 @@ - - - + + + - + Preview @@ -621,22 +621,25 @@ - - - - - - - - Donate - - + + + + + + + + Donate + + + + + + + Add a batch + + + - - - - Add a batch - diff --git a/internal/guard/testdata/screens/recipe-two-batches.png b/internal/guard/testdata/screens/recipe-two-batches.png index 7b1d049a..613c9e9e 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 d7c2c96b..6fc25efe 100644 --- a/internal/guard/testdata/screens/recipe-two-batches.xml +++ b/internal/guard/testdata/screens/recipe-two-batches.xml @@ -142,13 +142,13 @@ - + Duplicate - - Remove + + Remove @@ -335,7 +335,7 @@ - + @@ -428,13 +428,13 @@ - + Duplicate - - Remove + + Remove @@ -627,7 +627,7 @@ - + @@ -754,7 +754,7 @@ - + Choose... @@ -868,13 +868,13 @@ - - - + + + - + Preview @@ -897,22 +897,25 @@ - - - - - - - - Donate - - + + + + + + + + Donate + + + + + + + Add a batch + + + - - - - Add a batch - diff --git a/internal/guard/testdata/screens/recipe.png b/internal/guard/testdata/screens/recipe.png index 8267e89a..8ae9a8b8 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 266bcdf8..096b1d1c 100644 --- a/internal/guard/testdata/screens/recipe.xml +++ b/internal/guard/testdata/screens/recipe.xml @@ -142,7 +142,7 @@ - + Duplicate @@ -330,7 +330,7 @@ - + @@ -457,7 +457,7 @@ - + Choose... @@ -563,13 +563,13 @@ - - - + + + - + Preview @@ -592,22 +592,25 @@ - - - - - - - - Donate - - + + + + + + + + Donate + + + + + + + Add a batch + + + - - - - Add a batch - diff --git a/internal/gui/catalogue/catalogue.go b/internal/gui/catalogue/catalogue.go index aa166c32..ce7b27db 100644 --- a/internal/gui/catalogue/catalogue.go +++ b/internal/gui/catalogue/catalogue.go @@ -98,7 +98,6 @@ func NotDrawn() []Reason { {"PointerFocus", "a piece inside a control, knowing what put the keyboard there - no picture of its own"}, {"Returnable", "an interface: a control the window can tell it is coming back to the front, nothing of its own to draw"}, {"Shortcuts", "a keyboard map: nothing to draw, and saying so is the point of this row"}, - {"GroupKind", "an enum: what a group of settings is about, every value drawn under Folding as the colour of its rail"}, {"NameTap", "an invisible target over the name beside a box to tick, so pressing the name ticks the box - nothing of its own to draw"}, } } diff --git a/internal/gui/catalogue/controls.go b/internal/gui/catalogue/controls.go index feebc6bc..1807d8d4 100644 --- a/internal/gui/catalogue/controls.go +++ b/internal/gui/catalogue/controls.go @@ -26,8 +26,32 @@ func button() Entry { states = append(states, State{"long text", func() fyne.CanvasObject { return parts.NewButton(parts.Secondary, longText, func() {}) }}) + // The two parameters a button takes beyond its look, each at rest and + // switched off - off, both give their colour up, as every control here + // is quieter off than at rest. + states = append(states, + State{"quiet, with the heart", func() fyne.CanvasObject { + return parts.NewButton(parts.Quiet, "Donate", func() {}).WithHeart() + }}, + State{"secondary, with the heart", func() fyne.CanvasObject { + return parts.NewButton(parts.Secondary, "Donate", func() {}).WithHeart() + }}, + State{"quiet, with the heart, disabled", func() fyne.CanvasObject { + b := parts.NewButton(parts.Quiet, "Donate", func() {}).WithHeart() + b.Disable() + return b + }}, + State{"removing", func() fyne.CanvasObject { + return parts.NewButton(parts.Secondary, "Remove", func() {}).Removing() + }}, + State{"removing, disabled", func() fyne.CanvasObject { + b := parts.NewButton(parts.Secondary, "Remove", func() {}).Removing() + b.Disable() + return b + }}, + ) states = append(states, glyphStates()...) - return Entry{Name: "Button", Covers: []string{"GlyphButton"}, Natural: true, States: states} + return Entry{Name: "Button", Covers: []string{"GlyphButton", "HeartIcon"}, Natural: true, States: states} } // faceStates is one face of a button in the four states a person can put it @@ -92,19 +116,26 @@ func glyphStates() []State { 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. + // which is what draws the keyboard's mark round it AND its edge at rest - + // so every state is built the way a field builds it. Until 2026-09-24 only + // the two states with something to say were, and the rest drew a menu with + // no edge at all, which no screen shows: the catalogue was showing a + // control the window does not have (GUI rule 4, review UI-004). 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. + onAForm := func(c *parts.Chooser) fyne.CanvasObject { + o, _ := parts.WithRing(parts.Menu(c)) + return o + } 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) {})) + return onAForm(parts.NewChooser(options, func(string) {})) }}, {"chosen", func() fyne.CanvasObject { c := parts.NewChooser(options, func(string) {}) c.SetSelected("avif") - return parts.Menu(c) + return onAForm(c) }}, {"holding the keyboard", func() fyne.CanvasObject { c := parts.NewChooser(options, func(string) {}) @@ -120,20 +151,20 @@ func chooser() Entry { {"disabled", func() fyne.CanvasObject { c := parts.NewChooser(options, func(string) {}) c.Disable() - return parts.Menu(c) + return onAForm(c) }}, {"long value", func() fyne.CanvasObject { c := parts.NewChooser([]string{longText, "png"}, func(string) {}) c.SetSelected(longText) - return parts.Menu(c) + return onAForm(c) }}, {"every format, showing the kind of its value", func() fyne.CanvasObject { // The one menu that draws a picture in the shut box - see - // menuLook.placeKind - and the widest, because its open list - // carries headings, a filter and letters in bold. + // menuLook.placeKind. Its open list is wider than it, for the + // names, and that width is the list's own (parts.ListWidth). c := parts.NewChooser(format.IDs(), func(string) {}) c.SetSelected("zip") - return parts.Menu(c) + return onAForm(c) }}, }} } @@ -189,12 +220,20 @@ func toggle() Entry { t.FocusGained() return t }}, - {"disabled", func() fyne.CanvasObject { + // Both values switched off, because they draw differently: until + // 2026-09-24 this one state stood here, ticked, and drew an empty + // square - the defect was in the catalogue and nobody named it. + {"on, disabled", func() fyne.CanvasObject { t := parts.NewToggle(func(bool) {}) t.SetChecked(true) t.Disable() return t }}, + {"off, disabled", func() fyne.CanvasObject { + t := parts.NewToggle(func(bool) {}) + t.Disable() + return t + }}, }} } diff --git a/internal/gui/catalogue/fields.go b/internal/gui/catalogue/fields.go index b12bf675..c4c17bf4 100644 --- a/internal/gui/catalogue/fields.go +++ b/internal/gui/catalogue/fields.go @@ -212,7 +212,7 @@ func folding() Entry { // keyboard states a control has - and FoldHead is covered here rather // than as an entry of its own, because it is never on a screen without // the fold it heads. - return Entry{Name: "Folding", Covers: []string{"InnerFolding", "InnerFoldingOf", "FoldHead"}, States: []State{ + return Entry{Name: "Folding", Covers: []string{"InnerFolding", "FoldHead"}, States: []State{ {"open", func() fyne.CanvasObject { return parts.NewFolding("Notes for the manifest", nil, parts.Prose("inside the fold")).Object() }}, @@ -235,16 +235,6 @@ func folding() Entry { f.Set(false) return f.Object() }}, - // The rail down a group's left edge is the colour of what the group - // is about, so all three kinds are drawn. - {"inner, a damage's settings", func() fyne.CanvasObject { - return parts.NewInnerFoldingOf(parts.GroupDamage, "Settings for zero-head", - parts.Prose("inside the inner fold")).Object() - }}, - {"inner, notes for the manifest", func() fyne.CanvasObject { - return parts.NewInnerFoldingOf(parts.GroupNotes, "Notes for the manifest", - parts.Prose("inside the inner fold")).Object() - }}, {"a long title", func() fyne.CanvasObject { return parts.NewFolding(longText, nil, parts.Prose("inside the fold")).Object() }}, diff --git a/internal/gui/catalogue/lists.go b/internal/gui/catalogue/lists.go index a0df3000..5519d542 100644 --- a/internal/gui/catalogue/lists.go +++ b/internal/gui/catalogue/lists.go @@ -20,7 +20,8 @@ import ( // 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 on its own it is as wide as a row with no words, so a list +// drops from - the list of formats wider, for the names beside its values - +// and on its own it is as wide as a row with no words, 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 @@ -77,6 +78,7 @@ func everyFormat(typed string) fyne.CanvasObject { l := parts.NewOpenList(ids, "png", func(string, bool) {}, func(bool) {}) l.KindOf = parts.KindOfFile l.GroupUnder(parts.KindHeading) + l.NameEach(parts.NameOfFormat) l.WithFilter() l.LimitTo(parts.ListCeiling(everyFormatWindow)) if typed != "" { @@ -90,12 +92,12 @@ func everyFormat(typed string) fyne.CanvasObject { // look. Tall enough to show a few headings and the rows under them. const everyFormatWindow = 640 -// 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. +// asWideAsItsBox gives an open list the width it is drawn at under the menu it +// would drop from: the box a Chooser of the same values is given by +// parts.Menu, or wider where the list needs it - the list of formats, which +// names every value (parts.ListWidth). func asWideAsItsBox(values []string, list *parts.OpenList) fyne.CanvasObject { - box := parts.Menu(parts.NewChooser(values, func(string) {})) - return parts.Sized(box.MinSize().Width, list) + return parts.Sized(parts.ListWidth(parts.NewChooser(values, func(string) {})), list) } func tabs() Entry { diff --git a/internal/gui/catalogue/palette.go b/internal/gui/catalogue/palette.go index 9ded0266..2c2e2093 100644 --- a/internal/gui/catalogue/palette.go +++ b/internal/gui/catalogue/palette.go @@ -162,7 +162,11 @@ var palettePlan = map[fyne.ThemeColorName]paletteRole{ theme.ColorNameInputBackground, "a box to type in", byStep}, theme.ColorNameSeparator: {ladder, "a line that separates and says nothing else", parts.ColorNamePanel, "the panel", byStep}, - theme.ColorNameMenuBackground: {ladder, "what an open list floats on", + // Our lists and the explanation float on a card since 2026-09-24, so what + // is left on this colour is the menu a right press opens in a box. The + // sentence is one line in a row beside the square and cannot wrap there - + // the longer one ran past its section (docs/REVIEW-136-2026-09-24.md). + theme.ColorNameMenuBackground: {ladder, "the toolkit's own menus - the one a right press opens in a box. Our lists float on a card", theme.ColorNameInputBackground, "a box to type in", byStep}, theme.ColorNameInputBorder: {ladder, "the edge that says where the typing goes", theme.ColorNameInputBackground, "its own fill", byStep}, @@ -176,11 +180,13 @@ var palettePlan = map[fyne.ThemeColorName]paletteRole{ // comfortable number was the wrong number. // // The surface each one is drawn on is read from the code rather than - // assumed: a list row draws its words in Foreground on the floating - // surface (parts/listrow.go), a value and a hint sit inside a box, and a - // field's name stands on the panel beside it. + // assumed: the words of a secondary button stand on its face, the + // lightest surface Foreground is drawn on since the lists moved to a box's + // surface on 2026-09-24 (parts/button.go, parts/parts.go floatingCard), a + // value and a hint sit inside a box, and a field's name stands on the panel + // beside it. theme.ColorNameForeground: {inks, "a value, and everything read at rest", - theme.ColorNameMenuBackground, "an open list", byContrast}, + theme.ColorNameButton, "a button's face", byContrast}, parts.ColorNameLabel: {inks, "the name of a field, a step quieter than its value", parts.ColorNamePanel, "a panel", byContrast}, theme.ColorNameDisabled: {inks, "a value in a box switched off for the length of a run", diff --git a/internal/gui/parts/button.go b/internal/gui/parts/button.go index 1424d98e..a7ce333b 100644 --- a/internal/gui/parts/button.go +++ b/internal/gui/parts/button.go @@ -74,6 +74,13 @@ type Button struct { // keeps more room round its words - see InTheBar. inBar bool + // wordsInk and iconInk hold the words or the icon to an ink of their own + // where the look's says the wrong thing - see Removing and WithHeart. Empty + // for the look's ink. A switched off button draws both in its own ink + // whatever these say, so it stays quieter than at rest. + wordsInk fyne.ThemeColorName + iconInk fyne.ThemeColorName + // 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. // @@ -114,6 +121,16 @@ func (b *Button) InTheBar() *Button { return b } +// Removing draws the words in the error colour, for a button that takes +// something away with no way back - Remove beside Duplicate, which looked the +// same until 2026-09-24 (review UI-013, the owner's choice). The face stays the +// look's, so it is still one of the buttons beside it. +func (b *Button) Removing() *Button { + b.wordsInk = theme.ColorNameError + b.Refresh() + 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} @@ -264,8 +281,21 @@ type buttonRenderer struct { } func (r *buttonRenderer) Layout(size fyne.Size) { - r.bg.Resize(size) - r.bg.Move(fyne.NewPos(0, 0)) + // A face with an edge is drawn inside its bounds the way the toolkit draws + // a box to type in (fyne v2.8.1 widget/entry.go, Layout): a stroke is laid + // centred on the rectangle's edge, so half of it went outside. Measured on + // 2026-09-24 at equal bounds: a box drawn over rows 14 to 42 and the + // button beside it over 13 to 43, a pixel taller at each end (review + // UI-012). The half pixel off the trailing edge is the toolkit's too, so + // the two round the same way at every scale. + edge := r.bg.StrokeWidth + if edge > 0 { + r.bg.Resize(fyne.NewSize(size.Width-edge-.5, size.Height-edge-.5)) + r.bg.Move(fyne.NewSquareOffsetPos(edge / 2)) + } else { + 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. @@ -322,8 +352,17 @@ func (r *buttonRenderer) Refresh() { } else { r.ring.StrokeWidth = 0 } + words, mark := f.ink, f.ink + if r.state() != stateDisabled { + if r.button.wordsInk != "" { + words = r.button.wordsInk + } + if r.button.iconInk != "" { + mark = r.button.iconInk + } + } r.label.Text = r.button.Text - r.label.Color = PaletteColour(f.ink, theme.VariantDark) + r.label.Color = PaletteColour(words, theme.VariantDark) r.label.TextSize = TextBody // A quiet button is words rather than a face, so it drops the weight and // a rank of size - the prototype of 2026-09-23, see buttonFace. @@ -334,8 +373,8 @@ func (r *buttonRenderer) Refresh() { 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) + // resource - unless WithHeart gave it an ink of its own. + r.icon.Resource = theme.NewColoredResource(r.button.Icon, mark) r.icon.Show() } else { r.icon.Hide() @@ -409,6 +448,14 @@ func buttonFace(look Look, state buttonState) face { border.ink = theme.ColorNameDisabled if look == Quiet || look == Glyph { border.edgeWidth = 0 + // Except the two looks that are quiet at rest: they rest in the + // hint's ink (below), which is darker than the disabled one, so + // switched off they came out BRIGHTER than switched on - Donate and + // every i beside a field name, measured on the catalogue on + // 2026-09-24 at #C7C7CC off against #A2A2A9 on. The edge of a box + // to type in is the next ink down the ladder, and the one that + // still draws the shape. + border.ink = theme.ColorNameInputBorder } return border } diff --git a/internal/gui/parts/buttonrow.go b/internal/gui/parts/buttonrow.go index c9aa2396..435165c2 100644 --- a/internal/gui/parts/buttonrow.go +++ b/internal/gui/parts/buttonrow.go @@ -15,12 +15,17 @@ import ( // read as one control. What is hidden takes no room and no gap, so Cancel and // the two offers after a run come and go without leaving a hole. func ButtonRow(items ...fyne.CanvasObject) *fyne.Container { - return container.New(buttonRow{}, items...) + return container.New(&buttonRow{}, items...) } -type buttonRow struct{} +// buttonRow is the layout behind ButtonRow. clearLeft is room at its left the +// row keeps free of buttons - the rail laid over the action bar, see railOver - +// and nought everywhere else. A field of the layout rather than a move made +// from outside, because the row lays itself out again whenever a button in it +// comes or goes, and a move made once would be undone by the next of those. +type buttonRow struct{ clearLeft float32 } -func (buttonRow) MinSize(objects []fyne.CanvasObject) fyne.Size { +func (*buttonRow) MinSize(objects []fyne.CanvasObject) fyne.Size { size := fyne.NewSize(0, 0) shown := 0 for _, o := range objects { @@ -38,8 +43,11 @@ func (buttonRow) MinSize(objects []fyne.CanvasObject) fyne.Size { return size } -func (b buttonRow) Layout(objects []fyne.CanvasObject, size fyne.Size) { - x := (size.Width - b.MinSize(objects).Width) / 2 +func (b *buttonRow) Layout(objects []fyne.CanvasObject, size fyne.Size) { + // Centred, and moved right of centre only as far as the room kept clear + // asks - so in a wide window nothing changes, and a narrow one does not + // have to be wide enough to centre the row clear of the rail. + x := fyne.Max((size.Width-b.MinSize(objects).Width)/2, b.clearLeft) for _, o := range objects { if !o.Visible() { continue diff --git a/internal/gui/parts/detail.go b/internal/gui/parts/detail.go index 96d6ea96..e9c6fb09 100644 --- a/internal/gui/parts/detail.go +++ b/internal/gui/parts/detail.go @@ -216,9 +216,9 @@ func (t *Tips) open(near fyne.CanvasObject, detail string) fyne.CanvasObject { } driver := app.Driver() - // On the surface an open list floats on, not on a panel's - see - // floatingSurface for the report that moved it there. - box := container.NewStack(tipShadow(), tipSurface(), Padded(Inset, Prose(detail))) + // On the card an open list floats on, not on a panel's surface - see + // floatingCard for the reports that shaped it. + box := container.NewStack(append(floatingCard(), 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 997ae415..e272f4a3 100644 --- a/internal/gui/parts/entry.go +++ b/internal/gui/parts/entry.go @@ -2,7 +2,9 @@ package parts import ( "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" ) @@ -79,6 +81,44 @@ func (e *Entry) FocusLost() { } } +// CreateRenderer is the toolkit's own, with the border of a box switched off +// drawn quieter than the border at rest. See quietWhenOff. +func (e *Entry) CreateRenderer() fyne.WidgetRenderer { + return quietWhenOff{WidgetRenderer: e.Entry.CreateRenderer(), entry: e} +} + +// quietWhenOff recolours the border of a box switched off for a run. +// +// The toolkit draws that border in the disabled ink it also writes the value +// in (fyne v2.8.1 widget/entry.go, Refresh: the border and the text both read +// ColorNameDisabled) - an ink bright enough to read, and so an edge BRIGHTER +// than the box has at rest: measured on 2026-09-24, #7F7F85 switched off +// against #4E4E55 at rest. The form is switched off for every run, so every +// run it read as more outlined than the form you can type in. +// +// Recoloured at the source rather than covered. Laying the field's ring over +// it was tried first and measured: the toolkit's border stands inside the box, +// a pixel off the ring's line, and the bright one still showed beside the +// dark. The border is the one rectangle the renderer strokes. The value keeps +// the toolkit's disabled ink, which is what keeps it readable. +type quietWhenOff struct { + fyne.WidgetRenderer + entry *Entry +} + +func (q quietWhenOff) Refresh() { + q.WidgetRenderer.Refresh() + if !q.entry.Disabled() { + return + } + for _, o := range q.Objects() { + if edge, ok := o.(*canvas.Rectangle); ok && edge.StrokeWidth > 0 { + edge.StrokeColor = PaletteColour(theme.ColorNameSeparator, theme.VariantDark) + edge.Refresh() + } + } +} + // 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/filekind.go b/internal/gui/parts/filekind.go index f22bce7e..a3eeb1e7 100644 --- a/internal/gui/parts/filekind.go +++ b/internal/gui/parts/filekind.go @@ -4,6 +4,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/theme" + "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) @@ -97,6 +98,17 @@ func KindHeading(id string) string { return "" } +// NameOfFormat is what a format is called, drawn beside it in an open list - +// JPEG XL beside jxl. The registry's, so the window and "tfg formats" say one +// thing (D1), and empty for a value that is not a registered format. +func NameOfFormat(id string) string { + d, err := format.Get(id) + if err != nil { + return "" + } + return d.Name +} + type fileKind int const ( diff --git a/internal/gui/parts/folding.go b/internal/gui/parts/folding.go index faa8b012..039e4bc9 100644 --- a/internal/gui/parts/folding.go +++ b/internal/gui/parts/folding.go @@ -83,47 +83,34 @@ func NewFolding(title string, head []fyne.CanvasObject, content ...fyne.CanvasOb // and Duplicate, which act on the batch - a section of it is not a thing // anybody removes or copies on its own. func NewInnerFolding(title string, content ...fyne.CanvasObject) *Folding { - return NewInnerFoldingOf(GroupSettings, title, content...) -} - -// NewInnerFoldingOf is NewInnerFolding for a group of a named kind, which is -// what colours the rail down its left edge. -func NewInnerFoldingOf(kind GroupKind, title string, content ...fyne.CanvasObject) *Folding { // At the rank of a subheading rather than a section's title since // 2026-09-21: drawn as a section, it read as one (owner, running window). // White like every other heading: the owner's verdict on a coloured title - // was that one blue title among white ones looked strange, so the colour - // lives in the rail alone. + // was that one blue title among white ones looked strange. f := newFolding(title, words(title, TextBody, true, theme.ColorNameForeground), nil, content...) - // Framed, with a rail in the colour of what the group is about, since the - // prototype of 2026-09-23 - the owner chose this of three drawn side by - // side (wells, bands, accent). Opened, the settings of a format and the - // settings of a damage ran into the fields above them and into each other, - // and nothing said where one group ended or which was which. + // Framed, with a rail down its left edge, since the prototype of + // 2026-09-23 - the owner chose this of three drawn side by side (wells, + // bands, accent). Opened, the settings of a format and the settings of a + // damage ran into the fields above them and into each other, and nothing + // said where one group ended. + // + // One colour for every rail since 2026-09-24, the neutral one the notes + // had. The rail was in the colour of what the group was about until then - + // the primary colour for a format's settings, the warning colour for a + // damage's - and the review of that day (UI-009) found the blue one read as + // "this is the one chosen", the same blue as the main button and the + // keyboard's mark, with nothing anywhere saying what the colours meant. The + // owner chose grey for all of them. The title says what a group is about. // // Less room above and below than at the sides, because the head row keeps // TabInset round its words already for the pointer's fill to draw in. padded := container.New(layout.NewCustomPaddedLayout(GroupInsetY, GroupInsetY, GroupInset, GroupInset), f.inside) - rail := canvas.NewRectangle(PaletteColour(groupInk(kind), theme.VariantDark)) + rail := canvas.NewRectangle(PaletteColour(ColorNameLabel, theme.VariantDark)) rail.CornerRadius = RadiusMark f.object = container.New(groupCell{}, container.NewStack(groupFrame(), container.New(leftRail{}, rail), padded)) return f } -// GroupKind is what a group of settings is about, and it decides the colour -// of the rail down the group's left edge. -type GroupKind int - -const ( - // GroupSettings is a format's own settings - the primary colour. - GroupSettings GroupKind = iota - // GroupDamage is the settings of a damage - the warning colour, because - // what it does to a file is the one thing on the form that breaks it. - GroupDamage - // GroupNotes is the notes a batch leaves in the manifest - neutral. - GroupNotes -) - // groupFrame is the line drawn round a group of settings inside a section. func groupFrame() *canvas.Rectangle { rect := canvas.NewRectangle(color.Transparent) @@ -133,19 +120,6 @@ func groupFrame() *canvas.Rectangle { return rect } -// groupInk is the colour of a group's rail. -func groupInk(kind GroupKind) fyne.ThemeColorName { - switch kind { - case GroupDamage: - return theme.ColorNameWarning - case GroupNotes: - return ColorNameLabel - case GroupSettings: - return theme.ColorNamePrimary - } - return theme.ColorNamePrimary -} - // leftRail lays its one child as a narrow bar down the left edge. type leftRail struct{} diff --git a/internal/gui/parts/grid.go b/internal/gui/parts/grid.go index ea758099..1eca8c44 100644 --- a/internal/gui/parts/grid.go +++ b/internal/gui/parts/grid.go @@ -75,12 +75,19 @@ func (wideCell) Layout(objects []fyne.CanvasObject, size fyne.Size) { // of 2026-09-23: "Size "abc" has no number: write something like 10mb or // 1048576" broke into three lines in a column 185 px wide and pushed the whole // form down. So the grid tells the cell where its refusal goes - under the -// row, from the field's own left edge to the right edge of the row, and under -// any refusal about a field before it in the same row - and the cell draws it -// there. The red edge stays on the box it is about. +// row, across the whole of it, and under any refusal about a field before it +// in the same row - and the cell draws it there. The red edge stays on the box +// it is about, and every refusal names its field. +// +// From the row's left edge since 2026-09-24. Until then a refusal started at +// its own field's left edge, so two refusals in one row stood as a staircase - +// the second one column in and a line down - which the review of that day +// found reading as an accident (UI-011). Wrapping each in its own column was +// the other way out, and it is the three lines above. type fieldCell struct { column inGrid bool + areaLeft float32 areaTop float32 areaWidth float32 } @@ -94,7 +101,7 @@ func (f *fieldCell) Layout(objects []fyne.CanvasObject, size fyne.Size) { body.Resize(fyne.NewSize(size.Width, body.MinSize().Height)) body.Move(fyne.NewPos(0, 0)) area.Resize(fyne.NewSize(f.areaWidth, area.MinSize().Height)) - area.Move(fyne.NewPos(0, f.areaTop)) + area.Move(fyne.NewPos(f.areaLeft, f.areaTop)) } // cellOf stacks a field's pieces - its body, then its refusal - as one grid @@ -246,7 +253,9 @@ func rowHeights(items []placed, width float32) []float32 { } x := float32(p.column) * (column + GapColumns) cell.inGrid = true - cell.areaWidth = width - x + // From the row's left edge, which is x to the left of the cell. + cell.areaLeft = -x + cell.areaWidth = width cell.areaTop = heights[p.row] + GapTight + under[p.row] if area.Visible() { under[p.row] += area.MinSize().Height + GapTight diff --git a/internal/gui/parts/heart.go b/internal/gui/parts/heart.go new file mode 100644 index 00000000..e82a0069 --- /dev/null +++ b/internal/gui/parts/heart.go @@ -0,0 +1,47 @@ +package parts + +import ( + _ "embed" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" +) + +// WithHeart puts the heart in front of a button's words, in red - the error +// colour, which is the red this palette already has and has measured, so the +// heart brings no colour of its own into the window. The owner's choice of +// 2026-09-24, for both Donate buttons (review UI-015). The words keep the +// look's ink, and a button switched off draws the heart in its own. +func (b *Button) WithHeart() *Button { + b.Icon = heart + b.iconInk = theme.ColorNameError + b.Refresh() + return b +} + +// HeartIcon is the heart drawn in front of Donate. +// +// Drawn here, from two circles and a point, because the toolkit has none: +// fyne v2.8.1 ships 97 icons in theme/icons and not one is a heart or a +// "favourite", checked in the module on 2026-09-24. Taking one from an icon +// set would have brought that set's licence and its notice along with it, and +// the owner chose this instead - the route the application's own icon took +// (docs/LICENSING.md, section 11). So this is the project's own drawing, under +// the project's licence, with nobody's copyright to carry. +// +// The geometry, so it can be checked rather than trusted: two circles of +// radius 5 with centres at (7.5, 9) and (16.5, 9) meet at the notch (12, +// 6.821), and the lines from the tip at (12, 21) touch them at (3.874, 12.443) +// and (20.126, 12.443). The square is 24 by 24 like the toolkit's own icons, +// and the path is filled with no colour of its own, so it is tinted by name +// the way theirs are. +// +// The file carries no xmlns: the toolkit matches the svg element by its local +// name (fyne v2.8.1 internal/svg/svg.go), and an address in a shipped file is +// what the guard against a way out reads as one. +func HeartIcon() fyne.Resource { return heart } + +//go:embed heart.svg +var heartSVG []byte + +var heart = fyne.NewStaticResource("heart.svg", heartSVG) diff --git a/internal/gui/parts/heart.svg b/internal/gui/parts/heart.svg new file mode 100644 index 00000000..ddb2ce62 --- /dev/null +++ b/internal/gui/parts/heart.svg @@ -0,0 +1 @@ + diff --git a/internal/gui/parts/listcontents.go b/internal/gui/parts/listcontents.go index 423f1b7f..9c13c200 100644 --- a/internal/gui/parts/listcontents.go +++ b/internal/gui/parts/listcontents.go @@ -18,9 +18,15 @@ type listContents struct { // filled field making "I did not say" impossible to express. chosen string - // headingOf is the heading a value stands under, or nil for a list with no - // headings. See OpenList.GroupUnder. - headingOf func(string) string + // labels are the heading each value stands under and the name each is + // called by, either nil where the list has none. See OpenList.GroupUnder + // and NameEach. + labels + // column is how wide the widest value is drawn, in bold, on a list whose + // values have names: every name starts that far in, so the names stand in + // one column the way a form's values do beside its labels. Nought on a + // list with no names. + column float32 // typed is what is in the filter box, or nothing for a list without one. typed string // entries is what the list draws now - headings, values and the notice @@ -42,10 +48,19 @@ type listContents struct { // row filled for the old arrangement can still hold a label the list no // longer draws, and RowShowing would report it. func (c *listContents) rearrange() { - c.entries = arrange(c.options, c.headingOf, c.typed) + c.entries = arrange(c.options, c.labels, c.typed) c.view.shown = 0 } +// NameEach gives every value the name nameOf says it is called, drawn beside +// it and searched by the filter. Called before the list is shown, the way +// GroupUnder is. +func (c *listContents) NameEach(nameOf func(string) string) { + c.nameOf = nameOf + c.column = widestValue(c.options) + c.rearrange() +} + // Rows is what this list is showing, for a guard to read, in the order it is // drawn - headings and the notice included, so that a position from Active // is a position here. @@ -58,7 +73,7 @@ func (c *listContents) Rows() []Choice { out := make([]Choice, 0, len(c.entries)) for _, e := range c.entries { value := e.kind == entryValue - out = append(out, Choice{Label: e.text, Marked: value && c.isChosen(e.text), Choosable: value}) + out = append(out, Choice{Label: e.text, Name: e.name, Marked: value && c.isChosen(e.text), Choosable: value}) } return out } diff --git a/internal/gui/parts/listrow.go b/internal/gui/parts/listrow.go index 15ef90ca..01de3df2 100644 --- a/internal/gui/parts/listrow.go +++ b/internal/gui/parts/listrow.go @@ -40,6 +40,17 @@ type ListRow struct { // from and to are where what was typed into the list's filter stands in // the words, drawn in bold. Equal when nothing is to be bold. from, to int + // name is what the value is called, drawn a step quieter in a column of + // its own, with nameFrom and nameTo bold the way from and to are. column + // is how far in from the words that column starts - the widest value's + // width, so it is the same in every row. Empty and nought on a list whose + // values have no names. + name string + nameFrom, nameTo int + column float32 + // notice says this row is the sentence saying the filter left nothing, + // which is drawn as a sentence rather than as one more heading. + notice bool hovered bool } @@ -65,6 +76,10 @@ func (r *ListRow) Kind() fyne.Resource { return r.kind } // matched, rather than a value somebody can take - for a guard. func (r *ListRow) Heading() bool { return r.heading } +// Name is what this row says its value is called, for a guard. Empty on a +// list whose values have no names. +func (r *ListRow) Name() string { return r.name } + func newListRow() *ListRow { r := &ListRow{} r.ExtendBaseWidget(r) @@ -104,7 +119,9 @@ func (r *ListRow) CreateRenderer() fyne.WidgetRenderer { strong := canvas.NewText("", Theme().Color(theme.ColorNameForeground, theme.VariantDark)) strong.TextStyle = fyne.TextStyle{Bold: true} rest := canvas.NewText("", Theme().Color(theme.ColorNameForeground, theme.VariantDark)) - rr := &listRowRenderer{row: r, back: back, tick: tick, kind: kind, label: label, strong: strong, rest: rest} + name := pieces{canvas.NewText("", nil), canvas.NewText("", nil), canvas.NewText("", nil)} + name.strong.TextStyle = fyne.TextStyle{Bold: true} + rr := &listRowRenderer{row: r, back: back, tick: tick, kind: kind, label: label, strong: strong, rest: rest, name: name} rr.Refresh() return rr } @@ -113,6 +130,10 @@ func (r *ListRow) CreateRenderer() fyne.WidgetRenderer { // of them, or only what comes before the part that matched the filter, which // strong draws in bold, with rest after it. One text when nothing matched, so // a list nobody typed into draws exactly what it drew before the filter. +// +// The name is three more pieces cut the same way, after the words, so that +// what matched in the name is bold on the same rule as what matched in the +// value. type listRowRenderer struct { row *ListRow back *canvas.Rectangle @@ -121,34 +142,49 @@ type listRowRenderer struct { label *canvas.Text strong *canvas.Text rest *canvas.Text + + name pieces } +// pieces are some words drawn as up to three texts: whole holds all of them, +// or only what comes before the part the filter matched, which strong draws in +// bold, with rest after it. A value's words and a name's are cut by one rule +// because they are cut by this. +type pieces struct{ whole, strong, rest *canvas.Text } + +// value is the row's own words as pieces. +func (r *listRowRenderer) value() pieces { return pieces{r.label, r.strong, r.rest} } + func (r *listRowRenderer) Layout(size fyne.Size) { icon := Theme().Size(theme.SizeNameInlineIcon) r.back.Resize(size) r.tick.Resize(fyne.NewSquareSize(icon)) - // Two shapes of row, decided by whether the list draws pictures, and both - // are the owner's, from the running window. + // The tick at the far end of every row, and three decisions of the owner's + // from the running window are behind that shape. // // A row WITHOUT a picture puts its words at the gutter - where the word - // in the box above the list starts - and the tick at the far end. Until - // 2026-09-16 the tick was in front, and its column was kept whether or - // not anything in the list was ticked, so the words of every list stood a - // column to the right of the word in the box, and a list with no picture - // and nothing chosen read as words floating in a rectangle (O220). + // in the box above the list starts. Until 2026-09-16 the tick was in + // front, and its column was kept whether or not anything in the list was + // ticked, so the words of every list stood a column to the right of the + // word in the box, and a list with no picture and nothing chosen read as + // words floating in a rectangle (O220). + // + // A row WITH a picture keeps a column in front of the picture. Moving the + // list of formats' tick to the end on 2026-09-16 took that column with it + // and pulled the picture and the word a column to the left, and the + // report of 2026-09-21 was that the list had been broken: what stood in + // the middle of the box now hugged its edge. So the tick went back in + // front on that list alone. // - // A row WITH a picture keeps the tick in front, then the picture, then - // the words - the shape the list of formats had before that day, which is - // the shape the owner had said looked right. Moving its tick to the end - // with the others pulled the picture and the word a column to the left, - // and the report of 2026-09-21 was that the list had been broken: what - // stood in the middle of the box now hugged its edge. The column in front - // makes the picture and the word sit where they did, and the tick fills - // it or leaves it empty without the row changing width. + // And on 2026-09-24 the owner asked for the tick on one side in every list + // (review UI-005), knowing both of the above. It is at the end, and the + // pictured row keeps its column in front empty - so the picture and the + // word stand where they stood, which was the whole of the 2026-09-21 + // report, and no list's words move. // - // Either way the row is the same width for a chosen value as for any - // other, because the tick's column is kept in both shapes. + // Every row is the same width for a chosen value as for any other, + // because the tick's column is kept whether it is filled or not. left, right := float32(rowGutter), float32(rowGutter) if r.row.heading { // A heading is its words at the gutter, as wide as the row. It has no @@ -162,64 +198,75 @@ func (r *listRowRenderer) Layout(size fyne.Size) { r.label.Resize(fyne.NewSize(size.Width-left-right, text.Height)) return } + r.tick.Move(fyne.NewPos(size.Width-rowGutter-icon, (size.Height-icon)/2)) + right += icon + rowGap if r.row.kind != nil { - r.tick.Move(fyne.NewPos(left, (size.Height-icon)/2)) + // The column in front, kept empty - see above. left += icon + rowGap r.kind.Resize(fyne.NewSquareSize(icon)) r.kind.Move(fyne.NewPos(left, (size.Height-icon)/2)) left += icon + rowGap } else { - r.tick.Move(fyne.NewPos(size.Width-rowGutter-icon, (size.Height-icon)/2)) - right += icon + rowGap r.kind.Resize(fyne.NewSquareSize(0)) } - r.placeWords(left, size.Width-left-right, size.Height) + room := size.Width - left - right + if r.row.column <= 0 { + r.value().place(left, room, size.Height) + return + } + // The value in a column as wide as the widest value, and the name after + // it, so every name in the list starts on one line. + r.value().place(left, r.row.column, size.Height) + at := r.row.column + listNameGap + r.name.place(left+at, fyne.Max(0, room-at), size.Height) } -// placeWords lays the pieces of the words end to end from left, the last of -// them taking what is left of the row. -func (r *listRowRenderer) placeWords(left, room, height float32) { - text := r.label.MinSize() +// place lays the pieces end to end from left, the last of them taking what is +// left of the room. +func (p pieces) place(left, room, height float32) { + text := p.whole.MinSize() y := (height - text.Height) / 2 - if !r.strong.Visible() { - r.label.Move(fyne.NewPos(left, y)) - r.label.Resize(fyne.NewSize(room, text.Height)) + if !p.strong.Visible() { + p.whole.Move(fyne.NewPos(left, y)) + p.whole.Resize(fyne.NewSize(room, text.Height)) return } - before, match := text.Width, r.strong.MinSize().Width - r.label.Move(fyne.NewPos(left, y)) - r.label.Resize(fyne.NewSize(before, text.Height)) - r.strong.Move(fyne.NewPos(left+before, y)) - r.strong.Resize(fyne.NewSize(match, text.Height)) - r.rest.Move(fyne.NewPos(left+before+match, y)) - r.rest.Resize(fyne.NewSize(fyne.Max(0, room-before-match), text.Height)) + before, match := text.Width, p.strong.MinSize().Width + p.whole.Move(fyne.NewPos(left, y)) + p.whole.Resize(fyne.NewSize(before, text.Height)) + p.strong.Move(fyne.NewPos(left+before, y)) + p.strong.Resize(fyne.NewSize(match, text.Height)) + p.rest.Move(fyne.NewPos(left+before+match, y)) + p.rest.Resize(fyne.NewSize(fyne.Max(0, room-before-match), text.Height)) } -// splitWords cuts the words where the filter matched them, or leaves them -// whole. A span that does not fit the words - a row refilled with a shorter -// value - is no span, rather than a slice out of range. -func (r *listRowRenderer) splitWords() { - words, from, to := r.row.label, r.row.from, r.row.to - if r.row.heading || from < 0 || to <= from || to > len(words) { - r.strong.Text, r.rest.Text = "", "" - r.strong.Hide() - r.rest.Hide() +// split cuts words where the filter matched them, or leaves them whole in the +// first piece. A span that does not fit the words - a row refilled with a +// shorter value - is no span, rather than a slice out of range, and so is any +// span on words drawn plain. The pieces after the first take its colour and +// size. +func (p pieces) split(words string, from, to int, plain bool) { + p.whole.Text = words + if plain || from < 0 || to <= from || to > len(words) { + p.strong.Text, p.rest.Text = "", "" + p.strong.Hide() + p.rest.Hide() return } - r.label.Text, r.strong.Text, r.rest.Text = words[:from], words[from:to], words[to:] - for _, piece := range []*canvas.Text{r.strong, r.rest} { - piece.Color = r.label.Color - piece.TextSize = r.label.TextSize + p.whole.Text, p.strong.Text, p.rest.Text = words[:from], words[from:to], words[to:] + for _, piece := range []*canvas.Text{p.strong, p.rest} { + piece.Color = p.whole.Color + piece.TextSize = p.whole.TextSize piece.Show() } } -// wordsWidth is how wide the words are drawn, all pieces together. -func (r *listRowRenderer) wordsWidth() float32 { - width := r.label.MinSize().Width - if r.strong.Visible() { - width += r.strong.MinSize().Width + r.rest.MinSize().Width +// width is how wide the words are drawn, all pieces together. +func (p pieces) width() float32 { + width := p.whole.MinSize().Width + if p.strong.Visible() { + width += p.strong.MinSize().Width + p.rest.MinSize().Width } return width } @@ -228,7 +275,11 @@ func (r *listRowRenderer) MinSize() fyne.Size { if r.row.heading { return fyne.NewSize(HeadingRowWidthFor(r.label.MinSize().Width), ListRowHeight()) } - return fyne.NewSize(RowWidthFor(r.wordsWidth(), r.row.kind != nil), ListRowHeight()) + words := r.value().width() + if r.row.column > 0 { + words = r.row.column + listNameGap + r.name.width() + } + return fyne.NewSize(RowWidthFor(words, r.row.kind != nil), ListRowHeight()) } // headingText and headingStyle are how a heading in an open list is drawn: @@ -249,6 +300,23 @@ func headingWidth(heading string) float32 { return fyne.MeasureText(heading, headingText, headingStyle).Width } +// noticeWidth is how wide the sentence saying the filter left nothing is +// drawn - as a sentence, at the size of one, and not bold. See Refresh. +func noticeWidth(notice string) float32 { + return fyne.MeasureText(notice, Theme().Size(theme.SizeNameText), fyne.TextStyle{}).Width +} + +// widestValue is how wide the widest of some values is drawn in bold, which is +// the widest any of them is ever drawn: the filter draws what matched in bold. +// On a list with names it is the column every name starts after. +func widestValue(values []string) float32 { + var widest float32 + for _, v := range values { + widest = fyne.Max(widest, fyne.MeasureText(v, Theme().Size(theme.SizeNameText), fyne.TextStyle{Bold: true}).Width) + } + return widest +} + // RowWidthFor is the room one row of an open list needs for a word that wide. // // Here rather than counted twice, because the menu that OPENS this list has to @@ -272,7 +340,9 @@ func RowWidthFor(word float32, withKind bool) float32 { icon := Theme().Size(theme.SizeNameInlineIcon) width := rowGutter + word + rowGutter + icon + rowGap if withKind { - width += icon + rowGap + // The picture, and the empty column kept in front of it since the + // tick moved to the end of every row (see Layout). + width += 2 * (icon + rowGap) } return width } @@ -281,12 +351,17 @@ func RowWidthFor(word float32, withKind bool) float32 { // keyboard wins over the pointer, because a row somebody is hovering while the // keyboard sits elsewhere would otherwise show two rows as the current one. func (r *listRowRenderer) Refresh() { - r.label.Text = r.row.label r.kind.Resource = r.row.kind r.label.Color = Theme().Color(theme.ColorNameForeground, theme.VariantDark) r.label.TextSize = Theme().Size(theme.SizeNameText) r.label.TextStyle = fyne.TextStyle{} - if r.row.heading { + switch { + case r.row.notice: + // A sentence saying what happened and what to do, so it is drawn as + // one: at the size of a sentence, not bold. In bold at the caption + // size it read as the heading of a group with nothing under it. + r.label.Color = PaletteColour(ColorNameLabel, theme.VariantDark) + case r.row.heading: // The look a field's name has - a step quieter than a value - in // bold at the caption size, so a heading reads as the name over a // group and not as one more value to take. @@ -294,7 +369,18 @@ func (r *listRowRenderer) Refresh() { r.label.TextSize = headingText r.label.TextStyle = headingStyle } - r.splitWords() + r.value().split(r.row.label, r.row.from, r.row.to, r.row.heading) + + // The name a step quieter than the value, in the colour a field's name + // has - the value is what lands in the box, and the name says what it is. + r.name.whole.Color = PaletteColour(ColorNameLabel, theme.VariantDark) + r.name.whole.TextSize = Theme().Size(theme.SizeNameText) + r.name.split(r.row.name, r.row.nameFrom, r.row.nameTo, r.row.heading) + if r.row.name != "" && !r.row.heading { + r.name.whole.Show() + } else { + r.name.whole.Hide() + } switch { case r.row.heading: @@ -321,14 +407,14 @@ func (r *listRowRenderer) Refresh() { r.kind.Hide() } - redraw(r.back, r.tick, r.kind, r.label, r.strong, r.rest) + redraw(r.back, r.tick, r.kind, r.label, r.strong, r.rest, r.name.whole, r.name.strong, r.name.rest) // The width a row asks for changes with the picture, and the row is laid // out by the list rather than by this renderer. r.Layout(r.row.Size()) } func (r *listRowRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{r.back, r.tick, r.kind, r.label, r.strong, r.rest} + return []fyne.CanvasObject{r.back, r.tick, r.kind, r.label, r.strong, r.rest, r.name.whole, r.name.strong, r.name.rest} } func (r *listRowRenderer) Destroy() {} diff --git a/internal/gui/parts/narrow.go b/internal/gui/parts/narrow.go index 85078fc2..f2cba4b6 100644 --- a/internal/gui/parts/narrow.go +++ b/internal/gui/parts/narrow.go @@ -3,6 +3,8 @@ package parts import ( "sort" "strings" + "unicode" + "unicode/utf8" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) @@ -31,10 +33,22 @@ const ( // listEntry is one row of an open list. On a value, from and to are where // what was typed stands in its words, drawn in bold - equal when nothing in // the words matched, which is a value kept for the heading it stands under. +// name is what the value is called, drawn beside it, with its own bold span in +// nameFrom and nameTo - empty on a list whose values have no names. type listEntry struct { - kind entryKind - text string - from, to int + kind entryKind + text string + from, to int + name string + nameFrom, nameTo int +} + +// labels is what a list knows about its values beyond the values themselves: +// the heading each stands under and the name each is called by. Either may be +// nil, and on most lists both are. +type labels struct { + headingOf func(string) string + nameOf func(string) string } // arrange is what an open list draws: the values the typed text keeps, each @@ -49,18 +63,18 @@ type listEntry struct { // A heading with nothing under it is not drawn: a kind with no format yet, or // one the filter emptied. A value whose heading is empty stands first, with no // heading over it, rather than under a heading made up here. -func arrange(values []string, headingOf func(string) string, typed string) []listEntry { - kept := narrow(values, headingOf, typed) +func arrange(values []string, by labels, typed string) []listEntry { + kept := narrow(values, by, typed) if len(kept) == 0 { if strings.TrimSpace(typed) == "" { return nil } return []listEntry{{kind: entryNotice, text: text.ListNothingMatches()}} } - if headingOf == nil { + if by.headingOf == nil { out := make([]listEntry, 0, len(kept)) for _, v := range kept { - out = append(out, valueEntry(v, typed)) + out = append(out, valueEntry(v, by.nameOf, typed)) } return out } @@ -68,7 +82,7 @@ func arrange(values []string, headingOf func(string) string, typed string) []lis groups := map[string][]string{} headings := []string{} for _, v := range kept { - h := headingOf(v) + h := by.headingOf(v) if _, seen := groups[h]; !seen { headings = append(headings, h) } @@ -82,25 +96,41 @@ func arrange(values []string, headingOf func(string) string, typed string) []lis out = append(out, listEntry{kind: entryHeading, text: text.ListHeadingCount(h, len(groups[h]))}) } for _, v := range groups[h] { - out = append(out, valueEntry(v, typed)) + out = append(out, valueEntry(v, by.nameOf, typed)) } } return out } -// valueEntry is one value's row, with what was typed found in its words. -// Found only where lowering the words keeps their length, so the span marks -// the same letters in the words as drawn - true of every format name, and a -// value for which it is not simply gets no bold. -func valueEntry(v, typed string) listEntry { +// valueEntry is one value's row, with what was typed found in its words and +// in its name. +// +// In the words anywhere, as the filter keeps them. In the name only at the +// start of a word, as the filter keeps them too - so the bold is always the +// reason the row is there, and never a match the filter did not count. +// +// Found only where lowering keeps the length, so the span marks the same +// letters as are drawn - true of every format and every format name, which +// are ASCII (TestEveryFormatDeclaresTheFullSet), and a value for which it is +// not simply gets no bold. +func valueEntry(v string, nameOf func(string) string, typed string) listEntry { e := listEntry{kind: entryValue, text: v} + if nameOf != nil { + e.name = nameOf(v) + } want := strings.ToLower(strings.TrimSpace(typed)) - lower := strings.ToLower(v) - if want == "" || len(lower) != len(v) { + if want == "" { return e } - if at := strings.Index(lower, want); at >= 0 { - e.from, e.to = at, at+len(want) + if lower := strings.ToLower(v); len(lower) == len(v) { + if at := strings.Index(lower, want); at >= 0 { + e.from, e.to = at, at+len(want) + } + } + if len(strings.ToLower(e.name)) == len(e.name) { + if at := wordStart(e.name, want); at >= 0 { + e.nameFrom, e.nameTo = at, at+len(want) + } } return e } @@ -117,14 +147,20 @@ func valueEntry(v, typed string) listEntry { // only where a WORD of the heading starts with what was typed. Anywhere in the // heading, one letter would keep nearly every kind: "t" is in Pictures, // Documents, Text and data. Decided by the owner on 2026-09-23. -func narrow(values []string, headingOf func(string) string, typed string) []string { +// +// And for its name, on the same rule and for the same reason, since +// 2026-09-24: "excel" keeps xlsx and "vector" svg, while "a" does not keep +// every format whose name has an a somewhere in it. +func narrow(values []string, by labels, typed string) []string { want := strings.ToLower(strings.TrimSpace(typed)) if want == "" { return values } out := make([]string, 0, len(values)) for _, v := range values { - if strings.Contains(strings.ToLower(v), want) || (headingOf != nil && aWordStartsWith(headingOf(v), want)) { + if strings.Contains(strings.ToLower(v), want) || + (by.headingOf != nil && aWordStartsWith(by.headingOf(v), want)) || + (by.nameOf != nil && aWordStartsWith(by.nameOf(v), want)) { out = append(out, v) } } @@ -180,13 +216,35 @@ func edgeValue(entries []listEntry, step int) int { return -1 } -// aWordStartsWith says whether a word of a heading starts with what was typed, -// which is already lower case. -func aWordStartsWith(heading, want string) bool { - for _, word := range strings.Fields(strings.ToLower(heading)) { +// aWordStartsWith says whether a word of a heading or a name starts with what +// was typed, which is already lower case. +func aWordStartsWith(words, want string) bool { return wordStart(words, want) >= 0 } + +// wordStart is where in words the first word starting with want begins, or -1. +// +// A word is a run of letters and digits, so what stands between words is +// anything else rather than only a space. Split on spaces alone, "office" did +// not find "Word (Office Open XML)", whose word is "(office", and "separated" +// did not find "Comma-Separated Values". The position is in the lowered words, +// which is the position in the words as drawn wherever lowering keeps the +// length - see valueEntry. +func wordStart(words, want string) int { + lower := strings.ToLower(words) + for at, r := range lower { + if !wordRune(r) { + continue + } + if before, _ := utf8.DecodeLastRuneInString(lower[:at]); at > 0 && wordRune(before) { + continue + } + word := lower[at:] if strings.HasPrefix(word, want) { - return true + return at } } - return false + return -1 } + +// wordRune says whether a character belongs to a word rather than to what +// stands between words. +func wordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } diff --git a/internal/gui/parts/openlist.go b/internal/gui/parts/openlist.go index 5cdad041..a0235f35 100644 --- a/internal/gui/parts/openlist.go +++ b/internal/gui/parts/openlist.go @@ -68,6 +68,11 @@ type OpenList struct { // filter is the box at the top that narrows the list, or nil for a list // without one. What is typed into it is listContents.typed. See WithFilter. filter *FilterBox + + // resize is how a list that opened downward tells its popup the height + // what the filter left needs, and nil on a list that keeps one height - + // see MinSize. Set by the menu that opened it, which owns the popup. + resize func(fyne.Size) } // NewOpenList builds the list. take is called with the value somebody settled @@ -75,7 +80,7 @@ type OpenList struct { func NewOpenList(options []string, chosen string, take func(string, bool), close func(bool)) *OpenList { l := &OpenList{active: -1, take: take, close: close, listContents: listContents{options: options, chosen: chosen, view: newRowView()}} - l.entries = arrange(options, nil, "") + l.entries = arrange(options, labels{}, "") l.ExtendBaseWidget(l) return l } @@ -118,6 +123,11 @@ func (l *OpenList) narrowTo(typed string) { l.typed = typed l.rearrange() l.view.toTop() + // Before either way out below, so an emptied filter gives a list that + // opened downward its whole height back as well. + if l.resize != nil { + l.resize(fyne.NewSize(l.Size().Width, l.MinSize().Height)) + } if strings.TrimSpace(typed) == "" { l.active = -1 l.StartOn(l.chosen) @@ -140,7 +150,13 @@ func (l *OpenList) fill(id int, r *ListRow) { entry := l.entries[id] r.label = entry.text r.heading = entry.kind != entryValue + r.notice = entry.kind == entryNotice r.from, r.to = entry.from, entry.to + r.name, r.nameFrom, r.nameTo = entry.name, entry.nameFrom, entry.nameTo + r.column = 0 + if entry.name != "" { + r.column = l.column + } r.kind = nil r.marked = false r.active = false @@ -158,9 +174,11 @@ func (l *OpenList) fill(id int, r *ListRow) { } // Choice is one row of an open list, for a guard to read. Choosable is false -// on a heading and on the notice that nothing matched. +// on a heading and on the notice that nothing matched. Name is what the value +// is called, empty where the list has no names. type Choice struct { Label string + Name string Marked bool Choosable bool } @@ -176,13 +194,24 @@ type Choice struct { // of rows lived here instead, which made the list 224 px tall in every window // there is (O203). // -// All of them means the whole arrangement with nothing typed, and not what the -// filter has left. The list does not shrink while somebody types into it: a -// list that opened upward would pull its bottom edge away from the box it -// belongs to with every letter, and nothing on this screen jumps under a -// person's hands (GUI rule 3). +// All of them means the whole arrangement with nothing typed, on a list that +// opened upward: shrinking there would pull its bottom edge away from the box +// it belongs to with every letter, and move the filter box at its top under +// the hands typing into it (GUI rule 3). A list that opened DOWNWARD is as +// tall as what the filter left, since 2026-09-24 - the owner's report from the +// running window was a list of one format standing over a slab of empty grey, +// and downward the box, the filter and every row above the cut stay where +// they are while only the foot moves up. Which one a list is, the menu that +// opened it says, by handing it resize. func (l *OpenList) MinSize() fyne.Size { - rows := len(arrange(l.options, l.headingOf, "")) + // Arranged afresh only for a list that opened upward: a list that opened + // downward has its rows in l.entries already, and arranging the whole of + // it again on every keystroke only to throw that away was the one cost + // here - an outside review of #136 pointed at it. + rows := len(l.entries) + if l.resize == nil { + rows = len(arrange(l.options, l.labels, "")) + } if rows < 1 { rows = 1 } @@ -241,10 +270,10 @@ func (l *OpenList) CreateRenderer() fyne.WidgetRenderer { // it" is the colour actually on the screen. rows := l.view.scroll if l.filter == nil { - return widget.NewSimpleRenderer(container.NewStack(floatingSurface(), rows)) + return widget.NewSimpleRenderer(container.NewStack(append(floatingCard(), rows)...)) } head := container.New(layout.NewCustomPaddedLayout(filterInset, filterInset, filterInset, filterInset), l.filter) - return widget.NewSimpleRenderer(container.NewStack(floatingSurface(), container.NewBorder(head, nil, nil, nil, rows))) + return widget.NewSimpleRenderer(container.NewStack(append(floatingCard(), container.NewBorder(head, nil, nil, nil, rows))...)) } // ListRowHeight is one row, for a guard asking how many rows fit in a height. diff --git a/internal/gui/parts/parts.go b/internal/gui/parts/parts.go index 787f8de4..c3439f9c 100644 --- a/internal/gui/parts/parts.go +++ b/internal/gui/parts/parts.go @@ -224,43 +224,29 @@ func panelSurface() *canvas.Rectangle { return rect } -// floatingSurface is what anything drawn OVER the form stands on: the list a -// menu drops down, and the explanation behind a field's button. -// -// One function for both since 2026-09-21, and the second of them is why. The -// explanation stood on panelSurface until then, and it opens over a section - -// so a box the colour of the thing under it had no edge anywhere, and the -// owner's report from the running window was a sentence laid straight over -// the form, covering the row beneath. The list had already met the same -// question on 2026-08-12 and the palette answers it: the surface that floats -// is the lightest one, told from a panel by 13.6 L* with no border and no -// shadow (theme.go, ColorNameMenuBackground). The corner is a field's, not a -// panel's, because what floats is the size of a control and not of a section. -func floatingSurface() *canvas.Rectangle { - rect := canvas.NewRectangle(PaletteColour(theme.ColorNameMenuBackground, theme.VariantDark)) - rect.CornerRadius = RadiusField - return rect -} - -// tipSurface is what an explanation stands on: the floating surface with a -// line round it, so it reads as a thing laid over the form and not as a -// patch of it. Owner's report from the running window, 2026-09-21: without -// the line it looked like a random rectangle. -func tipSurface() *canvas.Rectangle { - rect := floatingSurface() - rect.StrokeColor = PaletteColour(theme.ColorNameInputBorder, theme.VariantDark) - rect.StrokeWidth = edgeWidth - return rect -} - -// tipShadow is the shade an explanation casts, offset downwards so it reads -// as depth rather than as a smudge - the same reason Refactoring UI gives -// for offsetting shadows. Drawn under tipSurface in a stack, so it shows -// only past the surface's lower edge. -func tipShadow() fyne.CanvasObject { - rect := canvas.NewRectangle(PaletteColour(ColorNameTipShade, theme.VariantDark)) - rect.CornerRadius = RadiusField - return container.New(shifted{dy: TipShadowDrop}, rect) +// floatingCard is what anything drawn OVER the form stands on - the list a +// menu drops down and the explanation behind a field's button - back to +// front: the shade it casts and its face. +// +// One function for both since 2026-09-21, when the explanation stood on the +// panel's surface and read as a sentence laid straight over the form. Until +// 2026-09-24 the face was the lightest surface of the palette (menuBackground) +// - the list with no edge and no shade, the explanation with an edge - and the +// owner's report from the running window was that both looked like a plain +// grey block. Of three looks drawn side by side in the real window he chose +// this one (docs/GUI-LOOK-REVIEW-2026-09-24.md, round 2, A): the surface of a +// box to type in with a box's edge, a panel's corner, and a shade offset +// downward so it reads as depth rather than as a smudge, showing only past the +// face's lower edge. +func floatingCard() []fyne.CanvasObject { + dark := theme.VariantDark + face := canvas.NewRectangle(PaletteColour(theme.ColorNameInputBackground, dark)) + face.StrokeColor = PaletteColour(theme.ColorNameInputBorder, dark) + face.StrokeWidth = edgeWidth + face.CornerRadius = RadiusPanel + shade := canvas.NewRectangle(PaletteColour(ColorNameTipShade, dark)) + shade.CornerRadius = RadiusPanel + return []fyne.CanvasObject{container.New(shifted{dy: TipShadowDrop}, shade), face} } // shifted lays its one child at an offset from its own origin. @@ -444,11 +430,14 @@ func (dividerLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { // begin. Until 2026-08-12 they did the latter: the form stopped at 822 px and // a refusal about it ran to 1099. // -// The rail is the exception, on the owner's decision of 2026-08-19: it stands -// at the left edge of the bar rather than in that column. What it holds is -// what the run is not about - Donate, and adding a batch - so lining it up -// with the form bought nothing and spent 78 px of margin saying so. Pass nil -// on a screen that has none. +// The rail stands in that column too, on its left edge, since 2026-09-24. It +// stood at the left edge of the bar from the owner's decision of 2026-08-19 - +// what it holds is what the run is not about, Donate and adding a batch, and +// lining it up with the form seemed to buy nothing. Looked at again in the +// review of 2026-09-24 (UI-006), the bar had four left edges - Donate at the +// window's, Add a batch beside it, the buttons centred and the line under them +// on the form's - and the owner reversed the decision knowing it: one edge for +// everything that is not centred. Pass nil on a screen that has none. func ActionBar(rail fyne.CanvasObject, content ...fyne.CanvasObject) fyne.CanvasObject { // The padding goes inside the column as well as around the bar, and that is // what puts the bar's own words on the same left edge as the form's. @@ -460,8 +449,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(Column(GapLabel, content...))) - standing := fyne.CanvasObject(column) + inner := Column(GapLabel, content...) if rail != nil && len(content) > 0 { // Laid over the column rather than beside it. Sharing the row, the rail // would take width from one side only and the buttons the column @@ -470,9 +458,38 @@ func ActionBar(rail fyne.CanvasObject, content ...fyne.CanvasObject) fyne.Canvas // The vertical box is what keeps the rail one row tall. Handed straight // to a stack it would be resized to the whole bar, and a Donate button // as tall as the bar is what the first attempt drew. - standing = container.New(railOver{centred: content[0]}, column, container.NewVBox(rail)) + // + // Hung out to the left by the room a bar button keeps round its words. + // The rail opens with Donate, a quiet button - words, with a surface + // only under the pointer - so what stands on the edge has to be its + // words and not its invisible box, the rule inkTight keeps for a label. + // Measured on 2026-09-24 with the box on the edge: the heart stood + // 16 px right of the line under it. + inner = container.New(railOver{centred: content[0]}, inner, + container.NewVBox(container.New(hungOut{by: BarButtonInsetX}, rail))) + } + column := container.New(readableWidth{}, Indented(inner)) + return container.NewStack(panelSurface(), Padded(InsetBar, column)) +} + +// hungOut lays its one child that far to the left of where it stands, the part +// hanging out taking no room - so a thing whose edge is invisible stands with +// what IS visible on the edge. +type hungOut struct{ by float32 } + +func (h hungOut) MinSize(objects []fyne.CanvasObject) fyne.Size { + size := fyne.Size{} + for _, o := range objects { + size = size.Max(o.MinSize().Subtract(fyne.NewSize(h.by, 0))) + } + return size +} + +func (h hungOut) Layout(objects []fyne.CanvasObject, size fyne.Size) { + for _, o := range objects { + o.Move(fyne.NewPos(-h.by, 0)) + o.Resize(size.Add(fyne.NewSize(h.by, 0))) } - return container.NewStack(panelSurface(), Padded(InsetBar, standing)) } // railOver is the rail laid over the column, and a bar that cannot be made @@ -487,9 +504,15 @@ func ActionBar(rail fyne.CanvasObject, content ...fyne.CanvasObject) fyne.Canvas // outside review of #126 (docs/REVIEW-126-2026-09-23.md). GUI rule 21 asks // exactly this of the smallest window. // -// The buttons are centred in the bar, so the room they leave either side is -// half of what is left over. That half has to hold the rail and a gap, which -// makes the smallest bar the buttons plus twice the rail and the gap. +// The buttons are centred in the bar while there is room for that, and give +// way to the right when there is not, since 2026-09-24. Until then the +// smallest bar was the buttons plus TWICE the rail and a gap - centred, the +// room either side of them is half of what is left over, and that half had to +// hold the rail - so every pixel the rail grew cost the window two. The heart +// on Donate grew it, and the smallest window went from 695 to 743 px. The +// owner chose giving way over the wider minimum: the row keeps the rail's +// room clear at its left (buttonRow.clearLeft) and the smallest bar is the +// rail, a gap and the buttons once. type railOver struct{ centred fyne.CanvasObject } func (r railOver) MinSize(objects []fyne.CanvasObject) fyne.Size { @@ -498,17 +521,45 @@ func (r railOver) MinSize(objects []fyne.CanvasObject) fyne.Size { size = size.Max(o.MinSize()) } if len(objects) > 1 { - need := r.centred.MinSize().Width + 2*(objects[1].MinSize().Width+GapColumns) + need := objects[1].MinSize().Width + GapColumns + r.centred.MinSize().Width size.Width = fyne.Max(size.Width, need) } return size } -func (railOver) Layout(objects []fyne.CanvasObject, size fyne.Size) { +func (r railOver) Layout(objects []fyne.CanvasObject, size fyne.Size) { + // Told before the column is laid out, since laying it out lays the row out. + row, moved := r.keepRailClear(objects) for _, o := range objects { o.Resize(size) o.Move(fyne.NewPos(0, 0)) } + // A rail whose words grow while the bar keeps its size is laid out here + // again by the toolkit, and the column handed the size it already has is + // not laid out at all - fyne v2.8.1 Container.Resize returns early - so the + // row kept the room it cleared for the shorter rail and its buttons stood + // under the longer one. Measured with a probe on 2026-09-24 after an outside + // review of #136: 16.6 px of overlap for "Donate" grown to "Donate more". + if moved { + row.Layout.Layout(row.Objects, row.Size()) + } +} + +// keepRailClear tells the row of centred buttons how much room at its left the +// rail takes, and says whether that changed since the row was last told. +func (r railOver) keepRailClear(objects []fyne.CanvasObject) (*fyne.Container, bool) { + row, ok := r.centred.(*fyne.Container) + if !ok || len(objects) < 2 { + return nil, false + } + keep, ours := row.Layout.(*buttonRow) + if !ours { + return nil, false + } + room := objects[1].MinSize().Width + GapColumns + moved := keep.clearLeft != room + keep.clearLeft = room + return row, moved } // Screen stacks sections under a head - a Title, or a Titled pair. diff --git a/internal/gui/parts/ring.go b/internal/gui/parts/ring.go index cb6c3990..9f90440a 100644 --- a/internal/gui/parts/ring.go +++ b/internal/gui/parts/ring.go @@ -193,6 +193,10 @@ type Chooser struct { // See NewChooser. HeadingOf func(string) string Filtered bool + // NameOf is what a value is called, drawn beside it in the open list and + // searched by its filter - set on the same one menu, and making its list + // wider than the box it drops from. See listWidth. + NameOf func(string) string // opened is the list this menu last dropped down, and it is here for a // guard: the canvas says whether a list appeared, and this says what was // in it. Neither alone is worth anything - a list built correctly and never @@ -241,6 +245,9 @@ func NewChooser(options []string, changed func(string)) *Chooser { // not exist when it was written. c.HeadingOf = KindHeading c.Filtered = true + // And named, decided by the owner on 2026-09-24: "jxl" says nothing + // to somebody who has not met it, "JPEG XL" does. + c.NameOf = NameOfFormat } c.ExtendBaseWidget(c) return c @@ -311,10 +318,10 @@ func menuWidth(c *Chooser) float32 { widest = w } } - // This one number decides two things, because the list opens at the width of - // the box - so the box also has to fit a ROW, which carries a tick column - // and, on a list of things of different kinds, a picture in front of the - // word. + // This one number decides two things, because a list without names opens at + // the width of the box - so the box also has to fit a ROW, which carries a + // tick column and, on a list of things of different kinds, a picture in + // front of the word. // // Both are asked, and the wider wins. It was the closed box alone until // 2026-08-27, on a measurement taken on 2026-08-25 across all six menus then @@ -351,9 +358,15 @@ func menuWidth(c *Chooser) float32 { if row := RowWidthFor(widest, c.KindOf != nil); row > box { box = row } - if open := openListWidth(c); open > box { - box = open - } + // What an open list holds besides its values - headings, the filter box, + // the sentence saying nothing matched - is not counted here. It was until + // 2026-09-24, when the list of formats started working out a width of its + // own (listWidth): a list with names is wider than its box anyway, and the + // sentence saying nothing matched grew to 270 px, which counted here would + // have widened every format box, the column it stands in and the smallest + // window. Measured that day: this function came to 140 with those counted + // and 140 without, the floor below, so no box on any screen moved. + // // And never narrower than the narrowest box on these screens. // // The owner's report of 2026-08-28, from the running window: the format menu @@ -370,15 +383,25 @@ func menuWidth(c *Chooser) float32 { return fyne.Max(NumericWidth, box) } -// openListWidth is the room the rows of an open list need that are not -// values: the headings, the notice that nothing matched and the filter box -// with the words standing in it. Nought for a list that has none of them. -// -// The box has to cover these for the reason it covers a row - the list opens -// at the box's width - and they are measured here rather than read off the -// list, because the list does not exist until somebody presses the box. -func openListWidth(c *Chooser) float32 { - var widest float32 +// ListWidth is how wide the list this menu drops down is drawn, for the +// catalogue to stand a list at the width the form gives it. +func ListWidth(c *Chooser) float32 { return listWidth(c, menuWidth(c)) } + +// listWidth is how wide the list a menu drops down is: the width of the box it +// drops from, and wider where what the list holds needs more. +// +// Wider since 2026-09-24, for the list of formats, which names every value +// beside it - decided by the owner, with the box itself staying as narrow as +// its values need. Before that day the list was the box's width exactly, and +// the box was widened to fit the list's headings and filter instead. +// +// Measured from what the list will hold rather than read off the list, +// because the list does not exist until somebody presses the box. Worked out +// from the words and the scale, never from the window: a width fitted to a +// window is a width wrong in the next one (GUI rule 14). Where the result goes +// past the window's edge is ColumnForList's to answer, not this. +func listWidth(c *Chooser, box float32) float32 { + widest := box if c.HeadingOf != nil { // With the count each heading carries when nothing is typed, which is // the most it ever carries. @@ -396,11 +419,52 @@ func openListWidth(c *Chooser) float32 { inner := Theme().Size(theme.SizeNameInnerPadding) words := fyne.MeasureText(text.PlaceholderFilter(), theme.TextSize(), fyne.TextStyle{}).Width widest = fyne.Max(widest, words+4*inner+2*filterInset) - widest = fyne.Max(widest, HeadingRowWidthFor(headingWidth(text.ListNothingMatches()))) + widest = fyne.Max(widest, HeadingRowWidthFor(noticeWidth(text.ListNothingMatches()))) + } + if c.NameOf != nil { + // Every name after the column the values stand in, each measured in + // bold: the filter draws what matched in bold, and a name typed in + // full is the widest it is ever drawn. + column := widestValue(c.Options) + for _, option := range c.Options { + name := fyne.MeasureText(c.NameOf(option), theme.TextSize(), fyne.TextStyle{Bold: true}).Width + widest = fyne.Max(widest, RowWidthFor(column+listNameGap+name, c.KindOf != nil)) + } } return widest } +// ColumnForList decides where the left edge of an open list goes and how wide +// it is, the way RoomForList decides its top and height. +// +// Under the box, from the box's own left edge, is where a list belongs - it +// reads as that field's list. A list wider than its box can run past the +// window's right edge from there, which no list could before 2026-09-24, +// because none was wider than the box it drops from. So a list that would end +// past the edge, less listEdgeGap, moves left until it does not, and a list +// wider than the whole window less a gap on each side is cut to it. +// +// A list no wider than its box never moves: the box is on the screen, so a +// list at its width under it is too, and moving one because its box stands +// closer to the edge than listEdgeGap would shift lists that have always stood +// where they stand. +// +// Arithmetic rather than widgets so that it can be checked directly. Every box +// with a list wider than itself stands in a form's first column today, so no +// screen reaches the move - which is exactly why it is asked of this function +// and not only of a screen. +func ColumnForList(canvasWidth, boxLeft, boxWidth, wanted float32) (left, width float32) { + width = wanted + if room := canvasWidth - 2*listEdgeGap; width > room && width > boxWidth { + width = fyne.Max(room, boxWidth) + } + left = boxLeft + if end := canvasWidth - listEdgeGap; width > boxWidth && left+width > end { + left = fyne.Max(listEdgeGap, end-width) + } + return left, width +} + // useRing takes the ring and asks it for a line at rest as well as the two it // already draws. The border a menu wears is the ring at its quietest, so there // is exactly one edge round this control in every state. @@ -513,20 +577,31 @@ func (c *Chooser) drop(surface fyne.Canvas) { if c.Filtered { list.WithFilter() } + if c.NameOf != nil { + list.NameEach(c.NameOf) + } pop = widget.NewPopUp(list, surface) c.opened = list at := fyne.CurrentApp().Driver().AbsolutePositionForObject(c) - // As wide as the box, so the list reads as belonging to that field. How - // tall and which side of the box it goes on is worked out from the room - // that is actually left - see roomForList. + // From the box's left edge, so the list reads as belonging to that field, + // and as wide as the box unless what it holds needs more - see listWidth. + // How tall and which side of the box it goes on is worked out from the + // room that is actually left - see RoomForList - and where its left edge + // goes when it is wider than the room beside the box, by ColumnForList. height, top := RoomForList(surface.Size().Height, at.Y, c.Size().Height, list.MinSize().Height, list.HeadHeight()) + left, width := ColumnForList(surface.Size().Width, at.X, c.Size().Width, listWidth(c, c.Size().Width)) // Told to the list rather than only to the popup, because a popup is never // laid out smaller than its content's minimum - so resizing alone left the // list its full height and the shortening did nothing. list.LimitTo(height) - pop.Resize(fyne.NewSize(c.Size().Width, height)) - pop.ShowAtPosition(fyne.NewPos(at.X, top)) + pop.Resize(fyne.NewSize(width, height)) + pop.ShowAtPosition(fyne.NewPos(left, top)) + // Opened downward, the list follows what its filter leaves - see + // OpenList.MinSize. The room worked out now stays its ceiling. + if top >= at.Y+c.Size().Height { + list.resize = pop.Resize + } surface.Focus(list.Keyboard()) // On the value already in the box, so that pressing Down once does not go diff --git a/internal/gui/parts/toggle.go b/internal/gui/parts/toggle.go index acb8f159..116b95eb 100644 --- a/internal/gui/parts/toggle.go +++ b/internal/gui/parts/toggle.go @@ -177,22 +177,27 @@ func (t *Toggle) CreateRenderer() fyne.WidgetRenderer { ring.StrokeColor = PaletteColour(theme.ColorNamePrimary, theme.VariantDark) square := canvas.NewRectangle(color.Transparent) square.CornerRadius = RadiusMark - tick := canvas.NewImageFromResource(theme.NewColoredResource(theme.ConfirmIcon(), theme.ColorNameForegroundOnPrimary)) + tickOn := theme.NewColoredResource(theme.ConfirmIcon(), theme.ColorNameForegroundOnPrimary) + tick := canvas.NewImageFromResource(tickOn) tick.FillMode = canvas.ImageFillContain - r := &toggleRenderer{toggle: t, halo: halo, ring: ring, square: square, tick: tick} + r := &toggleRenderer{toggle: t, halo: halo, ring: ring, square: square, tick: tick, + tickOn: tickOn, tickOff: theme.NewColoredResource(theme.ConfirmIcon(), theme.ColorNameDisabled)} 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. +// same box. tickOn is the tick on the primary colour and tickOff the one on a +// switch that is off, made once each rather than on every refresh. type toggleRenderer struct { - toggle *Toggle - halo *canvas.Rectangle - ring *canvas.Rectangle - square *canvas.Rectangle - tick *canvas.Image + toggle *Toggle + halo *canvas.Rectangle + ring *canvas.Rectangle + square *canvas.Rectangle + tick *canvas.Image + tickOn fyne.Resource + tickOff fyne.Resource } func (r *toggleRenderer) Layout(size fyne.Size) { @@ -237,10 +242,24 @@ func (r *toggleRenderer) Refresh() { } else { r.ring.StrokeWidth = 0 } + r.tick.Resource = r.tickOn switch { + case off && r.toggle.Checked: + // Still ticked. Until 2026-09-24 a switch that was off hid its tick + // whatever its value, so "Label in each file" ticked read as unticked + // for the length of every run - the form said the opposite of the run + // it was frozen for. Now the tick stays, on the quiet surface of a + // switched off control rather than the primary colour. + r.square.FillColor = PaletteColour(theme.ColorNameSeparator, dark) + r.square.StrokeWidth = 0 + r.tick.Resource = r.tickOff + r.tick.Show() case off: + // A step QUIETER than the edge at rest (ColorNameInputBorder), where it + // was the disabled ink until 2026-09-24 - a step brighter, so a + // switch frozen for a run read as more there than one you could press. r.square.FillColor = color.Transparent - r.square.StrokeColor = PaletteColour(theme.ColorNameDisabled, dark) + r.square.StrokeColor = PaletteColour(theme.ColorNameSeparator, dark) r.square.StrokeWidth = edgeWidth r.tick.Hide() case r.toggle.Checked: diff --git a/internal/gui/parts/tokens.go b/internal/gui/parts/tokens.go index ad5cca1f..850673b6 100644 --- a/internal/gui/parts/tokens.go +++ b/internal/gui/parts/tokens.go @@ -273,6 +273,10 @@ const ( // its second step now, so a row is 4 px wider than it was. rowGutter = space2 rowGap = space2 + // listNameGap is the room between a value and its name in an open list + // whose values have names. Twice rowGap, so the name reads as a second + // column rather than as more words of the value. + listNameGap = space4 // filterInset is the room round the filter box at the top of an open // list, so the box reads as standing inside the list rather than as a // second field glued onto its top edge. diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index c423e61b..fd287d1b 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -335,7 +335,7 @@ }, "ListNothingMatches": { "description": "Shown inside an open list somebody chooses from.", - "other": "Nothing matches" + "other": "No format matches - clear the box to see all" }, "ManifestNamed": { "description": "Shown in the window. Carries one value, {{.Name}}, which has to stay spelled exactly that way.", diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 8c22a174..6f7e5e79 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -253,14 +253,19 @@ func PresetCatchesHeading() string { return say("PresetCatchesHeading", "Typical func PlaceholderWorkedOut() string { return say("PlaceholderWorkedOut", "worked out from the size") } // PlaceholderFilter stands in the box at the top of an open list of formats, -// where typing narrows the list to the formats whose name holds what was -// typed. +// where typing narrows the list to the formats whose identifier holds what was +// typed, or whose name or kind has a word starting with it. func PlaceholderFilter() string { return say("PlaceholderFilter", "type to filter") } // ListNothingMatches stands in an open list whose filter left no value. It is // a row nobody can choose, so the list does not look broken while it is -// empty. -func ListNothingMatches() string { return say("ListNothingMatches", "Nothing matches") } +// empty, and it says what to do as well as what happened: "Nothing matches" +// said only the second, so the way back to the whole list was left to be +// guessed (review of #127, decided by the owner on 2026-09-23). "The box" is +// the filter box the person is typing in, the one control the list has. +func ListNothingMatches() string { + return say("ListNothingMatches", "No format matches - clear the box to see all") +} // The headings inside an open list of formats, one over each kind of file. // The list puts the kinds in the order of these words, so a translation diff --git a/internal/gui/window/about.go b/internal/gui/window/about.go index 7c399ccb..7904b76d 100644 --- a/internal/gui/window/about.go +++ b/internal/gui/window/about.go @@ -37,9 +37,7 @@ import ( // replaced the whole window it needed a door, and a door somebody could delete // without noticing was the thing worth guarding. func About(h Host) fyne.CanvasObject { - sections := []fyne.CanvasObject{ - parts.Indented(parts.Prose(text.AboutTagline())), - } + var sections []fyne.CanvasObject // Under the tagline and only when it is true: the window is drawn by the // software renderer shipped beside it, not by a driver. Said here rather // than in a title or a dialog because it is a fact about this window for @@ -85,11 +83,15 @@ func About(h Host) fyne.CanvasObject { // under the button for the reason above. parts.Section(text.SectionSupport(), parts.Prose(text.DetailDonate()), - container.NewHBox(parts.NewButton(parts.Secondary, text.ButtonDonate(), func() { h.OpenLink(text.SupportURL) })), + container.NewHBox(donate(h, parts.Secondary)), parts.Prose(text.SupportURL)), ) sections = append(sections, carried()...) - page := parts.Screen(parts.Title(text.HeadingAbout(version.Version)), sections...) + // The title with its sentence under it, as on the other three screens. + // The sentence stood as a section of its own until 2026-09-24 and sat + // 16 px further from the title than every other screen's does, measured + // on the stored screens (review UI-007). + page := parts.Screen(parts.Titled(text.HeadingAbout(version.Version), text.AboutTagline()), sections...) // No bar at the foot, since the prototype of 2026-09-23. It held only the // Donate button, which is in the Support card now - so the button is still diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index b0e90bbd..f1182aad 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -520,7 +520,7 @@ func (g *Generate) rebuildDamageFields() { return } - g.damage.fold = parts.NewInnerFoldingOf(parts.GroupDamage, text.DamageSettingsFor(d.ID), objects...) + g.damage.fold = parts.NewInnerFolding(text.DamageSettingsFor(d.ID), objects...) g.damage.fold.OnChange = func(open bool) { g.damage.folded = !open } g.damage.fold.Set(!g.damage.folded) g.damage.box.Add(g.damage.fold.Object()) diff --git a/internal/gui/window/open.go b/internal/gui/window/open.go index 5b7131c8..1644dbfe 100644 --- a/internal/gui/window/open.go +++ b/internal/gui/window/open.go @@ -332,7 +332,14 @@ func FirstScreen(h Host) fyne.CanvasObject { // rule 8 intact - see the carve out written into it on 2026-08-18. func donateButton(h Host) fyne.CanvasObject { // The bar's size, so its words stand level with the buttons beside them. - return parts.NewButton(parts.Quiet, text.ButtonDonate(), func() { h.OpenLink(text.SupportURL) }).InTheBar() + return donate(h, parts.Quiet).InTheBar() +} + +// donate is the Donate button in the look of the place it stands, with the +// heart in front of its word. One builder for the bar and the About card, so +// the two cannot come to look like two different buttons (GUI rule 5). +func donate(h Host, look parts.Look) *parts.Button { + return parts.NewButton(look, text.ButtonDonate(), func() { h.OpenLink(text.SupportURL) }).WithHeart() } // chooserFor is the output directory box with a way to browse to one. diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go index bcb1199e..e47b7b9e 100644 --- a/internal/gui/window/recipe.go +++ b/internal/gui/window/recipe.go @@ -471,7 +471,7 @@ func (r *Recipe) batchBlock(index int, b *batch) fyne.CanvasObject { parts.NewButton(parts.Secondary, text.ButtonDuplicateBatch(), func() { r.duplicateBatch(index) }), } if len(r.batches) > 1 || r.base.carriesTheRun() { - head = append(head, parts.NewButton(parts.Secondary, text.ButtonRemoveBatch(), func() { r.removeBatch(index) })) + head = append(head, parts.NewButton(parts.Secondary, text.ButtonRemoveBatch(), func() { r.removeBatch(index) }).Removing()) } b.fold = parts.NewFolding(text.BatchHeading(index+1), head, rows...) r.wire(b.fold, &b.folded, b.summary) @@ -551,7 +551,7 @@ func (r *Recipe) contentCells(table *parts.Table, b *batch, index, j int, c *con // Not a field, so the row would hand it a whole column - see // parts.BesideFields for the numbers that came off this very button. parts.BesideFields( - parts.NewButton(parts.Secondary, text.ButtonRemoveContents(), func() { r.removeContent(b, j) })), + parts.NewButton(parts.Secondary, text.ButtonRemoveContents(), func() { r.removeContent(b, j) }).Removing()), } } diff --git a/internal/gui/window/recipefolds.go b/internal/gui/window/recipefolds.go index 670f6514..8cb9155d 100644 --- a/internal/gui/window/recipefolds.go +++ b/internal/gui/window/recipefolds.go @@ -83,7 +83,7 @@ func (r *Recipe) declaredSettings(b *batch, at func(string) string) fyne.CanvasO // rule at all. TestNothingInTheManifestNotesChangesAByte holds the line against // the engine rather than against this comment. func (r *Recipe) manifestNotes(b *batch, add addField) fyne.CanvasObject { - b.notes = parts.NewInnerFoldingOf(parts.GroupNotes, text.SectionManifestNotes(), + b.notes = parts.NewInnerFolding(text.SectionManifestNotes(), parts.Note(text.NoteManifestOnly()), add(recipe.KeyGroup, text.FieldGroup(), text.HintGroup(), r.tips.Say(text.DetailGroup()), parts.Text(b.group)), diff --git a/internal/site/site.go b/internal/site/site.go index 2637d55e..23b2a042 100644 --- a/internal/site/site.go +++ b/internal/site/site.go @@ -49,7 +49,12 @@ type Property struct { // line prints the accepted one - so the site prints the same, or the two // surfaces would answer one question with two numbers. type Format struct { - ID string + ID string + // Name is what the format is called, from the registry and in English on + // every page. A proper name is not translated, which is why the registry + // can own it - and the two that describe rather than name (plain text and + // a log) are the known exception, decided on 2026-09-24. + Name string Extension string Fidelity string Determinism string diff --git a/web/content/en/site.json b/web/content/en/site.json index 313856e3..0c10a354 100644 --- a/web/content/en/site.json +++ b/web/content/en/site.json @@ -56,6 +56,7 @@ "ctaSource": "View the source", "ctaNote": "Free and open source, GPL-3.0. Nothing to sign up for. The Windows and macOS downloads are signed and start without a warning.", "colFormat": "Format", + "colName": "Name", "colExtension": "Extension", "colSmallest": "Smallest file", "colFidelity": "Fidelity", diff --git a/web/content/pl/site.json b/web/content/pl/site.json index 2b22a755..e63529fd 100644 --- a/web/content/pl/site.json +++ b/web/content/pl/site.json @@ -56,6 +56,7 @@ "ctaSource": "Zobacz kod źródłowy", "ctaNote": "Darmowe i otwarte, GPL-3.0. Bez zakładania konta. Pliki dla Windows i macOS są podpisane i uruchamiają się bez ostrzeżenia.", "colFormat": "Format", + "colName": "Nazwa", "colExtension": "Rozszerzenie", "colSmallest": "Najmniejszy plik", "colFidelity": "Wierność", diff --git a/web/public/formats/index.html b/web/public/formats/index.html index 0b876abf..5f8a06c5 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -86,6 +86,7 @@

26 file formats, every one generated at an exact size

Format + Name Extension Smallest file Fidelity @@ -95,6 +96,7 @@

26 file formats, every one generated at an exact size

avif + AV1 Image File Format .avif 311 full @@ -102,6 +104,7 @@

26 file formats, every one generated at an exact size

bmp + Windows Bitmap .bmp 58 full @@ -109,6 +112,7 @@

26 file formats, every one generated at an exact size

csv + Comma-Separated Values .csv 115 full @@ -116,6 +120,7 @@

26 file formats, every one generated at an exact size

docx + Word (Office Open XML) .docx 1220 full @@ -123,6 +128,7 @@

26 file formats, every one generated at an exact size

gif + Graphics Interchange Format .gif 114 full @@ -130,6 +136,7 @@

26 file formats, every one generated at an exact size

html + HyperText Markup Language .html 118 full @@ -137,6 +144,7 @@

26 file formats, every one generated at an exact size

ico + Windows Icon .ico 70 full @@ -144,6 +152,7 @@

26 file formats, every one generated at an exact size

jpg + JPEG .jpg 602 full @@ -151,6 +160,7 @@

26 file formats, every one generated at an exact size

json + JavaScript Object Notation .json 219 full @@ -158,6 +168,7 @@

26 file formats, every one generated at an exact size

jxl + JPEG XL .jxl 147 full @@ -165,6 +176,7 @@

26 file formats, every one generated at an exact size

log + Server and application log .log 155 full @@ -172,6 +184,7 @@

26 file formats, every one generated at an exact size

md + Markdown .md 0 full @@ -179,6 +192,7 @@

26 file formats, every one generated at an exact size

pdf + Portable Document Format .pdf 3415 full @@ -186,6 +200,7 @@

26 file formats, every one generated at an exact size

png + Portable Network Graphics .png 74 full @@ -193,6 +208,7 @@

26 file formats, every one generated at an exact size

pptx + PowerPoint (Office Open XML) .pptx 4809 full @@ -200,6 +216,7 @@

26 file formats, every one generated at an exact size

svg + Scalable Vector Graphics .svg 194 full @@ -207,6 +224,7 @@

26 file formats, every one generated at an exact size

targz + tar + gzip .tar.gz 9790 full @@ -214,6 +232,7 @@

26 file formats, every one generated at an exact size

tiff + Tagged Image File Format .tiff 183 full @@ -221,6 +240,7 @@

26 file formats, every one generated at an exact size

toml + TOML .toml 212 full @@ -228,6 +248,7 @@

26 file formats, every one generated at an exact size

txt + Plain text .txt 0 full @@ -235,6 +256,7 @@

26 file formats, every one generated at an exact size

wav + Waveform Audio .wav 98 full @@ -242,6 +264,7 @@

26 file formats, every one generated at an exact size

webp + WebP .webp 148 full @@ -249,6 +272,7 @@

26 file formats, every one generated at an exact size

xlsx + Excel (Office Open XML) .xlsx 1726 full @@ -256,6 +280,7 @@

26 file formats, every one generated at an exact size

xml + Extensible Markup Language .xml 264 full @@ -263,6 +288,7 @@

26 file formats, every one generated at an exact size

yaml + YAML .yaml 241 full @@ -270,6 +296,7 @@

26 file formats, every one generated at an exact size

zip + ZIP .zip 8382 full diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index 4a071af9..5dbfa2a4 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -85,6 +85,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

Format + Nazwa Rozszerzenie Najmniejszy plik Wierność @@ -94,6 +95,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

avif + AV1 Image File Format .avif 311 full @@ -101,6 +103,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

bmp + Windows Bitmap .bmp 58 full @@ -108,6 +111,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

csv + Comma-Separated Values .csv 115 full @@ -115,6 +119,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

docx + Word (Office Open XML) .docx 1220 full @@ -122,6 +127,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

gif + Graphics Interchange Format .gif 114 full @@ -129,6 +135,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

html + HyperText Markup Language .html 118 full @@ -136,6 +143,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

ico + Windows Icon .ico 70 full @@ -143,6 +151,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

jpg + JPEG .jpg 602 full @@ -150,6 +159,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

json + JavaScript Object Notation .json 219 full @@ -157,6 +167,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

jxl + JPEG XL .jxl 147 full @@ -164,6 +175,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

log + Server and application log .log 155 full @@ -171,6 +183,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

md + Markdown .md 0 full @@ -178,6 +191,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

pdf + Portable Document Format .pdf 3415 full @@ -185,6 +199,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

png + Portable Network Graphics .png 74 full @@ -192,6 +207,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

pptx + PowerPoint (Office Open XML) .pptx 4809 full @@ -199,6 +215,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

svg + Scalable Vector Graphics .svg 194 full @@ -206,6 +223,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

targz + tar + gzip .tar.gz 9790 full @@ -213,6 +231,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

tiff + Tagged Image File Format .tiff 183 full @@ -220,6 +239,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

toml + TOML .toml 212 full @@ -227,6 +247,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

txt + Plain text .txt 0 full @@ -234,6 +255,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

wav + Waveform Audio .wav 98 full @@ -241,6 +263,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

webp + WebP .webp 148 full @@ -248,6 +271,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

xlsx + Excel (Office Open XML) .xlsx 1726 full @@ -255,6 +279,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

xml + Extensible Markup Language .xml 264 full @@ -262,6 +287,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

yaml + YAML .yaml 241 full @@ -269,6 +295,7 @@

26 formatów plików, każdy generowany o dokładnym rozmiarze

zip + ZIP .zip 8382 full diff --git a/web/templates/partials.html b/web/templates/partials.html index 6a504a22..dbb4a038 100644 --- a/web/templates/partials.html +++ b/web/templates/partials.html @@ -93,6 +93,7 @@ {{ .Word "colFormat" }} + {{ .Word "colName" }} {{ .Word "colExtension" }} {{ .Word "colSmallest" }} {{ .Word "colFidelity" }} @@ -103,6 +104,7 @@ {{- range .Facts.Formats }} {{ .ID }} + {{ .Name }} {{ .Extension }} {{ .Smallest }} {{ .Fidelity }}