diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edb7a962..e2ba8fde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -858,7 +858,7 @@ jobs: # this refuses. A key that does not exist, or a value outside what the # schema allows, is otherwise ignored in silence - and a linter reading # a setting nobody applied is a gate that reports what it feels like. - run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 config verify + run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0 config verify - name: golangci-lint # Run rather than imported, so it never enters go.mod and the @@ -872,7 +872,7 @@ jobs: # .golangci.yml. Measured before switching this on: zero findings from # ineffassign and one from misspell, which turned out to be a comment # written in the wrong language rather than a typo. - run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 run --build-tags "$(cat .github/build-tags)" ./... + run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0 run --build-tags "$(cat .github/build-tags)" ./... semgrep: name: semgrep diff --git a/.golangci.yml b/.golangci.yml index d7528926..1ca9c8bc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -114,8 +114,10 @@ linters: default-signifies-exhaustive: true # fyne.KeyName has 70 members, so a switch on a keystroke would have to # list every key on the keyboard to satisfy this. Somebody else's enum, - # and not one we can be complete about. - ignore-enum-types: "KeyName$" + # and not one we can be complete about. A list since v2.15.0, which + # refuses the old ignore-enum-types in config verify. + ignore-types: + - "KeyName$" gosec: # Every entry is a measurement, not a preference. The count beside each diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index f4a5e47f..910b4df6 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -78,7 +78,7 @@ Source: ## golang.org/x/text -Version 0.42.0. Supplies Unicode normalisation, used to decide whether two file +Version 0.43.0. Supplies Unicode normalisation, used to decide whether two file names are one name spelled two ways. ``` diff --git a/go.mod b/go.mod index 016dc501..9eea3188 100644 --- a/go.mod +++ b/go.mod @@ -83,7 +83,7 @@ require ( github.com/goccy/go-yaml v1.19.2 github.com/nicksnyder/go-i18n/v2 v2.6.1 golang.org/x/sys v0.49.0 - golang.org/x/text v0.42.0 + golang.org/x/text v0.43.0 ) require ( diff --git a/go.sum b/go.sum index 5162576d..87161d03 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/guard/menushape_test.go b/internal/guard/menushape_test.go index 0688cf70..949a0999 100644 --- a/internal/guard/menushape_test.go +++ b/internal/guard/menushape_test.go @@ -1,11 +1,13 @@ package guard import ( + "fmt" "strings" "testing" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/software" "fyne.io/fyne/v2/test" "fyne.io/fyne/v2/theme" @@ -219,43 +221,58 @@ func typingBoxesOn(screen fyne.CanvasObject) []*parts.Entry { // puts in by itself - fyne v2.8.2 widget/select.go line 98 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. +// +// Asked twice, and the second half is what the floor in parts.menuWidth is for. +// Laid out, a menu in the form's grid is as wide as the columns it takes rather +// than as wide as it asks, so the room it is given can cover the toolkit's +// words while the width it ASKS for does not - and the first layout that gives +// it exactly what it asks cuts them. Measured on 2026-10-19 with the floor taken +// out: the damage menu asked for 112.86 px against the toolkit's 124.23 and was +// still given 124.96, one column of the narrowest window, so the layout half +// stayed green by 0.73 px and the mutation run scored the floor a HOLE. 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) - menus := menusWithAnArchiveOpened(t, screen, tab, canvas) - buried := underSomethingHidden(screen) - for _, menu := range menus { - room, shown, err := widthShown(menu, buried) + asked := map[*parts.Chooser]bool{} + inEveryMenuLayout(t, func(l menuLayout) { + for _, menu := range l.menus { + needs := menu.MinSize().Width + if !asked[menu] { + asked[menu] = true + if wants := parts.Menu(menu).MinSize().Width; wants < needs { + t.Errorf("a menu of %v on the %s screen asks for %.2f px and the toolkit says it needs"+ + " %.2f to show what is in it, so a layout that gives it what it asks cuts the words off.\n"+ + "What to do: parts.menuWidth is what decides this width.", + menu.Options, l.tab, wants, needs) + } + } + room, shown, err := widthShown(menu, l.buried) if err != nil { - t.Errorf("a menu of %v on the %s screen %v", menu.Options, tab, err) + t.Errorf("a menu of %v on %s %v", menu.Options, l, err) continue } if !shown { continue } - needs := menu.MinSize().Width if room < needs { - t.Errorf("a menu of %v on the %s screen is %.2f px and the toolkit says it needs"+ + t.Errorf("a menu of %v on %s 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) + menu.Options, l, 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) + t.Logf("%d menus laid out, 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. @@ -275,16 +292,17 @@ func TestAMenuIsWideEnoughForTheWordsTheToolkitPutsInIt(t *testing.T) { // and a floor taken from those would have been no floor at all. None is today // - measured 2026-09-16, after the switch became segments - and the rule stays // for the day a hidden box is laid out at nothing again. +// +// In the narrowest window too, which is the only width at which the floor of +// parts.menuWidth shows - see inEveryMenuLayout. func TestNoMenuIsNarrowerThanTheBoxesItStandsBeside(t *testing.T) { ourTheme(t) - content, canvas := laidOutWindow(t) checked := 0 - for _, tab := range allTabs() { - screen := selectTab(t, content, tab) - menus, boxes := menusWithAnArchiveOpened(t, screen, tab, canvas), typingBoxesOn(screen) - if len(menus) == 0 || holdsNoBoxByDesign(t, tab, boxes) { - continue + inEveryMenuLayout(t, func(l menuLayout) { + boxes := typingBoxesOn(l.screen) + if len(l.menus) == 0 || holdsNoBoxByDesign(t, l.tab, boxes) { + return } narrowest := float32(0) for _, box := range boxes { @@ -297,33 +315,120 @@ func TestNoMenuIsNarrowerThanTheBoxesItStandsBeside(t *testing.T) { } } 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)) + t.Fatalf("%s has %d menus and no box to type in that was laid out,"+ + " so there is nothing to compare them against", l, len(l.menus)) } - buried := underSomethingHidden(screen) - for _, menu := range menus { - got, shown, err := widthShown(menu, buried) + for _, menu := range l.menus { + got, shown, err := widthShown(menu, l.buried) if err != nil { - t.Errorf("a menu of %v on the %s screen %v", menu.Options, tab, err) + t.Errorf("a menu of %v on %s %v", menu.Options, l, err) continue } if !shown { continue } if got < narrowest { - t.Errorf("a menu of %v on the %s screen is %.2f px and the narrowest box beside it"+ + t.Errorf("a menu of %v on %s 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) + menu.Options, l, 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) + t.Logf("%d menus laid out, none of them narrower than the boxes beside them", checked) +} + +// menuLayout is one screen laid out at one width, while it stands. +type menuLayout struct { + tab string + width float32 + screen fyne.CanvasObject + menus []*parts.Chooser + buried map[fyne.CanvasObject]bool +} + +func (l menuLayout) String() string { + return fmt.Sprintf("the %s screen in a %.0f px window", l.tab, l.width) +} + +// inEveryMenuLayout hands every screen of the window to ask with every section +// on it open, twice: at the width the window opens at and at the narrowest the +// window allows. +// +// Open, because a menu in a shut section is not shown and the guards above +// step over what is not shown: the batch screen's menus of compression, +// encryption, the expected outcome and its reason were measured by none of +// them until 2026-10-19. +// +// Narrowest, because a field in the form's grid is as wide as the columns it +// takes rather than as wide as it asks (parts.Grid), so at the opening width +// every menu is a column wide whatever it asks for and the floor of +// parts.menuWidth cannot show. The mutation run scored the floor's two entries +// HOLE on every Sunday from the first. Measured on 2026-10-19 with the floor +// taken out: at 1000 px every menu kept its 179 or 185 px, and in the +// narrowest window of the single batch screen (580 px) the damage menu fell +// from two columns, 265.91 px, to one, 124.96 - beside boxes of 253.91. +// +// The narrowest window is the window's, and opening a screen's sections can +// widen it for the screens after it, which is what a person who opened them +// gets too. +func inEveryMenuLayout(t *testing.T, ask func(menuLayout)) { + t.Helper() + content, canvas := laidOutWindow(t) + sized, resizable := canvas.(software.WindowlessCanvas) + if !resizable { + t.Fatalf("the window's canvas is a %T, which cannot be resized, so the narrowest window cannot be asked", canvas) + } + opening := canvas.Size() + // The window's own padding, which a window keeps round its content at + // every width - so the narrowest window is the content's least width and + // this much more. + padding := opening.Width - content.Size().Width + for _, tab := range allTabs() { + screen := selectTab(t, content, tab) + menusWithAnArchiveOpened(t, screen, tab, canvas) + openEverySection(t, screen, tab) + for _, width := range []float32{opening.Width - padding, content.MinSize().Width} { + sized.Resize(fyne.NewSize(width+padding, opening.Height)) + content.Refresh() + // In the state this is about, or it proves nothing: a resize the + // window ignored would measure the wide layout twice. + if got := content.Size().Width; got > width+1 || got < width-1 { + t.Fatalf("the window was resized for content %.0f px wide and its content is %.0f px, so this would not measure the layout it names", + width, got) + } + ask(menuLayout{tab: tab, width: width + padding, screen: screen, menus: menusOn(screen), buried: underSomethingHidden(screen)}) + } + sized.Resize(opening) + } +} + +// openEverySection opens every shut section of a screen, the sections that +// only appear inside an opened one included, and refuses a head that stays +// shut when it is pressed - the state is asserted, not assumed. +func openEverySection(t *testing.T, screen fyne.CanvasObject, tab string) { + t.Helper() + for { + opened := 0 + for _, row := range foldRows(screen) { + if row.head.Open() { + continue + } + row.head.Tapped(nil) + if !row.head.Open() { + t.Fatalf("pressing the head of the section %q on the %s screen left it shut", row.title, tab) + } + opened++ + } + if opened == 0 { + return + } + } } // menusWithAnArchiveOpened is every menu of a screen, including the ones that diff --git a/internal/guard/mutation/entries.py b/internal/guard/mutation/entries.py index 225e4c38..795754f2 100644 --- a/internal/guard/mutation/entries.py +++ b/internal/guard/mutation/entries.py @@ -6527,15 +6527,15 @@ "CI stops verifying the linter configuration, so a key the schema " "refuses is ignored in silence", ".github/workflows/ci.yml", - "golangci-lint@v2.14.0 config verify", - "golangci-lint@v2.14.0 version", + "golangci-lint@v2.15.0 config verify", + "golangci-lint@v2.15.0 version", "TestTheWorkflowRunsTheLinterAndVerifiesItsConfiguration", ), ( "CI reads the linter configuration and never runs it against the tree", ".github/workflows/ci.yml", - 'golangci-lint@v2.14.0 run --build-tags "$(cat .github/build-tags)" ./...', - "golangci-lint@v2.14.0 help", + 'golangci-lint@v2.15.0 run --build-tags "$(cat .github/build-tags)" ./...', + "golangci-lint@v2.15.0 help", "TestTheWorkflowRunsTheLinterAndVerifiesItsConfiguration", ), ( @@ -6933,10 +6933,11 @@ "a module's own section states a version nobody builds", "THIRD-PARTY-NOTICES.md", # The number here follows the build: 0.40 became 0.41 on 2026-08-28 - # and 0.42 on 2026-09-22 (#112), and each time this pattern went stale - # with it - a SKIP that reads like a proof, which staleness.py names. + # and 0.42 on 2026-09-22 (#112), 0.43 on 2026-10-19, and each time this + # pattern went stale with it - a SKIP that reads like a proof, which + # staleness.py names. + "Version 0.43.0. Supplies Unicode normalisation", "Version 0.42.0. Supplies Unicode normalisation", - "Version 0.41.0. Supplies Unicode normalisation", "TestTheNoticesFileNamesTheVersionsThatAreActuallyBuilt", ), ( @@ -9327,15 +9328,6 @@ "", "TestAMenuIsOneWidthBeforeAndAfterItIsDrawn", ), - ( - # The same defect asked on the screens: the format menu is one width - # on the single batch screen and another on the preset screen. - "the format menu is two widths on two screens", - "internal/gui/parts/ring.go", - "var widest float32\n\tfor _, option := range c.Options {", - "widest := fyne.MeasureText(c.PlaceHolder, size, fyne.TextStyle{}).Width\n\tfor _, option := range c.Options {", - "TestOneSettingIsOneMenuWidthOnEveryScreen", - ), ( # Every row's cells slide one column to the right of the names: the # header stands where it stood and the first row does not. @@ -9345,15 +9337,6 @@ "return Column(GapTight, append([]fyne.CanvasObject{Row(append([]fyne.CanvasObject{Clear()}, bodies...)...)}, areas...)...)", "TestATableNamesItsColumnsOnceOverTheFirstRow", ), - ( - # And the two rows of one table come out two widths: the first row's - # menu has been drawn once, the second's has not. - "the first row of a table is wider than the second", - "internal/gui/parts/ring.go", - "var widest float32\n\tfor _, option := range c.Options {", - "widest := fyne.MeasureText(c.PlaceHolder, size, fyne.TextStyle{}).Width\n\tfor _, option := range c.Options {", - "TestEveryRowOfATableStandsUnderTheSameNames", - ), ( # The other direction: the catalogue vouches for a part nobody has. "the catalogue covers a name parts does not export", diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index e2afa787..cfceab0a 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -68,6 +68,18 @@ var notProvenByMutation = map[string]bool{ // would NOT catch a wrapper put in front of one and stealing the presses. "TestThreeClicksInABoxSelectWhatIsInIt": true, "TestAFreshRunIntoAnEmptyDirectoryStillWorks": true, + // One menu, two widths, measured at two moments: the cause they were + // written for was the toolkit putting its placeholder into a menu at its + // first draw, and that is closed at the source since 2026-10-06 - + // parts.NewChooser gives every menu the window's placeholder, held by + // TestAMenuIsOneWidthBeforeAndAfterItIsDrawn. Their mutation, measuring the + // placeholder in parts.menuWidth, scored HOLE on every Sunday run, and + // rightly: measured on 2026-10-19 it widens every format menu from 154.73 + // to 180.23 px on every screen and in every row at once. With the + // placeholder taken out of NewChooser as well, both stayed green that day, + // so no pair of breaks here reaches them either. + "TestOneSettingIsOneMenuWidthOnEveryScreen": true, + "TestEveryRowOfATableStandsUnderTheSameNames": true, // The property is a package NAME, and no substitution in the tree can // move a package without breaking the build. Every mutation that reaches // the constant it reads also reddens the size guards, which the runner diff --git a/internal/gui/parts/ring.go b/internal/gui/parts/ring.go index 2ba3cb62..3130aa70 100644 --- a/internal/gui/parts/ring.go +++ b/internal/gui/parts/ring.go @@ -328,9 +328,11 @@ func menuWidth(c *Chooser) float32 { // // Whether the placeholder fits is not this function's to answer, and is // answered: TestAMenuIsWideEnoughForTheWordsTheToolkitPutsInIt asks every - // laid out menu whether its width covers the toolkit's own MinSize, which - // is the one measurement that knows the placeholder. The floor at the end - // of this function is what covers it today, with room to spare. + // menu whether the width it asks for here, and the width it is laid out + // at, cover the toolkit's own MinSize, which is the one measurement that + // knows the placeholder. The floor at the end of this function is what + // covers it today: without it the menu of compression levels asks for 90 + // px where the toolkit needs 131 (measured 2026-10-19). // // Since 2026-10-06 every menu is made with the window's own placeholder // (NewChooser), so the toolkit no longer puts one in and the two widths