diff --git a/CHANGELOG.md b/CHANGELOG.md index a213ffe..d56c064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,18 @@ because it turns other people's test suites red. it so under every format, so the ellipsis only ever appears in a window made narrower than a title needs. +- **The switch between the three ways of stating a size is drawn as one + shape.** The chosen way was a sharp rectangle under a rounded border, with + a hairline against its edge and the keyboard ring outside - three + geometries at once. The fill is rounded and sits inside the border now, and + a line stands only between two ways neither of which is chosen. + +- **The words in an open list start where the word in the box does.** Every + row kept a column for the tick in front of its words, whether or not + anything in the list was ticked, so the words of a list with nothing chosen + floated a column to the right of the box. The tick stands at the end of the + row now, and the picture of a file kind stays in front where it was. + ### Added - **Every control shows where the keyboard is and answers the pointer.** Every @@ -134,6 +146,12 @@ because it turns other people's test suites red. ### Fixed +- **The switch between the three ways of stating a size freezes with the + rest of the form while a run is going.** It stayed live: during a run, + choosing another way rebuilt the size boxes under a form drawn as frozen. + A frozen switch also kept no sign of which way was chosen. Both are + fixed, and the switch thaws with the form when the run ends. + - **The explanation beside a field opens on the first hover, and opens and closes from the keyboard.** Pointing at the information mark in a fresh window showed nothing until something else on the screen happened to diff --git a/internal/guard/listwords_test.go b/internal/guard/listwords_test.go new file mode 100644 index 0000000..6a74a8d --- /dev/null +++ b/internal/guard/listwords_test.go @@ -0,0 +1,136 @@ +package guard + +import ( + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// The words in an open list start where the word in the box does, and the +// tick stands at the far end of the row. +// +// 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 +// floating in a rectangle. Measured: every row kept a column for the tick in +// front of its words whether or not anything in the list was ticked, so the +// words of every list stood 36 px to the right of the word in the box - the +// pictures in front of the formats made that look meant, and a list with no +// picture and nothing chosen showed the empty column for what it was (O220). +// +// Asked of two real lists on the screen: one without pictures, where the row's +// words start at the gutter with nothing in front of them, and one with them, +// where the picture stands at the gutter and the tick behind the words. +// Positions are read off rows the list is actually drawing, because a row laid +// out at one width in a probe says nothing about the width the form gives it. +// +// The rule is held against OUR geometry - the gutter, the tick, the picture - +// and the distance to the box's own word is only logged. The first version +// asserted that distance within a step of the scale, and CI turned it red: +// the toolkit draws the box's word 2 px from where the row's word starts on +// the owner's machine and 6 px on the runners (2026-09-16, all three systems), +// because the inset of a Select's RichText is the toolkit's and not a token +// of ours. What the owner saw was 36 px, and that is what the tick column in +// front of the words was. +func TestTheWordsInAnOpenListStartWhereTheWordInTheBoxDoes(t *testing.T) { + cv, content := screenOnACanvas(t) + drv := fyne.CurrentApp().Driver() + + for _, tc := range []struct { + field string + pictured bool + }{ + {text.FieldDamage(), false}, + {text.FieldFormat(), true}, + } { + menu := chooserUnder(t, content, tc.field) + menu.Tapped(&fyne.PointEvent{}) + cv.Capture() + list := menu.Opened() + if list == nil { + t.Fatalf("pressing the %s menu opened no list", tc.field) + } + boxWord := drv.AbsolutePositionForObject(wordsInTheBox(t, menu)).X + + rows := list.DrawnRows() + if len(rows) == 0 { + t.Fatalf("the %s list is drawing no row at all", tc.field) + } + for _, row := range rows { + words, tick, picture := piecesOfARow(t, row) + first := words.Position().X + if tc.pictured { + first = picture.Position().X + } + if first != parts.RowGutter() { + t.Errorf("%s: row %q starts its first piece at %.1f rather than at the gutter (%.1f) - a column stands in front of the words and the list reads as words floating in a rectangle", + tc.field, row.Label(), first, parts.RowGutter()) + } + if !tc.pictured { + 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) + } + if tc.pictured && picture.Position().X+picture.Size().Width > words.Position().X { + t.Errorf("%s: the picture of row %q reaches %.1f, over words starting at %.1f", + tc.field, row.Label(), picture.Position().X+picture.Size().Width, words.Position().X) + } + } + list.TypedKey(&fyne.KeyEvent{Name: fyne.KeyEscape}) + } +} + +// wordsInTheBox is the text the closed menu draws, read off its renderer - +// the toolkit draws it through a RichText, and a guard that cannot find it +// says so rather than measuring nothing. +func wordsInTheBox(t *testing.T, menu *parts.Chooser) *canvas.Text { + t.Helper() + for _, o := range test.WidgetRenderer(menu).Objects() { + rich, is := o.(*widget.RichText) + if !is { + continue + } + for _, drawn := range test.WidgetRenderer(rich).Objects() { + if words, is := drawn.(*canvas.Text); is { + return words + } + } + } + t.Fatal("the closed menu draws no text this guard can find, so it cannot say where the word in the box starts") + return nil +} + +// 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. +func piecesOfARow(t *testing.T, row *parts.ListRow) (words *canvas.Text, tick, picture *canvas.Image) { + t.Helper() + for _, o := range test.WidgetRenderer(row).Objects() { + switch drawn := o.(type) { + case *canvas.Text: + words = drawn + case *canvas.Image: + if drawn.Resource != nil && drawn.Resource.Name() == theme.ConfirmIcon().Name() { + tick = drawn + } else if row.Kind() != nil { + picture = drawn + } + } + } + if words == nil || tick == nil { + t.Fatalf("row %q draws no words or no tick, so this guard read the wrong objects", row.Label()) + } + if row.Kind() != nil && picture == nil { + t.Fatalf("row %q has a kind and draws no picture for it", row.Label()) + } + return words, tick, picture +} diff --git a/internal/guard/segmentface_test.go b/internal/guard/segmentface_test.go new file mode 100644 index 0000000..1015e55 --- /dev/null +++ b/internal/guard/segmentface_test.go @@ -0,0 +1,157 @@ +package guard + +import ( + "image/color" + "testing" + + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// The face of the segmented switch, after the owner looked at it (O219), and +// the two halves of what a frozen form did not do to it (O223). + +// The switch between the three ways of stating a size freezes with the rest +// of the form, and thaws with it. +// +// Measured on a render of the batch screen mid run on 2026-09-16: every box +// frozen, the switch above them live. It went on the form through +// Fields.Unlabelled, which built its row and registered nothing, and Freeze +// walked the registry - so a press on "A range" during a run rebuilt the size +// boxes under a form drawn as frozen. The guard for a frozen form asks the +// single batch screen, which has no such switch (O223). +func TestTheSizeWaySwitchFreezesWithTheRestOfTheForm(t *testing.T) { + dir := t.TempDir() + batches, _, _ := screenInAWindowWithHost(t, text.TabRecipe()) + entryUnder(t, batches, text.FieldTargetID()).SetText("invoices") + entryUnder(t, batches, text.FieldOutputDir()).SetText(dir) + entryUnder(t, batches, text.FieldSize()).SetText("64kb") + entryUnder(t, batches, text.FieldCount()).SetText("400") + press(t, batches, "Generate") + + box := entryUnder(t, batches, text.FieldSize()) + if !box.Disabled() { + t.Fatalf("the size box is not frozen during the run, so this guard is asking about a form that never froze. Refusal: %q", + anyRefusal(batches)) + } + if !sizeWaySwitch(t, batches).Disabled() { + t.Error("the size box is frozen and the switch above it is not - a press on another way of stating " + + "a size rebuilds the boxes under a form drawn as frozen") + } + + cancel := buttonNamed(batches, "Cancel") + if cancel == nil { + t.Fatalf("there is no Cancel button. The screen has: %v", buttonNames(batches)) + } + cancel.OnTapped() + if sizeWaySwitch(t, batches).Disabled() { + t.Error("the switch is still frozen after the run stopped, so it never comes back") + } +} + +// A frozen switch still shows which way is chosen. +// +// Until 2026-09-16 every fill went transparent when the switch was disabled, +// so a form frozen for a run would have said nothing about which of the three +// ways it was running with. Nobody saw it, because the switch was not being +// frozen at all - the other half of O223. +func TestAFrozenSwitchStillShowsWhichWayIsChosen(t *testing.T) { + ways := []string{"one", "two", "three"} + s := parts.NewSegments(ways, nil) + s.SetSelected("two") + s.Disable() + filled := segmentFills(t, s) + if len(filled) != 1 || filled[0] != 1 { + t.Errorf("the switch is frozen on %q and the filled segments are %v (by position), so it no longer says which way is chosen", + "two", filled) + } + s.Enable() + if filled := segmentFills(t, s); len(filled) != 1 || filled[0] != 1 { + t.Errorf("thawed, the switch fills segments %v rather than the chosen one", filled) + } +} + +// The chosen segment's fill stays inside the border, and no rule touches it. +// +// What the owner saw on 2026-09-16 was three geometries at once: a sharp +// rectangle standing out past the arc of a rounded border, a hairline against +// its edge, and the ring outside. The fill is rounded and a border's width +// inside the switch now, and a rule stands only between two segments neither +// of which is the chosen one. Read off the renderer's objects by what they +// are, not by their position in the list - a guard reading by position is +// green the day the order changes. +func TestTheChosenSegmentStaysInsideTheBorderAndNoRuleTouchesIt(t *testing.T) { + ways := []string{"one", "two", "three"} + s := parts.NewSegments(ways, nil) + s.SetSelected("two") + s.Resize(s.MinSize()) + + var fill *canvas.Rectangle + var rules []*canvas.Rectangle + for _, o := range test.WidgetRenderer(s).Objects() { + rect, is := o.(*canvas.Rectangle) + if !is { + continue + } + switch { + case rect.FillColor == parts.PaletteColour(theme.ColorNameSelection, theme.VariantDark): + fill = rect + case rect.Size().Width == parts.Hairline: + rules = append(rules, rect) + } + } + if fill == nil { + t.Fatal("no segment is filled with the selection colour, so the chosen way is not drawn at all") + } + if len(rules) != len(ways)-1 { + t.Fatalf("%d rules drawn for %d segments, so this guard read the wrong objects", len(rules), len(ways)) + } + + edge := float32(parts.EdgeWidth()) + pos, size, whole := fill.Position(), fill.Size(), s.Size() + if pos.X < edge || pos.Y < edge || pos.X+size.Width > whole.Width-edge || pos.Y+size.Height > whole.Height-edge { + t.Errorf("the chosen fill at %v size %v reaches under the border of a %v switch, so its corner stands out past the border's arc", + pos, size, whole) + } + if fill.CornerRadius <= 0 { + t.Error("the chosen fill has square corners inside a rounded border") + } + // The chosen segment is the second, so the first rule and the second both + // touch it. A third would stand clear, and there is none with three ways. + for i, rule := range rules { + if rule.Visible() { + t.Errorf("rule %d is drawn against the chosen segment's edge - the fill is the boundary there", i) + } + } + s.SetSelected("one") + if !rules[1].Visible() { + t.Error("with the first way chosen the rule between the second and third is hidden, and it stands between two unchosen segments") + } +} + +// segmentFills is the position of every segment whose fill is not +// transparent. A fill is told from the border, the ring and the rules by what +// it is: the one rectangle rounded to sit inside the border's own radius. +func segmentFills(t *testing.T, s *parts.Segments) []int { + t.Helper() + var filled []int + at := 0 + for _, o := range test.WidgetRenderer(s).Objects() { + rect, is := o.(*canvas.Rectangle) + if !is || rect.CornerRadius != parts.RadiusField-parts.EdgeWidth() { + continue + } + if rect.FillColor != color.Transparent { + filled = append(filled, at) + } + at++ + } + if at != len(s.Options) { + t.Fatalf("read %d fills for %d segments, so this guard is not reading the fills", at, len(s.Options)) + } + return filled +} diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index d28672a..16cdd86 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 f724b73..fc9ef2b 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -1,7 +1,7 @@ - + - - + + @@ -760,10 +760,10 @@ - - - - + + + + Segments @@ -777,10 +777,9 @@ - - - - + + + Exact @@ -802,11 +801,9 @@ - - - - - + + + Exact Range @@ -827,10 +824,9 @@ - - - - + + + Exact @@ -852,10 +848,9 @@ - - - - + + + Exact @@ -866,6 +861,29 @@ + + + + + frozen, middle chosen + + + + + + + + + + + Exact + Range + Boundary + + + + + @@ -877,10 +895,9 @@ - - - - + + + Exactly this size @@ -893,10 +910,10 @@ - - - - + + + + OpenList @@ -921,20 +938,77 @@ - png + png + + + + + + + jpg + + + + + + avif + + + + + + + + + + + + + + + + + + + + + + + + + + + nothing chosen yet + + + + + + + + + + + + + + + + + + png - - jpg + jpg - avif + avif @@ -956,7 +1030,7 @@ - + @@ -979,31 +1053,31 @@ - avif + avif - bmp + bmp - csv + csv - docx + docx - gif + gif @@ -1039,7 +1113,7 @@ - + @@ -1062,86 +1136,86 @@ - - avif + + avif - - bmp + + bmp - - csv + + csv - - - docx + + + docx - - gif + + gif - - html + + html - - ico + + ico - - jpg + + jpg - - json + + json - - jxl + + jxl - - log + + log - - md + + md @@ -1190,7 +1264,7 @@ - + @@ -1213,14 +1287,14 @@ - 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 - - png + + png @@ -1242,7 +1316,7 @@ - + @@ -1426,7 +1500,7 @@ - + @@ -1789,7 +1863,7 @@ - + @@ -2026,7 +2100,7 @@ - + @@ -2160,7 +2234,7 @@ - + @@ -2275,7 +2349,7 @@ - + @@ -2339,7 +2413,7 @@ - + @@ -2392,7 +2466,7 @@ - + @@ -2614,7 +2688,7 @@ - + @@ -2884,7 +2958,7 @@ - + @@ -3130,7 +3204,7 @@ - + @@ -3236,7 +3310,7 @@ - + diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index 32486af..3043cfc 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 45a9b62..49cc1bf 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -429,170 +429,170 @@ - - - avif + + + avif - - bmp + + bmp - - csv + + csv - - docx + + docx - - gif + + gif - - html + + html - - ico + + ico - - jpg + + jpg - - json + + json - - jxl + + jxl - - log + + log - - md + + md - - pdf + + pdf - - png + + png - - pptx + + pptx - - svg + + svg - - targz + + targz - - tiff + + tiff - - txt + + txt - - wav + + wav - - webp + + webp - - xlsx + + xlsx - - xml + + xml - - zip + + zip diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index c6a3734..ecb82f4 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 0d68175..3772352 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -429,170 +429,170 @@ - - - avif + + + avif - - bmp + + bmp - - csv + + csv - - docx + + docx - - gif + + gif - - html + + html - - ico + + ico - - jpg + + jpg - - json + + json - - jxl + + jxl - - log + + log - - md + + md - - pdf + + pdf - - png + + png - - pptx + + pptx - - svg + + svg - - targz + + targz - - tiff + + tiff - - txt + + txt - - wav + + wav - - webp + + webp - - xlsx + + xlsx - - xml + + xml - - zip + + zip diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index 77a100e..be4f7c2 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 2e561a6..a0ecc1e 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -429,170 +429,170 @@ - - - avif + + + avif - - bmp + + bmp - - csv + + csv - - docx + + docx - - gif + + gif - - html + + html - - ico + + ico - - jpg + + jpg - - json + + json - - jxl + + jxl - - log + + log - - md + + md - - pdf + + pdf - - png + + png - - pptx + + pptx - - svg + + svg - - targz + + targz - - tiff + + tiff - - txt + + txt - - wav + + wav - - webp + + webp - - xlsx + + xlsx - - xml + + xml - - zip + + zip diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png index 6c806e4..b2a18a5 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 7d7e45d..3f8daaf 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -405,170 +405,170 @@ - - avif + + avif - - bmp + + bmp - - csv + + csv - - docx + + docx - - gif + + gif - - html + + html - - ico + + ico - - jpg + + jpg - - json + + json - - jxl + + jxl - - log + + log - - md + + md - - - pdf + + + pdf - - png + + png - - pptx + + pptx - - svg + + svg - - targz + + targz - - tiff + + tiff - - txt + + txt - - wav + + wav - - webp + + webp - - xlsx + + xlsx - - xml + + xml - - zip + + zip diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png index ec515d3..b7c3e54 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 73c58b1..0493f2e 100644 --- a/internal/guard/testdata/screens/preset-menu.xml +++ b/internal/guard/testdata/screens/preset-menu.xml @@ -405,8 +405,8 @@ - - size-boundaries + + size-boundaries diff --git a/internal/guard/testdata/screens/recipe-contents.png b/internal/guard/testdata/screens/recipe-contents.png index e9796b5..275f6a1 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 f3591b3..a346e5c 100644 --- a/internal/guard/testdata/screens/recipe-contents.xml +++ b/internal/guard/testdata/screens/recipe-contents.xml @@ -168,10 +168,9 @@ - - - - + + + One size 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 bec0351..2c2b94a 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 0167332..2ac7b27 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 @@ -188,10 +188,9 @@ - - - - + + + One size @@ -433,10 +432,9 @@ - - - - + + + One size diff --git a/internal/guard/testdata/screens/recipe-refused.png b/internal/guard/testdata/screens/recipe-refused.png index e6a6da4..760ad23 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 a67ad15..eec1377 100644 --- a/internal/guard/testdata/screens/recipe-refused.xml +++ b/internal/guard/testdata/screens/recipe-refused.xml @@ -183,10 +183,9 @@ - - - - + + + One size diff --git a/internal/guard/testdata/screens/recipe-two-batches.png b/internal/guard/testdata/screens/recipe-two-batches.png index 309a8a8..6b734b7 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 e683977..f565aa1 100644 --- a/internal/guard/testdata/screens/recipe-two-batches.xml +++ b/internal/guard/testdata/screens/recipe-two-batches.xml @@ -173,10 +173,9 @@ - - - - + + + One size @@ -407,10 +406,9 @@ - - - - + + + One size diff --git a/internal/guard/testdata/screens/recipe.png b/internal/guard/testdata/screens/recipe.png index bc15c4e..3c1dd91 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 c88f216..57cfc8c 100644 --- a/internal/guard/testdata/screens/recipe.xml +++ b/internal/guard/testdata/screens/recipe.xml @@ -168,10 +168,9 @@ - - - - + + + One size diff --git a/internal/gui/catalogue/controls.go b/internal/gui/catalogue/controls.go index 4c24140..ca3ed57 100644 --- a/internal/gui/catalogue/controls.go +++ b/internal/gui/catalogue/controls.go @@ -213,6 +213,15 @@ func segments() Entry { s.MouseIn(&desktop.MouseEvent{PointEvent: fyne.PointEvent{Position: fyne.NewPos(s.MinSize().Width-parts.GapInline, 0)}}) return s }}, + {"frozen, middle chosen", func() fyne.CanvasObject { + // Frozen for a run, and still saying which way was chosen. The + // state the catalogue lacked until 2026-09-16, when the frozen + // face lost the choice and nobody had a picture of it (O223). + s := parts.NewSegments(ways, func(string) {}) + s.SetSelected("Range") + s.Disable() + return s + }}, {"long words", func() fyne.CanvasObject { return parts.NewSegments([]string{"Exactly this size", "Somewhere in a range", "On a boundary"}, func(string) {}) }}, diff --git a/internal/gui/catalogue/lists.go b/internal/gui/catalogue/lists.go index 1a4dc3e..8bd9bbf 100644 --- a/internal/gui/catalogue/lists.go +++ b/internal/gui/catalogue/lists.go @@ -36,6 +36,12 @@ func openList() Entry { {"a few values, one chosen", func() fyne.CanvasObject { return asWideAsItsBox(few, parts.NewOpenList(few, "jpg", func(string, bool) {}, func(bool) {})) }}, + {"nothing chosen yet", func() fyne.CanvasObject { + // The list under a box that shows "not stated": no tick anywhere, + // and the words start where the box's word does. The state the + // owner reported as words floating in a rectangle (O220). + return asWideAsItsBox(few, parts.NewOpenList(few, "", func(string, bool) {}, func(bool) {})) + }}, {"more values than a short window shows at once", func() fyne.CanvasObject { l := parts.NewOpenList(many, "json", func(string, bool) {}, func(bool) {}) l.LimitTo(parts.ListCeiling(shortWindow)) diff --git a/internal/gui/parts/fields.go b/internal/gui/parts/fields.go index d195f32..964274b 100644 --- a/internal/gui/parts/fields.go +++ b/internal/gui/parts/fields.go @@ -110,6 +110,24 @@ type Fields struct { // widest name the window can ever show - see LabelColumn - and nought // until then, which lays a name out at its own width. names float32 + + // bare are the controls on the form that have no setting and no name of + // their own and are still part of the form - the switch between the three + // ways of stating a size. Each remembers how many fields stood before it, + // so that KeepFirst can throw it away with the fields it arrived with. + // + // A second list rather than an entry in list with an empty key, because + // list is what a refusal is addressed against and what the guards compare + // with the tree: a field that cannot say what it is about does not belong + // there. Until 2026-09-16 there was no list at all, and Freeze reached + // only list - measured on a render of the batch screen mid run, the size + // boxes frozen and the switch above them live (O223). + bare []bareControl +} + +type bareControl struct { + control fyne.Disableable + after int } // LabelColumn says how wide the column of names is on this screen. @@ -252,8 +270,12 @@ func (s *Fields) register(setting, label string, detail Detail, control fyne.Can // Unlabelled is a row of the form for something that is not a field and has // no name of its own - the switch that chooses between three ways of saying -// how big - so it stands in the column of controls like everything else. +// how big - so it stands in the column of controls like everything else, and +// freezes with them. func (s *Fields) Unlabelled(control fyne.CanvasObject) fyne.CanvasObject { + if d, ok := control.(fyne.Disableable); ok { + s.bare = append(s.bare, bareControl{control: d, after: len(s.list)}) + } return FieldRow(s.names, Clear(), control) } @@ -434,6 +456,13 @@ func (s *Fields) KeepFirst(n int) { for _, f := range s.list { s.by[f.Setting] = f } + kept := s.bare[:0] + for _, b := range s.bare { + if b.after <= n { + kept = append(kept, b) + } + } + s.bare = kept } // Mark shows a refusal under the field it is about, and says whether it found @@ -564,6 +593,13 @@ func (s *Fields) Freeze(frozen bool) { } control.Enable() } + for _, b := range s.bare { + if frozen { + b.control.Disable() + continue + } + b.control.Enable() + } } // Controls is every control a person types into, for a guard comparing the diff --git a/internal/gui/parts/listrow.go b/internal/gui/parts/listrow.go index 3bd861e..807929f 100644 --- a/internal/gui/parts/listrow.go +++ b/internal/gui/parts/listrow.go @@ -101,11 +101,22 @@ func (r *listRowRenderer) Layout(size fyne.Size) { icon := Theme().Size(theme.SizeNameInlineIcon) r.back.Resize(size) r.tick.Resize(fyne.NewSquareSize(icon)) - r.tick.Move(fyne.NewPos(rowGutter, (size.Height-icon)/2)) - // The kind sits between the tick and the words, and takes no room at all - // where there is none - so a list of paper sizes is drawn exactly as it was. - left := rowGutter + icon + rowGap + // The tick stands at the far end of the row, and the words start at the + // gutter - where the word 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. On a list of formats the + // picture in front made that look intended, and on a list with no picture + // and nothing chosen it read as words floating in a rectangle - the + // owner's report from the running window (O220). The column is still + // kept, on the right, so a row does not change width when its value is + // chosen. + r.tick.Move(fyne.NewPos(size.Width-rowGutter-icon, (size.Height-icon)/2)) + left, right := float32(rowGutter), float32(rowGutter+icon+rowGap) + + // The kind sits in front of the words, and takes no room at all where + // there is none - so a list of paper sizes is drawn exactly as it was. if r.row.kind != nil { r.kind.Resize(fyne.NewSquareSize(icon)) r.kind.Move(fyne.NewPos(left, (size.Height-icon)/2)) @@ -116,7 +127,7 @@ func (r *listRowRenderer) Layout(size fyne.Size) { text := r.label.MinSize() r.label.Move(fyne.NewPos(left, (size.Height-text.Height)/2)) - r.label.Resize(fyne.NewSize(size.Width-left-rowGutter, text.Height)) + r.label.Resize(fyne.NewSize(size.Width-left-right, text.Height)) } func (r *listRowRenderer) MinSize() fyne.Size { @@ -144,7 +155,7 @@ func (r *listRowRenderer) MinSize() fyne.Size { // first. func RowWidthFor(word float32, withKind bool) float32 { icon := Theme().Size(theme.SizeNameInlineIcon) - width := rowGutter + icon + rowGap + word + rowGutter + width := rowGutter + word + rowGutter + icon + rowGap if withKind { width += icon + rowGap } diff --git a/internal/gui/parts/segments.go b/internal/gui/parts/segments.go index bbc4ee5..6e73d80 100644 --- a/internal/gui/parts/segments.go +++ b/internal/gui/parts/segments.go @@ -241,6 +241,11 @@ type segmentsRenderer struct { func (r *segmentsRenderer) build() { for range r.seg.Options { fill := canvas.NewRectangle(color.Transparent) + // Rounded to sit inside the border's own curve. Until 2026-09-16 the + // fill was a sharp rectangle under a rounded border, so the corner of + // the chosen segment stood out past the border's arc - one of the + // three geometries the owner saw at once (O219). + fill.CornerRadius = RadiusField - edgeWidth r.fills = append(r.fills, fill) r.words = append(r.words, canvas.NewText("", color.Transparent)) } @@ -249,13 +254,16 @@ func (r *segmentsRenderer) build() { } } +// Layout puts each fill a border's width inside the switch, so that it +// never reaches under the stroke, and each rule on the seam between two +// segments. The words are centred in their segment, not in their fill. func (r *segmentsRenderer) Layout(fyne.Size) { h := r.MinSize().Height x := float32(0) for i, word := range r.seg.Options { w := r.seg.segmentWidth(word) - r.fills[i].Resize(fyne.NewSize(w, h)) - r.fills[i].Move(fyne.NewPos(x, 0)) + r.fills[i].Resize(fyne.NewSize(w-edgeWidth*2, h-edgeWidth*2)) + r.fills[i].Move(fyne.NewPos(x+edgeWidth, edgeWidth)) ink := r.words[i].MinSize() r.words[i].Resize(ink) r.words[i].Move(fyne.NewPos(x+(w-ink.Width)/2, (h-ink.Height)/2)) @@ -285,28 +293,26 @@ func (r *segmentsRenderer) MinSize() fyne.Size { } func (r *segmentsRenderer) Refresh() { - dark := theme.VariantDark off := r.seg.Disabled() chosen := r.seg.indexOf(r.seg.Selected) for i, word := range r.seg.Options { r.words[i].Text = word r.words[i].TextSize = TextBody - switch { - case off: - r.fills[i].FillColor = color.Transparent - r.words[i].Color = PaletteColour(theme.ColorNameDisabled, dark) - case i == chosen: - r.fills[i].FillColor = PaletteColour(theme.ColorNameSelection, dark) - r.words[i].Color = PaletteColour(theme.ColorNameForeground, dark) - case i == r.seg.hovered: - r.fills[i].FillColor = PaletteColour(theme.ColorNameHover, dark) - r.words[i].Color = PaletteColour(theme.ColorNameForeground, dark) - default: - r.fills[i].FillColor = color.Transparent - r.words[i].Color = PaletteColour(theme.ColorNamePlaceHolder, dark) - } + r.fills[i].FillColor, r.words[i].Color = segmentFace(i == chosen, i == r.seg.hovered, off) redraw(r.fills[i], r.words[i]) } + for i := range r.rules { + // A rule stands on the seam between two segments, and only where + // neither of them is the chosen one: there the chosen fill is the + // boundary, and a line beside it was the second of the three + // geometries (O219). + if chosen != i && chosen != i+1 { + r.rules[i].Show() + } else { + r.rules[i].Hide() + } + redraw(r.rules[i]) + } if r.seg.marked { r.ring.StrokeWidth = ringWidth } else { @@ -316,6 +322,34 @@ func (r *segmentsRenderer) Refresh() { r.Layout(r.seg.Size()) } +// segmentFace is the fill and the ink of one segment in one state. +// +// A frozen switch keeps the chosen fill, in disabled ink. Until 2026-09-16 it +// lost it - every fill went transparent when the switch was off - so a form +// frozen for a run would not have said which way of stating a size it was +// running with (O223). It never showed, because the switch was not being +// frozen at all, which is the other half of O223. +func segmentFace(chosen, hovered, off bool) (fill, ink color.Color) { + dark := theme.VariantDark + fill = color.Transparent + switch { + case off: + ink = PaletteColour(theme.ColorNameDisabled, dark) + if chosen { + fill = PaletteColour(theme.ColorNameSelection, dark) + } + case chosen: + fill = PaletteColour(theme.ColorNameSelection, dark) + ink = PaletteColour(theme.ColorNameForeground, dark) + case hovered: + fill = PaletteColour(theme.ColorNameHover, dark) + ink = PaletteColour(theme.ColorNameForeground, dark) + default: + ink = PaletteColour(theme.ColorNamePlaceHolder, dark) + } + return fill, ink +} + // Objects draws the fills first, then the rules and border over their edges, // then the words on top, then the ring outside all of it. func (r *segmentsRenderer) Objects() []fyne.CanvasObject { diff --git a/internal/gui/parts/tokens.go b/internal/gui/parts/tokens.go index d919cd8..7355755 100644 --- a/internal/gui/parts/tokens.go +++ b/internal/gui/parts/tokens.go @@ -213,3 +213,11 @@ const ( rowGutter = space2 rowGap = space2 ) + +// EdgeWidth is the line round a control at rest, for a guard measuring +// whether a fill stays inside it. +func EdgeWidth() float32 { return edgeWidth } + +// RowGutter is the room in front of the first thing on a list row, for a +// guard asking whether the words start there or a column later. +func RowGutter() float32 { return rowGutter }