diff --git a/.github/scripts/make_app_bundle.sh b/.github/scripts/make_app_bundle.sh index ee4f074..3fdbe59 100755 --- a/.github/scripts/make_app_bundle.sh +++ b/.github/scripts/make_app_bundle.sh @@ -37,10 +37,29 @@ if [ ! -f "${work}/${binary}" ]; then fi app="${work}/${binary}.app" -mkdir -p "${app}/Contents/MacOS" +mkdir -p "${app}/Contents/MacOS" "${app}/Contents/Resources" mv "${work}/${binary}" "${app}/Contents/MacOS/${binary}" chmod +x "${app}/Contents/MacOS/${binary}" +# The icon, and it is refused rather than skipped when it is missing. +# +# A bundle without one is not a bundle that looks slightly worse - macOS draws a +# blank sheet of paper for it in the Finder and the Dock, which is what a program +# it knows nothing about looks like. O154, reported by the owner on 2026-08-28. +# +# The file is in the repository rather than made here with sips and iconutil, +# because this script runs on ubuntu: the command line archives are cross +# compiled, so Apple's tools are not there. tools/appicon.py writes it, at every +# size macOS asks for, and Apple's own iconutil was asked whether it accepts +# what came out. +here="$(cd "$(dirname "$0")" && pwd)" +icon="${here}/../../internal/gui/icon/chickpea.icns" +if [ ! -f "${icon}" ]; then + echo "make_app_bundle: no icon at ${icon}, so the bundle would show a blank page" >&2 + exit 1 +fi +cp "${icon}" "${app}/Contents/Resources/icon.icns" + # LSMinimumSystemVersion is 11.0 because this project builds darwin/arm64 only # and Apple silicon starts there. NSHighResolutionCapable keeps the window from # being drawn blurry on a Retina display, and costs the command line nothing. @@ -51,6 +70,8 @@ cat > "${app}/Contents/Info.plist" < CFBundleExecutable ${binary} + CFBundleIconFile + icon CFBundleIdentifier ${bundle_id} CFBundleName diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d2a19..1febd8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,26 @@ because it turns other people's test suites red. ### Fixed +- On macOS the program now has an icon. It is a `.app` bundle since the release + before this one, and a bundle with no icon in it is drawn by the Finder and + the Dock as a blank sheet of paper - which is what a program the system knows + nothing about looks like. The icon is the same drawing the other two systems + use, on the rounded square macOS puts every icon on, at every size from 16 px + to 1024. + +- In the window, a menu is now the same width wherever it appears, and always + wide enough for the words in it. The menu for choosing a format was 140 px + wide on the single batch screen and 98 px on the presets screen and in a row + of an archive's contents, for the same twenty formats. In that narrow box the + toolkit's own "(Select one)" was cut off mid word, so a row of an archive's + contents offered "(Select ..." until a format was picked. No menu is drawn + narrower than the boxes standing beside it any more. + +- In the window, the Remove button ending a row of an archive's contents is the + size of a button. It was taking a quarter of the form's width and the height + of a label and a control together, which drew it as a panel with a word in + the middle rather than as something to press. + - The notices that travel with a release now name the fonts and drawings the window binary carries. Seven font files and ninety-seven images are compiled into `tfg-gui` from inside the graphics toolkit, under the SIL Open Font diff --git a/internal/guard/formwidth_test.go b/internal/guard/formwidth_test.go index 2641742..cc334a6 100644 --- a/internal/guard/formwidth_test.go +++ b/internal/guard/formwidth_test.go @@ -124,6 +124,60 @@ func TestABoxForANumberIsTheWidthOfANumber(t *testing.T) { // Only the contents are asked about here. A guard that also demanded the // surface stop at the column would be pinning down the one thing that is // deliberately different, and the next person to widen the bar would delete it. +// A button standing in a row of fields is the size of a button. +// +// Reported by the owner on 2026-08-28 from the running window, and the numbers +// are off the laid out screen rather than off the code: the Remove button +// ending a row of an archive's contents was 197.50 x 63.16 px for a word the +// toolkit says needs 67.92 x 32. That is a quarter of the form wide and as tall +// as a label and a control together, so it read as a grey panel with a word in +// the middle of it. The Duplicate button at the head of the same batch, which +// stands in no row, is 78.97 x 35.16. +// +// The cause is that parts.Row shares the width out in equal columns, which is +// what a field wants and what anything else gets whether it wants it or not. +// See parts.BesideFields. +// +// Asked against MinSize, which is the widget's own answer for the room its word +// needs, so nothing here repeats a layout's arithmetic. Both directions, because +// a button smaller than its own minimum is a word cut in half. +func TestAButtonInARowOfFieldsIsTheSizeOfAButton(t *testing.T) { + ourTheme(t) + content, _ := laidOutWindow(t) + screen := selectTab(t, content, text.TabRecipe()) + + // An archive first. The only row in this window that ends in a button is + // the one saying what an archive holds, and it is not on the screen until a + // batch says it holds anything. + chooseFormat(t, screen, "zip") + pressNamed(t, screen, text.ButtonAddContents()) + + remove := buttonNamed(screen, text.ButtonRemoveContents()) + if remove == nil { + t.Fatalf("no %q button after asking a zip what it holds, so this guard checked nothing", + text.ButtonRemoveContents()) + } + got, needs := remove.Size(), remove.MinSize() + if got.Width == 0 || got.Height == 0 { + t.Fatal("the button was never laid out, so its size says nothing") + } + const slack = 0.5 + if got.Width > needs.Width+slack || got.Height > needs.Height+slack { + t.Errorf("the %q button in a row of an archive's contents is %.2f x %.2f px and the word"+ + " in it needs %.2f x %.2f, so the row is drawing it as a panel rather than as a"+ + " button.\n"+ + "What to do: parts.BesideFields keeps something that is not a field out of the"+ + " column arithmetic.", + remove.Text, got.Width, got.Height, needs.Width, needs.Height) + } + if got.Width+slack < needs.Width || got.Height+slack < needs.Height { + t.Errorf("the %q button is %.2f x %.2f px and needs %.2f x %.2f, so its word is cut off.", + remove.Text, got.Width, got.Height, needs.Width, needs.Height) + } + t.Logf("the %q button is %.2f x %.2f, and it needs %.2f x %.2f", + remove.Text, got.Width, got.Height, needs.Width, needs.Height) +} + func TestTheRunSpeaksInsideTheSameColumnAsTheForm(t *testing.T) { host := newFakeHost(t) window.Open(host) diff --git a/internal/guard/macossigning_test.go b/internal/guard/macossigning_test.go index 1122635..ff4ea6c 100644 --- a/internal/guard/macossigning_test.go +++ b/internal/guard/macossigning_test.go @@ -1,6 +1,10 @@ package guard import ( + "bytes" + "encoding/binary" + "image" + _ "image/png" "os" "path/filepath" "regexp" @@ -96,6 +100,110 @@ func TestTheLinkBesideTheBundleCannotBeACopy(t *testing.T) { } } +// The bundle carries an icon, and it refuses to be built without one. +// +// O154, reported by the owner on 2026-08-28: a bundle with no CFBundleIconFile +// and nothing in Resources is drawn by the Finder and the Dock as a blank sheet +// of paper, which is what a program the system knows nothing about looks like. +// It was not a regression - until that day macOS got a bare binary and had no +// icon either - and it became visible the moment the program became a .app. +// +// Refusing rather than skipping, because the failure is silent on both ends: a +// bundle without an icon builds, signs, notarises and staples exactly like one +// with an icon, and nothing before a person's screen would say a word. +func TestTheMacBundleCarriesOurIcon(t *testing.T) { + script := bundleScript(t) + + if !strings.Contains(script, "chickpea.icns") { + t.Error("make_app_bundle.sh never copies an icon into the bundle, so macOS draws " + + "the program as a blank page in the Finder and the Dock") + } + if !strings.Contains(script, "Contents/Resources/icon.icns") { + t.Error("make_app_bundle.sh puts no icon.icns in Contents/Resources, which is " + + "where CFBundleIconFile is looked up") + } + if !strings.Contains(script, "CFBundleIconFile") { + t.Error("the Info.plist this script writes names no icon file, so an icon sitting " + + "in Resources is never read") + } + // The refusal, and it is the half worth guarding. An icon that quietly is + // not there produces a bundle that is wrong in the one way nothing later in + // the release can see. + if !strings.Contains(script, "no icon at") { + t.Error("make_app_bundle.sh does not stop when the icon is missing, so a build " + + "with no icon file produces a bundle that looks finished and shows a blank page") + } +} + +// And the icon it copies really carries every size macOS asks for. +// +// Read out of the bytes rather than trusted, because this file is written by a +// script that is not run in CI and cannot be regenerated here - Pillow is a +// build tool on one machine. So the committed file is the artefact, and what +// nobody would notice is a file that parses, opens in a viewer, and is missing +// the two sizes a screen without Retina asks for. +// +// Apple's own iconutil was asked whether it accepts what tools/appicon.py +// writes, on a real Mac on 2026-08-28, and handed back all ten entries at the +// pixel sizes below. This guard is the part of that answer that can be asked +// again on any machine, on every run. +func TestTheIconMacOSReadsCarriesEverySizeItIsAskedFor(t *testing.T) { + body, err := os.ReadFile(filepath.Join(repoRoot(t), "internal", "gui", "icon", "chickpea.icns")) + if err != nil { + t.Skipf("no macOS icon here: %v", err) + } + if len(body) < 8 || string(body[:4]) != "icns" { + t.Fatalf("the icon does not start with the four bytes that say what it is, so no "+ + "reader will take it: %q", body[:min(8, len(body))]) + } + if declared := binary.BigEndian.Uint32(body[4:8]); int(declared) != len(body) { + t.Errorf("the icon says it is %d bytes and it is %d, and a reader that trusts the "+ + "header stops early or runs off the end", declared, len(body)) + } + // Apple's names for the sizes, with the pixels each one has to hold. Both + // members of a pair are the same picture: macOS asks for a point size and a + // scale, so 32 px answers two questions and has to be in the file twice. + wanted := map[string]int{ + "icp4": 16, "ic11": 32, "icp5": 32, "ic12": 64, "ic07": 128, + "ic13": 256, "ic08": 256, "ic14": 512, "ic09": 512, "ic10": 1024, + } + found := map[string]int{} + for at := 8; at < len(body); { + if at+8 > len(body) { + t.Fatalf("a chunk header runs past the end of the file at byte %d", at) + } + name := string(body[at : at+4]) + size := int(binary.BigEndian.Uint32(body[at+4 : at+8])) + if size < 8 || at+size > len(body) { + t.Fatalf("the %q chunk says it is %d bytes, which does not fit in the file", name, size) + } + config, format, err := image.DecodeConfig(bytes.NewReader(body[at+8 : at+size])) + if err != nil { + t.Fatalf("the %q chunk does not hold a picture: %v", name, err) + } + if format != "png" { + t.Errorf("the %q chunk holds a %s and a bundle icon is read as PNG", name, format) + } + if config.Width != config.Height { + t.Errorf("the %q chunk is %dx%d and an icon is square", name, config.Width, config.Height) + } + found[name] = config.Width + at += size + } + for name, pixels := range wanted { + got, is := found[name] + if !is { + t.Errorf("the icon has no %q entry, so macOS falls back to scaling another size "+ + "where it wanted %d px", name, pixels) + continue + } + if got != pixels { + t.Errorf("the %q entry is %d px and macOS reads it as %d", name, got, pixels) + } + } + t.Logf("%d entries, every size macOS asks for, %d bytes", len(found), len(body)) +} + // The pin is a digest with a date, and the script derives its selector from it. // // Same shape as the Windows pin and for the same reason: codesign selects by diff --git a/internal/guard/menushape_test.go b/internal/guard/menushape_test.go index 991f141..306ab85 100644 --- a/internal/guard/menushape_test.go +++ b/internal/guard/menushape_test.go @@ -9,6 +9,7 @@ import ( "fyne.io/fyne/v2/theme" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) // What this defends. A control you press to open a list is not drawn as a @@ -180,3 +181,134 @@ func typingBoxesOn(screen fyne.CanvasObject) []*parts.Entry { }) return found } + +// What this defends. A menu is at least as wide as the toolkit says it needs to +// show what it is showing. +// +// Why it needed a guard, and it is a defect somebody looked straight at. +// Reported by the owner on 2026-08-28 from the running window and then rendered: +// the format menu in a row of an archive's contents drew "(Select ..." - the +// placeholder cut off in the middle of the word it exists to show. Measured with +// tools/probes/menuwidth: that box was 97.75 px and the toolkit's own answer for +// what it needs was 119.91. +// +// How it is asked. Against MinSize, which is the widget's own claim about the +// room its text takes, rather than against arithmetic repeated here. That is +// deliberate for two reasons. A guard that recomputed parts.menuWidth would +// agree with a wrong answer. And the string being measured is one the toolkit +// puts in by itself - fyne v2.8.1 widget/select.go line 94 substitutes its +// default placeholder while the renderer is made - so the only party that knows +// what a menu will end up showing is the menu. +func TestAMenuIsWideEnoughForTheWordsTheToolkitPutsInIt(t *testing.T) { + ourTheme(t) + content, canvas := laidOutWindow(t) + + checked := 0 + tightest, tightestIn := float32(0), float32(0) + for _, tab := range allTabs() { + screen := selectTab(t, content, tab) + for _, menu := range menusWithAnArchiveOpened(t, screen, tab, canvas) { + room, needs := menu.Size().Width, menu.MinSize().Width + if room == 0 { + continue + } + if room < needs { + t.Errorf("a menu of %v on the %s screen is %.2f px and the toolkit says it needs"+ + " %.2f to show what is in it, so the words are cut off in the box that"+ + " exists to show them.\n"+ + "What to do: parts.menuWidth is what decides this width.", + menu.Options, tab, room, needs) + } + if slack := room - needs; tightest == 0 || slack < tightest { + tightest, tightestIn = slack, room + } + checked++ + } + } + if checked == 0 { + t.Fatal("no menu was laid out, so this guard checked nothing") + } + t.Logf("%d menus, all wide enough. The tightest has %.2f px to spare in %.2f", checked, tightest, tightestIn) +} + +// And no menu is narrower than the boxes it stands beside. +// +// This is the owner's report of 2026-08-28 rather than a preference. The format +// menu on the preset screen sat between a limit and a seed, both 140 px, and was +// 97.75 - so the same setting was 140 px on the single batch screen and 98 on +// this one, because the width came from how long the words "targz" and "pdf" +// happen to be. Nothing was cut off. What was wrong is that a control this +// window never draws under 140 px was drawn at 98. +// +// Asked against the narrowest box laid out on the SAME screen, for the reason +// its sibling above is asked against the widest: the claim is a relationship +// between the controls a person sees together, not a number written down twice. +// Boxes with no width are left out - the two ways of stating a size that the +// switch is hiding are laid out at nought and minus three, and a floor taken +// from those would be no floor at all. +func TestNoMenuIsNarrowerThanTheBoxesItStandsBeside(t *testing.T) { + ourTheme(t) + content, canvas := laidOutWindow(t) + + checked := 0 + for _, tab := range allTabs() { + screen := selectTab(t, content, tab) + menus := menusWithAnArchiveOpened(t, screen, tab, canvas) + if len(menus) == 0 { + continue + } + narrowest := float32(0) + for _, box := range typingBoxesOn(screen) { + w := box.Size().Width + if w <= 0 { + continue + } + if narrowest == 0 || w < narrowest { + narrowest = w + } + } + if narrowest == 0 { + t.Fatalf("the %s screen has %d menus and no box to type in that was laid out,"+ + " so there is nothing to compare them against", tab, len(menus)) + } + for _, menu := range menus { + got := menu.Size().Width + if got == 0 { + continue + } + if got < narrowest { + t.Errorf("a menu of %v on the %s screen is %.2f px and the narrowest box beside it"+ + " is %.2f, so one setting is drawn shorter here than the same setting is"+ + " elsewhere in this window.\n"+ + "What to do: parts.menuWidth holds a menu to parts.NumericWidth at the least.", + menu.Options, tab, got, narrowest) + } + checked++ + } + } + if checked == 0 { + t.Fatal("no screen has a menu, so this guard checked nothing") + } + t.Logf("%d menus, none of them narrower than the boxes beside them", checked) +} + +// menusWithAnArchiveOpened is every menu of a screen, including the ones that +// only exist once a batch says it holds files. +// +// Without this the row an archive's contents are typed into is invisible to +// these guards, and that row is where the defect they are about was seen. A +// screen is left exactly as it was found on every other tab. +func menusWithAnArchiveOpened(t *testing.T, screen fyne.CanvasObject, tab string, canvas fyne.Canvas) []*parts.Chooser { + t.Helper() + if tab != text.TabRecipe() { + return menusOn(screen) + } + // An archive first, because since 2026-08-27 the offer to say what a batch + // holds is only under a format that holds anything. + chooseFormat(t, screen, "zip") + pressNamed(t, screen, text.ButtonAddContents()) + if canvas != nil { + canvas.Content().Resize(canvas.Size()) + } + return menusOn(screen) +} diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index bdc6f17..a4054b8 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -97,6 +97,15 @@ var notProvenByMutation = map[string]bool{ // "proven another way" are different states and lumping them together would // send a later session to re-prove what is already proven. var provenByProbe = map[string]string{ + "TestTheIconMacOSReadsCarriesEverySizeItIsAskedFor": "broken by hand on 2026-08-28, three ways, and put back byte for byte - the file is untracked in git, so the restore was checked by hash rather than by a clean diff. " + + "Cutting the icp4 chunk out made it red naming that entry and the 16 px it holds, which is the failure that matters most: the file still opens, still shows an icon, and is missing the size a screen without Retina asks for. " + + "Resizing the 1024 px picture to 900 made it red as well, which is what an upscale from the wrong master would look like. " + + "Lying in the length field of the header made it red a third time, and that is the one a viewer would forgive and a reader that trusts the header would not. " + + "A probe rather than a mutation entry because what it reads is a committed BINARY asset, not code: no substitution in a .go or .py file changes those bytes, " + + "and the script that writes them - tools/appicon.py - is not run in CI at all, so mutating it would leave this guard green while proving nothing. " + + "Apple's own iconutil was asked the same question on a real Mac on the day this went in and handed back all ten entries at these pixel sizes, " + + "which is the half of the answer that cannot be asked again on a machine without macOS.", + "TestEveryScriptAWorkflowRunsDirectlyIsExecutable": "broken by hand on 2026-08-28, in both directions, and put back byte for byte. " + "git update-index --chmod=-x on .github/scripts/make_app_bundle.sh made it red, naming the file, the mode it found and the command that fixes it. " + "Putting the bit back made it green again. " + diff --git a/internal/guard/regressiontable_test.go b/internal/guard/regressiontable_test.go index e88a476..f68cb68 100644 --- a/internal/guard/regressiontable_test.go +++ b/internal/guard/regressiontable_test.go @@ -48,7 +48,7 @@ var notYetJustified = []string{ "everyfield_test.go", "exeproperties_test.go", "foldedsections_test.go", - "formwidth_test.go", + "guitext_test.go", "keyboard_test.go", "livecheck_test.go", diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.xml b/internal/guard/testdata/screens/generate-chosen-by-key.xml index 972b19c..f5d397b 100644 --- a/internal/guard/testdata/screens/generate-chosen-by-key.xml +++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml @@ -59,18 +59,18 @@ - - - + + + - + png - + - + diff --git a/internal/guard/testdata/screens/generate-chosen.xml b/internal/guard/testdata/screens/generate-chosen.xml index b8fcabf..1c13a8a 100644 --- a/internal/guard/testdata/screens/generate-chosen.xml +++ b/internal/guard/testdata/screens/generate-chosen.xml @@ -59,18 +59,18 @@ - - - + + + - + png - + - + diff --git a/internal/guard/testdata/screens/generate-empty.xml b/internal/guard/testdata/screens/generate-empty.xml index e197dbb..d34ad6d 100644 --- a/internal/guard/testdata/screens/generate-empty.xml +++ b/internal/guard/testdata/screens/generate-empty.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/generate-focused.xml b/internal/guard/testdata/screens/generate-focused.xml index 1c53d8d..612768d 100644 --- a/internal/guard/testdata/screens/generate-focused.xml +++ b/internal/guard/testdata/screens/generate-focused.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/generate-hovered.xml b/internal/guard/testdata/screens/generate-hovered.xml index f8be6eb..de445c8 100644 --- a/internal/guard/testdata/screens/generate-hovered.xml +++ b/internal/guard/testdata/screens/generate-hovered.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/generate-menu-hovered.xml b/internal/guard/testdata/screens/generate-menu-hovered.xml index 24737a1..f6bfddf 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + @@ -419,112 +419,112 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - bmp + bmp - - - + + + - csv + csv - - - + + + - docx + docx - - - + + + - gif + gif - - - + + + - html + html - - - + + + - ico + ico - - - + + + - jpg + jpg - - - + + + - json + json - - - + + + - log + log - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + diff --git a/internal/guard/testdata/screens/generate-menu-keyed.xml b/internal/guard/testdata/screens/generate-menu-keyed.xml index b3829ce..437af8c 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + @@ -419,112 +419,112 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - bmp + bmp - - - + + + - csv + csv - - - + + + - docx + docx - - - + + + - gif + gif - - - + + + - html + html - - - + + + - ico + ico - - - + + + - jpg + jpg - - - + + + - json + json - - - + + + - log + log - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + diff --git a/internal/guard/testdata/screens/generate-menu.xml b/internal/guard/testdata/screens/generate-menu.xml index f158b56..53a2434 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + @@ -419,112 +419,112 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - bmp + bmp - - - + + + - csv + csv - - - + + + - docx + docx - - - + + + - gif + gif - - - + + + - html + html - - - + + + - ico + ico - - - + + + - jpg + jpg - - - + + + - json + json - - - + + + - log + log - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + diff --git a/internal/guard/testdata/screens/generate-refused-both.xml b/internal/guard/testdata/screens/generate-refused-both.xml index 216e9ed..8cafe12 100644 --- a/internal/guard/testdata/screens/generate-refused-both.xml +++ b/internal/guard/testdata/screens/generate-refused-both.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/generate-refused-setting.xml b/internal/guard/testdata/screens/generate-refused-setting.xml index 39cf3ee..0f1beb6 100644 --- a/internal/guard/testdata/screens/generate-refused-setting.xml +++ b/internal/guard/testdata/screens/generate-refused-setting.xml @@ -59,18 +59,18 @@ - - - + + + - + png - + - + diff --git a/internal/guard/testdata/screens/generate-refused.xml b/internal/guard/testdata/screens/generate-refused.xml index 0f50deb..1e53d1c 100644 --- a/internal/guard/testdata/screens/generate-refused.xml +++ b/internal/guard/testdata/screens/generate-refused.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/generate-switch-by-key.xml b/internal/guard/testdata/screens/generate-switch-by-key.xml index fa3e48e..9f20ee9 100644 --- a/internal/guard/testdata/screens/generate-switch-by-key.xml +++ b/internal/guard/testdata/screens/generate-switch-by-key.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/generate-typed.xml b/internal/guard/testdata/screens/generate-typed.xml index d26b62a..60e1143 100644 --- a/internal/guard/testdata/screens/generate-typed.xml +++ b/internal/guard/testdata/screens/generate-typed.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/generate-unchecked.xml b/internal/guard/testdata/screens/generate-unchecked.xml index 902a77e..d949c80 100644 --- a/internal/guard/testdata/screens/generate-unchecked.xml +++ b/internal/guard/testdata/screens/generate-unchecked.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/generate.xml b/internal/guard/testdata/screens/generate.xml index 99efc96..dd7648b 100644 --- a/internal/guard/testdata/screens/generate.xml +++ b/internal/guard/testdata/screens/generate.xml @@ -59,18 +59,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png index 43cc1a9..7f81c04 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 b3a7f3c..1a9b274 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -226,18 +226,18 @@ - - - + + + - + pdf - + - + @@ -407,115 +407,115 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - gif + gif - - - + + + - html + html - - - + + + - ico + ico - - - + + + - jpg + jpg - - - + + + - json + json - - - + + + - log + log - - - + + + - md + md - - - + + + - pdf + pdf - - - + + + - png + png - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png index 35ac769..758c68f 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 b5101eb..6245239 100644 --- a/internal/guard/testdata/screens/preset-menu.xml +++ b/internal/guard/testdata/screens/preset-menu.xml @@ -226,18 +226,18 @@ - - - + + + - + pdf - + - + diff --git a/internal/guard/testdata/screens/preset-refused.png b/internal/guard/testdata/screens/preset-refused.png index b90f9b8..06088b0 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 ba316aa..9a9ce24 100644 --- a/internal/guard/testdata/screens/preset-refused.xml +++ b/internal/guard/testdata/screens/preset-refused.xml @@ -233,18 +233,18 @@ - - - + + + - + pdf - + - + diff --git a/internal/guard/testdata/screens/preset.png b/internal/guard/testdata/screens/preset.png index 0520b85..ab61b7a 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 02e6376..2177962 100644 --- a/internal/guard/testdata/screens/preset.xml +++ b/internal/guard/testdata/screens/preset.xml @@ -226,18 +226,18 @@ - - - + + + - + pdf - + - + diff --git a/internal/guard/testdata/screens/recipe-contents.png b/internal/guard/testdata/screens/recipe-contents.png index 9b53785..3ebdf8e 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 180e489..26736e9 100644 --- a/internal/guard/testdata/screens/recipe-contents.xml +++ b/internal/guard/testdata/screens/recipe-contents.xml @@ -77,18 +77,18 @@ - - - + + + - + zip - + - + @@ -305,18 +305,18 @@ - - - + + + - - (Select … + + (Select one) - + - + @@ -372,13 +372,18 @@ - - - - - Remove - - + + + + + + + + Remove + + + + 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 8602688..1a9fa3c 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 @@ -84,18 +84,18 @@ - - - + + + - + bmp - + - + @@ -367,18 +367,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/recipe-refused.xml b/internal/guard/testdata/screens/recipe-refused.xml index b1619e9..53a2cb9 100644 --- a/internal/guard/testdata/screens/recipe-refused.xml +++ b/internal/guard/testdata/screens/recipe-refused.xml @@ -77,18 +77,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/recipe-two-batches.xml b/internal/guard/testdata/screens/recipe-two-batches.xml index 21b2755..5da9c0a 100644 --- a/internal/guard/testdata/screens/recipe-two-batches.xml +++ b/internal/guard/testdata/screens/recipe-two-batches.xml @@ -84,18 +84,18 @@ - - - + + + - + bmp - + - + @@ -348,18 +348,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/guard/testdata/screens/recipe.xml b/internal/guard/testdata/screens/recipe.xml index 6e1118b..19c0a2a 100644 --- a/internal/guard/testdata/screens/recipe.xml +++ b/internal/guard/testdata/screens/recipe.xml @@ -77,18 +77,18 @@ - - - + + + - + bmp - + - + diff --git a/internal/gui/icon/chickpea.icns b/internal/gui/icon/chickpea.icns new file mode 100644 index 0000000..3c27360 Binary files /dev/null and b/internal/gui/icon/chickpea.icns differ diff --git a/internal/gui/parts/parts.go b/internal/gui/parts/parts.go index 8bd2b1a..508d984 100644 --- a/internal/gui/parts/parts.go +++ b/internal/gui/parts/parts.go @@ -25,6 +25,7 @@ import ( "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -178,6 +179,25 @@ func Row(fields ...fyne.CanvasObject) fyne.CanvasObject { return container.NewGridWithColumns(len(fields), fields...) } +// BesideFields puts something that is not a field into a row of them without +// letting the row decide how big it is. +// +// Row shares the width out in equal columns, which is what fields want and what +// anything else gets whether it wants it or not. Measured on 2026-08-28, from +// the owner reading the batch screen: the Remove button ending a row of an +// archive's contents came out 198x63 px for a word needing 63x32, so it was +// drawn as a panel with a word in the middle of it rather than as a button. The +// Duplicate button at the head of the same batch, which is in no row, is 79x35. +// +// Two layouts and each answers one half. The box across gives it the width it +// asks for instead of the column's. The spacer above pushes it down onto the +// line the controls are on - a row of fields is a label with a control under +// it, so anything sitting at the top of that column lines up with the labels +// and reads as a heading of its own. +func BesideFields(o fyne.CanvasObject) fyne.CanvasObject { + return container.NewVBox(layout.NewSpacer(), container.NewHBox(o)) +} + // Divider is a line between two things standing side by side. // // The rail at the left of the action bar carries Donate and, on the batch diff --git a/internal/gui/parts/ring.go b/internal/gui/parts/ring.go index 2b64fac..77b49da 100644 --- a/internal/gui/parts/ring.go +++ b/internal/gui/parts/ring.go @@ -255,6 +255,22 @@ func Menu(c *Chooser) fyne.CanvasObject { return Sized(menuWidth(c), c) } // for the placeholder, so a menu keeps the proportions the toolkit gives it. func menuWidth(c *Chooser) float32 { th, size := Theme(), theme.TextSize() + // The placeholder read here is whatever has been set. The toolkit puts its + // own default in when the field is empty and does it while the renderer is + // made - fyne v2.8.1 widget/select.go line 94, read in the pinned module - + // so a menu built cold is measured against a string that is about to be + // replaced by a longer one. That is what made the same twenty format ids + // 139.91 px on two screens and 97.75 px on the other two, measured on + // 2026-08-28 with tools/probes/menuwidth, and the narrow ones then drew + // "(Select ..." in a box that exists to show "(Select one)". + // + // Asking the widget for its MinSize first would settle it, and it was + // written that way for an hour. It came out because it could not change an + // answer: the string the toolkit inserts needs 139.91 px, the floor at the + // end of this function is 140, so every menu it could affect is already + // wider than the placeholder it is about to be given. A line that cannot + // change an answer is not a defence, and this project has taken seven of + // them out for that reason. widest := fyne.MeasureText(c.PlaceHolder, size, fyne.TextStyle{}).Width for _, option := range c.Options { if w := fyne.MeasureText(option, size, fyne.TextStyle{}).Width; w > widest { @@ -294,9 +310,22 @@ func menuWidth(c *Chooser) float32 { pad := th.Size(theme.SizeNameInnerPadding) box := widest + pad*4 + th.Size(theme.SizeNameInlineIcon) if row := RowWidthFor(widest, c.KindOf != nil); row > box { - return row + box = row } - return box + // 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 + // on the preset screen reads as too short. It was 97.75 px, sitting between + // a limit and a seed of 140, because the values it holds are three and five + // letters long. Every value fitted, so nothing was cut off - what was wrong + // is that a control this window never draws under 140 px was drawn at 98. + // + // NumericWidth rather than a number of its own, so there is one answer to + // "how narrow does a control get here" and a menu cannot drift away from + // the boxes it stands beside. It is a floor and not a size: a menu of long + // values is as wide as its values, which is what the arithmetic above is + // for. + return fyne.Max(NumericWidth, box) } // useRing takes the ring and asks it for a line at rest as well as the two it diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go index 27e3828..2bba102 100644 --- a/internal/gui/window/recipe.go +++ b/internal/gui/window/recipe.go @@ -478,7 +478,11 @@ func (r *Recipe) contentsBlock(index int, b *batch) fyne.CanvasObject { r.fields.Add(at(recipe.KeyFormat), text.FieldFormat(), "", parts.NoDetail, c.formatPick), r.fields.Add(at(recipe.KeyCount), text.FieldCount(), "", parts.NoDetail, parts.Numeric(c.count)), r.fields.Add(at(recipe.KeySize), text.FieldSize(), "", parts.NoDetail, parts.Numeric(c.size)), - widget.NewButton(text.ButtonRemoveContents(), func() { r.removeContent(b, entry) }), + // Not a field, so the row would hand it a whole column and the + // height of a label and a control together - see parts.BesideFields + // for the numbers that came off this very button. + parts.BesideFields( + widget.NewButton(text.ButtonRemoveContents(), func() { r.removeContent(b, entry) })), )) } // The button to add another only where another one would be legal. The rows