diff --git a/CHANGELOG.md b/CHANGELOG.md index d856caf..07fca07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,16 @@ because it turns other people's test suites red. ### Fixed +- **The whole head row of a section opens and closes it.** Until now only the + small arrow after a section's title was the target, so a click on "Notes + for the manifest" or on "Settings for png" did nothing and the section + stayed shut. The row is one control now: the title, the arrow, the line a + closed section shows about its contents, and the room to the right of them + all open and close it, the row lights under the pointer, and it is one stop + for the keyboard, where Space and Enter open and close it. The Duplicate and + Remove buttons in a batch's head keep doing their own job. Section titles + stay exactly where they were. + - **A window the graphics toolkit could not create is a refusal that says why, not a process with no window in it.** On a Windows machine whose graphics driver offers no OpenGL 2.1 - a virtual machine without 3D diff --git a/internal/guard/foldedbatch_test.go b/internal/guard/foldedbatch_test.go index 8bebcaf..122faba 100644 --- a/internal/guard/foldedbatch_test.go +++ b/internal/guard/foldedbatch_test.go @@ -5,7 +5,6 @@ import ( "testing" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/theme" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" @@ -14,54 +13,27 @@ import ( ) // foldRow is one fold as this file finds it: the words in its head and the -// button that puts it away. +// control the head row is. type foldRow struct { - title string - toggle *parts.Button + title string + head *parts.FoldHead } // foldRows is every fold on a screen, in the order the tree holds them. // -// Found by shape AND by the title beside it, which is a change of 2026-08-25 -// and the reason is worth stating. A fold used to be the only button on this -// screen with an icon and nothing written on it, so counting those was enough -// and the batches came out in order. Then batches gained folds INSIDE them - -// settings and manifest notes - and the same count returned six buttons for two -// batches, so "the fold of batch 2" was the settings of batch 1. That is O118 -// again: nothing broke in the guard or in the screen, the shape it identified -// its subject by stopped identifying it. -// -// The title is read out of the same row as the button rather than by position -// in it. Positions in that row have moved before - a star, a byte count and a -// detail button have all been added to a field's name row, and each time three -// walks and a probe that read position 1 stopped reading what they meant. -// -// The button that opens a field's longer explanation looks the same from a -// distance and is a *parts.DetailButton, which embeds widget.Button rather than -// being one, so it does not answer this type assertion. It also has no title -// beside it, which is the second reason it cannot be mistaken for a fold here. +// Found by TYPE since O221 (2026-09-17), and by shape and title before that - +// the shape was "an icon-only button in a row with words", and the reason it +// stopped being right is the reason the change was made: the whole head row +// is one control now, and the arrow is a mark on it rather than a button, so +// there is no button to find. The head carries its own title, read from the +// fold rather than from the row beside it, which closes the O118 shape this +// helper used to describe: a title read by position in a row moves when the +// row gains a star, a count or a detail button, and this did three times. func foldRows(o fyne.CanvasObject) []foldRow { var out []foldRow walk(o, func(obj fyne.CanvasObject) { - row, ok := obj.(*fyne.Container) - if !ok { - return - } - var toggle *parts.Button - title := "" - for _, item := range row.Objects { - if found, ok := item.(*parts.Button); ok { - if found.Text == "" && found.Icon != nil { - toggle = found - } - continue - } - if words, ok := wordsOf(item); ok && title == "" { - title = words - } - } - if toggle != nil && title != "" { - out = append(out, foldRow{title: title, toggle: toggle}) + if head, ok := obj.(*parts.FoldHead); ok { + out = append(out, foldRow{title: head.Title(), head: head}) } }) return out @@ -70,7 +42,7 @@ func foldRows(o fyne.CanvasObject) []foldRow { // foldTitled is the fold with these words in its head, counting from the one // named. Titles repeat - every batch has a section called "Settings for bmp" - // so a section is asked for as the first one after the batch it belongs to. -func foldTitled(t *testing.T, o fyne.CanvasObject, after, title string) *parts.Button { +func foldTitled(t *testing.T, o fyne.CanvasObject, after, title string) *parts.FoldHead { t.Helper() rows := foldRows(o) from := 0 @@ -88,7 +60,7 @@ func foldTitled(t *testing.T, o fyne.CanvasObject, after, title string) *parts.B } for _, row := range rows[from:] { if row.title == title { - return row.toggle + return row.head } } t.Fatalf("there is no fold headed %q after %q, and the screen has %v", title, after, foldTitles(rows)) @@ -123,7 +95,7 @@ func batchFolds(o fyne.CanvasObject) int { func foldBatch(t *testing.T, o fyne.CanvasObject, position int) { t.Helper() - foldTitled(t, o, "", text.BatchHeading(position)).OnTapped() + foldTitled(t, o, "", text.BatchHeading(position)).Tapped(nil) } // openFold opens a fold that is shut, and says so if it was open already. @@ -131,15 +103,15 @@ func foldBatch(t *testing.T, o fyne.CanvasObject, position int) { // Asserting the state rather than assuming it, which is the lesson O118 keeps // teaching in this package: a guard that presses a toggle blindly SHUTS a fold // somebody has since made open by default, and then measures a screen it -// believes it opened. The state is read off the arrow, which is the same thing -// a person reads it off. +// believes it opened. The state is read off the head, which reads it off the +// fold - the same state the arrow is drawn from. func openFold(t *testing.T, o fyne.CanvasObject, after, title string) { t.Helper() - toggle := foldTitled(t, o, after, title) - if toggle.Icon == nil || toggle.Icon.Name() != theme.MenuExpandIcon().Name() { + head := foldTitled(t, o, after, title) + if head.Open() { t.Fatalf("the section headed %q is already open, so this guard is not asking what it thinks", title) } - toggle.OnTapped() + head.Tapped(nil) } // assertFoldOpen says the fold is open without touching it. @@ -150,8 +122,7 @@ func openFold(t *testing.T, o fyne.CanvasObject, after, title string) { // a screen with nothing on it. func assertFoldOpen(t *testing.T, o fyne.CanvasObject, after, title string) { t.Helper() - toggle := foldTitled(t, o, after, title) - if toggle.Icon != nil && toggle.Icon.Name() == theme.MenuExpandIcon().Name() { + if !foldTitled(t, o, after, title).Open() { t.Fatalf("the section headed %q is shut, so whatever is measured next is not on the screen", title) } } diff --git a/internal/guard/foldedsections_test.go b/internal/guard/foldedsections_test.go index 66451a8..8030440 100644 --- a/internal/guard/foldedsections_test.go +++ b/internal/guard/foldedsections_test.go @@ -291,7 +291,7 @@ func TestASectionPutAwayStillSaysWhatIsInIt(t *testing.T) { // Shut again, which is what a person does after looking - and the line // has to be worked out at that moment rather than when the panel was // built, because at build time nobody had typed anything. - foldTitled(t, body, text.BatchHeading(1), section.title).OnTapped() + foldTitled(t, body, text.BatchHeading(1), section.title).Tapped(nil) if !buriedFields(body, fields)[at] { t.Fatalf("%q did not shut, so this guard is asking about an open section", section.title) diff --git a/internal/guard/foldedsummary_test.go b/internal/guard/foldedsummary_test.go index 43e5c94..1956fe3 100644 --- a/internal/guard/foldedsummary_test.go +++ b/internal/guard/foldedsummary_test.go @@ -57,7 +57,7 @@ func TestTheFoldedLineNamesWhatWasChosenAndNotTheDefaults(t *testing.T) { // that prefixes, because there the same key belongs to several batches. openFold(t, body, "", text.SettingsFor("log")) chooserIn(t, fields, "entry_format").SetSelected("syslog") - foldTitled(t, body, "", text.SettingsFor("log")).OnTapped() + foldTitled(t, body, "", text.SettingsFor("log")).Tapped(nil) said := shownText(body) if !strings.Contains(said, "syslog") { diff --git a/internal/guard/foldhead_test.go b/internal/guard/foldhead_test.go new file mode 100644 index 0000000..9c620e2 --- /dev/null +++ b/internal/guard/foldhead_test.go @@ -0,0 +1,263 @@ +package guard + +import ( + "image/color" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// The whole head row of a fold opens it and shuts it: the title, the arrow, +// the line said while it is shut and the room to the right of them - and the +// buttons a batch keeps in its head do their own job without touching the +// fold. +// +// Measured on 2026-09-16 (O221) with test.TapCanvas on the recipe screen: +// two presses on the title "Notes for the manifest" and the section stayed +// shut, a press on the arrow opened it. The arrow was the control and the +// title was words beside it. The owner's decision is that the whole row is +// one target, and this guard presses the row where a person would - through +// the canvas, at positions read off the laid out screen, so a target that +// is not where it looks is a red guard rather than a green one. +// +// Each press is asserted to have CHANGED the fold, not only to have landed: +// a press that toggles twice - the row and something inside it both +// answering - leaves the fold as it was, and "as it was" is what a guard +// asking only "did it open" would read as open already (O118). +func TestTheWholeHeadRowOfAFoldOpensAndShutsIt(t *testing.T) { + ourTheme(t) + content, c := laidOutWindow(t) + screen := tabContent(t, content, text.TabRecipe()) + head := foldTitled(t, screen, "", text.BatchHeading(1)) + if !head.Open() { + t.Fatal("batch 1 is built shut, so this guard is not asking what it thinks") + } + + at := fyne.CurrentApp().Driver().AbsolutePositionForObject(head) + size := head.Size() + if size.Width < 200 || size.Height < 20 { + t.Fatalf("the head of batch 1 is %v, which is not a row anybody could press", size) + } + arrow := arrowIn(t, screen, head) + arrowAt := fyne.CurrentApp().Driver().AbsolutePositionForObject(arrow) + middle := at.Y + size.Height/2 + + for _, press := range []struct { + label string + where fyne.Position + }{ + {"the title's first letters", fyne.NewPos(at.X+parts.TabInset+4, middle)}, + {"the arrow", arrowAt.Add(fyne.NewPos(arrow.Size().Width/2, arrow.Size().Height/2))}, + {"the empty room right of the words", fyne.NewPos(at.X+size.Width-4, middle)}, + {"the row's top edge", fyne.NewPos(at.X+size.Width/2, at.Y+1)}, + } { + before := head.Open() + test.TapCanvas(c, press.where) + if head.Open() == before { + t.Errorf("a press on %s (at %v) left the fold as it was, open=%v - the whole head row is one target (O221)", + press.label, press.where, before) + } + } + + // The pointer puts the keyboard on the row quietly: the next Space works, + // and no mark is drawn for somebody using the mouse (PointerFocus). + if c.Focused() != head { + t.Errorf("after a press the keyboard is on %T, not on the head row", c.Focused()) + } + if head.Marked() { + t.Error("the row was pressed with the pointer and drew the keyboard mark") + } + + // A button in the head does its own job and leaves the fold alone. A + // press on it goes to the button, which is on top of the row - and if the + // row answered as well, pressing Duplicate would also shut the batch. + duplicate := buttonNamed(screen, text.ButtonDuplicateBatch()) + if duplicate == nil { + t.Fatalf("batch 1 has no %q button", text.ButtonDuplicateBatch()) + } + before, batches := head.Open(), batchFolds(screen) + buttonAt := fyne.CurrentApp().Driver().AbsolutePositionForObject(duplicate) + test.TapCanvas(c, buttonAt.Add(fyne.NewPos(duplicate.Size().Width/2, duplicate.Size().Height/2))) + if got := batchFolds(screen); got != batches+1 { + t.Fatalf("pressing %q left %d batch(es) where %d were expected, so the press did not land on the button", + text.ButtonDuplicateBatch(), got, batches+1) + } + head = foldTitled(t, screen, "", text.BatchHeading(1)) + if head.Open() != before { + t.Errorf("pressing %q in the head of batch 1 also toggled the fold (open %v -> %v)", text.ButtonDuplicateBatch(), before, head.Open()) + } +} + +// The keyboard opens and shuts the row once per press, and the mark is +// drawn for the keyboard alone. +// +// One press of the space bar reaches a focused control twice from the +// desktop driver - as the key and as the character - which is how one press +// of Space added two batches on 2026-09-16. Both arrivals are delivered here +// the way the driver delivers them, and the fold has to move exactly once. +func TestTheKeyboardOpensAndShutsAFoldOncePerPress(t *testing.T) { + ourTheme(t) + content, c := laidOutWindow(t) + screen := tabContent(t, content, text.TabRecipe()) + head := foldTitled(t, screen, "", text.BatchHeading(1)) + + c.Focus(head) + if c.Focused() != head { + t.Fatalf("the head row cannot hold the keyboard: the canvas put it on %T", c.Focused()) + } + if !head.Marked() { + t.Error("the keyboard arrived on the head row and no mark was drawn") + } + for _, key := range []fyne.KeyName{fyne.KeySpace, fyne.KeyReturn, fyne.KeyEnter} { + before := head.Open() + head.TypedKey(&fyne.KeyEvent{Name: key}) + if key == fyne.KeySpace { + head.TypedRune(' ') + } + if head.Open() == before { + t.Errorf("%s left the fold as it was", key) + } + } + c.Unfocus() + if head.Marked() { + t.Error("the keyboard left the head row and the mark stayed") + } +} + +// The row says it is under the pointer with a fill, says it holds the +// keyboard with a ring, and says neither at rest - read off the drawn +// rectangles rather than off a flag, because a state set and not painted +// looks exactly like one painted (GUI rule 10). The arrow follows: it is the +// head's mark, inked like the words at rest and brighter under the pointer, +// and it points down at an open fold and right at a shut one. +func TestTheHeadRowDrawsItsStatesAndTheArrowFollows(t *testing.T) { + ourTheme(t) + fold := parts.NewFolding("Notes for the manifest", nil, parts.Prose("inside")) + w := test.NewWindow(fold.Object()) + t.Cleanup(w.Close) + w.Resize(fyne.NewSize(600, 200)) + head := fold.Head() + arrow := arrowIn(t, fold.Object(), head) + + back, ring := headRectangles(t, head) + if back.FillColor != color.Transparent || ring.StrokeWidth != 0 { + t.Errorf("at rest the row draws fill %v and ring %v, and it has to draw nothing", back.FillColor, ring.StrokeWidth) + } + restingArrow := arrow.Resource.Name() + + head.MouseIn(&desktop.MouseEvent{}) + if want := parts.PaletteColour(theme.ColorNameHover, theme.VariantDark); back.FillColor != want { + t.Errorf("under the pointer the row's fill is %v, not the hover colour %v", back.FillColor, want) + } + if arrow.Resource.Name() == restingArrow { + t.Error("the arrow is inked the same under the pointer as at rest, so it does not follow the row") + } + head.MouseOut() + if back.FillColor != color.Transparent { + t.Errorf("the pointer left and the fill stayed at %v", back.FillColor) + } + if arrow.Resource.Name() != restingArrow { + t.Error("the pointer left and the arrow stayed inked as under the pointer") + } + + head.FocusGained() + if ring.StrokeWidth == 0 { + t.Error("the keyboard is on the row and no ring is drawn") + } + if back.FillColor != color.Transparent { + t.Errorf("the keyboard mark is a fill (%v), and it has to be a line - see Ring", back.FillColor) + } + head.FocusLost() + if ring.StrokeWidth != 0 { + t.Error("the keyboard left and the ring stayed") + } + + openArrow := arrow.Resource.Name() + fold.Set(false) + if arrow.Resource.Name() == openArrow { + t.Error("the fold shut and the arrow still points the way it did open") + } + fold.Set(true) + if arrow.Resource.Name() != openArrow { + t.Error("the fold opened again and the arrow does not point the way it did before") + } +} + +// The title of a fold stands on the edge its fields stand on, with the row +// reaching TabInset to the left of it for the fill and the ring to draw in - +// asked here of the batch on the recipe screen, and of every title on every +// screen by TestEverythingAPersonReadsStartsOnOneLeftEdge. +func TestTheHeadRowOverhangsTheColumnAndTheTitleDoesNot(t *testing.T) { + ourTheme(t) + content, _ := laidOutWindow(t) + screen := tabContent(t, content, text.TabRecipe()) + head := foldTitled(t, screen, "", text.BatchHeading(1)) + title, ok := labelBox(screen, text.BatchHeading(1)) + if !ok { + t.Fatalf("the recipe screen has no title reading %q", text.BatchHeading(1)) + } + field, ok := labelBox(screen, text.FieldFormat()) + if !ok { + t.Fatalf("the recipe screen has no field named %q", text.FieldFormat()) + } + if off := title.X - field.X; off > 1 || off < -1 { + t.Errorf("the title of batch 1 starts at %.1f px and the name of its first field at %.1f px - one edge for everything a person reads", title.X, field.X) + } + headAt, ok := absoluteOf(screen, head) + if !ok { + t.Fatal("the head row is not on the screen it was found in") + } + if got := title.X - headAt.X; got < parts.TabInset-1 || got > parts.TabInset+1 { + t.Errorf("the row starts %.1f px left of its title, and it has to start TabInset (%v) left of it - the room the fill and the ring draw in", got, parts.TabInset) + } +} + +// arrowIn is the arrow of the fold whose head is given: the icon standing +// inside the head row's bounds. +func arrowIn(t *testing.T, root fyne.CanvasObject, head *parts.FoldHead) *widget.Icon { + t.Helper() + headAt, ok := absoluteOf(root, head) + if !ok { + t.Fatal("the head row is not on the screen it was found in") + } + var found *widget.Icon + atAbsolute(root, func(o fyne.CanvasObject, pos fyne.Position) { + icon, ok := o.(*widget.Icon) + if !ok || found != nil { + return + } + inside := pos.X >= headAt.X && pos.Y >= headAt.Y && + pos.X < headAt.X+head.Size().Width && pos.Y < headAt.Y+head.Size().Height + if inside { + found = icon + } + }) + if found == nil { + t.Fatalf("the head row %q has no arrow in it", head.Title()) + } + return found +} + +// headRectangles are the fill and the ring a head row draws, in that order, +// read from the renderer the way the canvas reads them. +func headRectangles(t *testing.T, head *parts.FoldHead) (back, ring *canvas.Rectangle) { + t.Helper() + objects := test.WidgetRenderer(head).Objects() + if len(objects) != 2 { + t.Fatalf("the head row draws %d object(s), and this guard knows a fill and a ring", len(objects)) + } + back, okBack := objects[0].(*canvas.Rectangle) + ring, okRing := objects[1].(*canvas.Rectangle) + if !okBack || !okRing { + t.Fatalf("the head row draws %T and %T, and this guard knows two rectangles", objects[0], objects[1]) + } + return back, ring +} diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index 16cdd86..4021657 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 fc9ef2b..88b4921 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -1,7 +1,7 @@ - + - - + + @@ -2466,12 +2466,12 @@ - - - - + + + + Folding - + @@ -2481,24 +2481,35 @@ - - - - - - Notes for the manifest - - - - - - - + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + - - + + + - + @@ -2513,7 +2524,7 @@ - + @@ -2523,28 +2534,39 @@ - - - - - - Notes for the manifest - - - - - - - + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + - - + + + - + @@ -2554,33 +2576,44 @@ - - - - - - Settings for png - - - - - - - - - - width 800, height 600 - + + + + + + + + + + + + + Settings for png + + + + + + + + width 800, height 600 + + + + + + - - + + + - + @@ -2590,21 +2623,32 @@ - - - Advanced - - - - - - - + + + + + + + + + + + Advanced + + + + + + + + + - - + + + - + @@ -2617,7 +2661,7 @@ - + @@ -2627,23 +2671,34 @@ - - - Advanced - - - - - - - + + + + + + + + + + + Advanced + + + + + + + + + - - + + + - + @@ -2653,24 +2708,141 @@ - - - - - - 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 + + + + + + + + + + + + + + + + + + + + inside the fold + + + - - + + + + + + + + + + + the head under the pointer + + + + + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + + + + + + + + + inside the fold + + - - + + + + + + + + + + the head holding the keyboard + + + + + + + + + + + + + + + + + + Notes for the manifest + + + + + + + + + + + + + + + @@ -2685,10 +2857,57 @@ + + + + + + shut, the head under the pointer + + + + + + + + + + + + + + + + + + Settings for png + + + + + + + + width 800, height 600 + + + + + + + + + + + + + + + - + @@ -2958,7 +3177,7 @@ - + @@ -3204,7 +3423,7 @@ - + @@ -3310,7 +3529,7 @@ - + diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png index 8508269..6d69020 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 912ae58..d3520c3 100644 --- a/internal/guard/testdata/screens/generate-chosen-by-key.xml +++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,24 +212,35 @@ - - - - Settings for png - - - - - - - + + + + + + + + + + + + Settings for png + + + + + + + + + - - + + + - + Damage @@ -256,12 +267,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-chosen.png b/internal/guard/testdata/screens/generate-chosen.png index eb3dae4..11d6438 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 e2283c4..acf2af5 100644 --- a/internal/guard/testdata/screens/generate-chosen.xml +++ b/internal/guard/testdata/screens/generate-chosen.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,24 +212,35 @@ - - - - Settings for png - - - - - - - + + + + + + + + + + + + Settings for png + + + + + + + + + - - + + + - + Damage @@ -256,12 +267,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-empty.png b/internal/guard/testdata/screens/generate-empty.png index c9532e7..7b63f59 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 5135a43..8206ccb 100644 --- a/internal/guard/testdata/screens/generate-empty.xml +++ b/internal/guard/testdata/screens/generate-empty.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -225,24 +225,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -269,12 +280,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png index de81a21..f6c0026 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 1dc3846..b67fb6c 100644 --- a/internal/guard/testdata/screens/generate-focused.xml +++ b/internal/guard/testdata/screens/generate-focused.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -213,24 +213,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -257,12 +268,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-hovered.png b/internal/guard/testdata/screens/generate-hovered.png index 004f62b..3f720d0 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 8ec6826..b577423 100644 --- a/internal/guard/testdata/screens/generate-hovered.xml +++ b/internal/guard/testdata/screens/generate-hovered.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,24 +212,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -256,12 +267,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index 3043cfc..652dd0d 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 49cc1bf..3363ea1 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,24 +212,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -256,12 +267,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index ecb82f4..5958ca3 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 3772352..3d38bb2 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,24 +212,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -256,12 +267,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index be4f7c2..c6907c2 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 a0ecc1e..e2b0f5c 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,24 +212,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -256,12 +267,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png index f92f4e1..48f54e0 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 93c5971..a7e769c 100644 --- a/internal/guard/testdata/screens/generate-refused-both.xml +++ b/internal/guard/testdata/screens/generate-refused-both.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -238,24 +238,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -282,12 +293,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png index dee38ba..1bbbeee 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 64d2a15..3d7bdd7 100644 --- a/internal/guard/testdata/screens/generate-refused-setting.xml +++ b/internal/guard/testdata/screens/generate-refused-setting.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,22 +212,33 @@ - - - - Settings for png - - - - - - - + + + + + + + + + + + + Settings for png + + + + + + + + + - - + + + - + @@ -312,7 +323,7 @@ - + Damage @@ -339,12 +350,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png index 47d88d2..df830d5 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 6f2361f..4d7c863 100644 --- a/internal/guard/testdata/screens/generate-refused.xml +++ b/internal/guard/testdata/screens/generate-refused.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -227,24 +227,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -271,12 +282,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png index cacd34d..6f390e4 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 9d45f6b..30f3845 100644 --- a/internal/guard/testdata/screens/generate-switch-by-key.xml +++ b/internal/guard/testdata/screens/generate-switch-by-key.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,24 +212,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -256,12 +267,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-typed.png b/internal/guard/testdata/screens/generate-typed.png index 07868c6..d6e64c8 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 276651f..e892b4d 100644 --- a/internal/guard/testdata/screens/generate-typed.xml +++ b/internal/guard/testdata/screens/generate-typed.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -224,24 +224,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -268,12 +279,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate-unchecked.png b/internal/guard/testdata/screens/generate-unchecked.png index 46307ba..b5dcbc4 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 a7326ce..44cf4e6 100644 --- a/internal/guard/testdata/screens/generate-unchecked.xml +++ b/internal/guard/testdata/screens/generate-unchecked.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,24 +212,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -256,12 +267,12 @@ - + - + diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png index d88d7a5..610093c 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 53ce072..9dfc5f6 100644 --- a/internal/guard/testdata/screens/generate.xml +++ b/internal/guard/testdata/screens/generate.xml @@ -56,10 +56,10 @@ - - - - + + + + File configuration @@ -212,24 +212,35 @@ - - - - Settings for avif - - - - - - - + + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - + Damage @@ -256,12 +267,12 @@ - + - + diff --git a/internal/guard/testdata/screens/recipe-contents.png b/internal/guard/testdata/screens/recipe-contents.png index 275f6a1..783f2f5 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 a346e5c..d5a56a8 100644 --- a/internal/guard/testdata/screens/recipe-contents.xml +++ b/internal/guard/testdata/screens/recipe-contents.xml @@ -56,31 +56,42 @@ - - - - - - - - Batch 1 - - - - - - - + + + + + + + + + + + + + + + + Batch 1 + + + + + + + + + - - - - - - Duplicate - + + + + + Duplicate + + + - + @@ -252,37 +263,59 @@ - - - Settings for zip - - - - - - - + + + + + + + + + + + Settings for zip + + + + + + + + + - - + + + - - - Notes for the manifest - - - - - - - + + + + + + + + + + + Notes for the manifest + + + + + + + + + - - + + + - + @@ -378,7 +411,7 @@ - + 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 2c2b94a..5675329 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 2ac7b27..978c416 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 @@ -32,8 +32,8 @@ - - + + @@ -56,36 +56,47 @@ - - - - - - - - Batch 1 - - - - - - - + + + + + + + + + + + + + + + + Batch 1 + + + + + + + + + - - - - - - Duplicate - - - - - Remove - + + + + + Duplicate + + + + + Remove + + + - + @@ -286,68 +297,101 @@ - - - Settings for avif - - - - - - - + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - - - Notes for the manifest - - - - - - - + + + + + + + + + + + Notes for the manifest + + + + + + + + + - - + + + - - - - - - Batch 2 - - - - - - - + + + + + + + + + + + + + + Batch 2 + + + + + + + + + - - - - - - Duplicate - - - - - Remove - + + + + + Duplicate + + + + + Remove + + + - + @@ -513,34 +557,56 @@ - - - Settings for avif - - - - - - - + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - - - Notes for the manifest - - - - - - - + + + + + + + + + + + Notes for the manifest + + + + + + + + + - - + + + @@ -549,7 +615,7 @@ - + @@ -678,8 +744,8 @@ - - + + diff --git a/internal/guard/testdata/screens/recipe-refused.png b/internal/guard/testdata/screens/recipe-refused.png index 760ad23..e898d68 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 eec1377..5bbfffc 100644 --- a/internal/guard/testdata/screens/recipe-refused.xml +++ b/internal/guard/testdata/screens/recipe-refused.xml @@ -56,31 +56,42 @@ - - - - - - - - Batch 1 - - - - - - - + + + + + + + + + + + + + + + + Batch 1 + + + + + + + + + - - - - - - Duplicate - + + + + + Duplicate + + + - + @@ -281,34 +292,56 @@ - - - Settings for avif - - - - - - - + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - - - Notes for the manifest - - - - - - - + + + + + + + + + + + Notes for the manifest + + + + + + + + + - - + + + @@ -317,7 +350,7 @@ - + diff --git a/internal/guard/testdata/screens/recipe-two-batches.png b/internal/guard/testdata/screens/recipe-two-batches.png index 6b734b7..d304ef8 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 f565aa1..591b16c 100644 --- a/internal/guard/testdata/screens/recipe-two-batches.xml +++ b/internal/guard/testdata/screens/recipe-two-batches.xml @@ -32,8 +32,8 @@ - - + + @@ -56,36 +56,47 @@ - - - - - - - - Batch 1 - - - - - - - + + + + + + + + + + + + + + + + Batch 1 + + + + + + + + + - - - - - - Duplicate - - - - - Remove - + + + + + Duplicate + + + + + Remove + + + - + @@ -257,68 +268,101 @@ - - - Settings for avif - - - - - - - + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - - - Notes for the manifest - - - - - - - + + + + + + + + + + + Notes for the manifest + + + + + + + + + - - + + + - - - - - - Batch 2 - - - - - - - + + + + + + + + + + + + + + Batch 2 + + + + + + + + + - - - - - - Duplicate - - - - - Remove - + + + + + Duplicate + + + + + Remove + + + - + @@ -490,34 +534,56 @@ - - - Settings for avif - - - - - - - + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - - - Notes for the manifest - - - - - - - + + + + + + + + + + + Notes for the manifest + + + + + + + + + - - + + + @@ -526,7 +592,7 @@ - + @@ -655,8 +721,8 @@ - - + + diff --git a/internal/guard/testdata/screens/recipe.png b/internal/guard/testdata/screens/recipe.png index 3c1dd91..490ce99 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 57cfc8c..a78e152 100644 --- a/internal/guard/testdata/screens/recipe.xml +++ b/internal/guard/testdata/screens/recipe.xml @@ -56,31 +56,42 @@ - - - - - - - - Batch 1 - - - - - - - + + + + + + + + + + + + + + + + Batch 1 + + + + + + + + + - - - - - - Duplicate - + + + + + Duplicate + + + - + @@ -252,34 +263,56 @@ - - - Settings for avif - - - - - - - + + + + + + + + + + + Settings for avif + + + + + + + + + - - + + + - - - Notes for the manifest - - - - - - - + + + + + + + + + + + Notes for the manifest + + + + + + + + + - - + + + @@ -288,7 +321,7 @@ - + diff --git a/internal/gui/catalogue/fields.go b/internal/gui/catalogue/fields.go index 60fc518..a13ba77 100644 --- a/internal/gui/catalogue/fields.go +++ b/internal/gui/catalogue/fields.go @@ -193,7 +193,11 @@ func folding() Entry { // A fold is built open, which the first render of this catalogue found // out: "open" drew exactly as "closed" did, because the closed one had // never been closed. Guarded now, and closed here on purpose. - return Entry{Name: "Folding", Covers: []string{"InnerFolding"}, States: []State{ + // The head row is one control since O221, so it has the pointer and the + // 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", "FoldHead"}, States: []State{ {"open", func() fyne.CanvasObject { return parts.NewFolding("Notes for the manifest", nil, parts.Prose("inside the fold")).Object() }}, @@ -219,6 +223,23 @@ func folding() Entry { {"a long title", func() fyne.CanvasObject { return parts.NewFolding(longText, nil, parts.Prose("inside the fold")).Object() }}, + {"the head under the pointer", func() fyne.CanvasObject { + f := parts.NewFolding("Notes for the manifest", nil, parts.Prose("inside the fold")) + f.Head().MouseIn(&desktop.MouseEvent{}) + return f.Object() + }}, + {"the head holding the keyboard", func() fyne.CanvasObject { + f := parts.NewFolding("Notes for the manifest", nil, parts.Prose("inside the fold")) + f.Head().FocusGained() + return f.Object() + }}, + {"shut, the head under the pointer", func() fyne.CanvasObject { + f := parts.NewFolding("Settings for png", nil, parts.Prose("inside the fold")) + f.Say("width 800, height 600") + f.Set(false) + f.Head().MouseIn(&desktop.MouseEvent{}) + return f.Object() + }}, }} } diff --git a/internal/gui/parts/foldhead.go b/internal/gui/parts/foldhead.go new file mode 100644 index 0000000..2712a13 --- /dev/null +++ b/internal/gui/parts/foldhead.go @@ -0,0 +1,223 @@ +package parts + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// FoldHead is what the head row of a fold answers to: the pointer, anywhere +// on the row, and the keyboard, as one stop. +// +// Measured on 2026-09-16 (O221): a press on the title of a fold did nothing, +// and only the arrow after it opened the section, because the arrow was the +// control and the title was words beside it - two presses on "Notes for the +// manifest" and the section stayed shut. The owner's decision is that the +// whole row is one target. So this is one control the width of the row: the +// title, the arrow, the line said while the fold is shut, and the room to +// the right of them up to the buttons a batch keeps in its head. The arrow +// is a mark on it rather than a button of its own, which is also why the +// row has one stop for the keyboard rather than the two a button inside a +// row would make. +// +// It draws only its own state and holds no words. A fill under the pointer +// and a ring for the keyboard, under the row's content in a stack - WithRing +// the other way up. The title stays a plain object in a plain container on +// purpose: every guard that reads titles walks containers and stops at a +// widget it was not told about, so a title inside this renderer would be a +// title nothing reads (O118). The arrow is this control's to colour, so it +// follows the row's state the way a glyph button's mark follows its own. +type FoldHead struct { + widget.BaseWidget + + fold *Folding + arrow *widget.Icon + + hovered bool + marked bool + from PointerFocus +} + +var ( + _ fyne.Tappable = (*FoldHead)(nil) + _ fyne.Focusable = (*FoldHead)(nil) + _ desktop.Hoverable = (*FoldHead)(nil) +) + +func newFoldHead(fold *Folding, arrow *widget.Icon) *FoldHead { + h := &FoldHead{fold: fold, arrow: arrow} + h.ExtendBaseWidget(h) + h.drawArrow() + return h +} + +// Title is the words in the head, for a guard that finds a fold by them. +func (h *FoldHead) Title() string { return h.fold.title } + +// Open says whether the fold this heads is open. +func (h *FoldHead) Open() bool { return h.fold.open } + +// Hovered and Marked report the drawn state, for a guard. +func (h *FoldHead) Hovered() bool { return h.hovered } +func (h *FoldHead) Marked() bool { return h.marked } + +// Tapped opens the fold or puts it away. The keyboard comes to the row +// quietly first, so the mark meaning "the keyboard is here" is not drawn for +// somebody using the mouse - see PointerFocus. +func (h *FoldHead) Tapped(*fyne.PointEvent) { + if c := fyne.CurrentApp().Driver().CanvasForObject(h); c != nil { + h.from.Quietly(func() { c.Focus(h) }) + } + h.fold.Set(!h.fold.open) +} + +func (h *FoldHead) MouseIn(*desktop.MouseEvent) { + h.hovered = true + h.Refresh() +} + +func (h *FoldHead) MouseMoved(*desktop.MouseEvent) {} + +func (h *FoldHead) MouseOut() { + h.hovered = false + h.Refresh() +} + +// FocusGained draws the mark only for the keyboard. See PointerFocus. +func (h *FoldHead) FocusGained() { + if h.from.Quiet() { + return + } + h.marked = true + h.Refresh() +} + +func (h *FoldHead) FocusLost() { + h.marked = false + h.Refresh() +} + +// TypedRune answers nothing, for the reason Button gives: one press of the +// space bar reaches a focused control twice from the desktop driver, as the +// key and as the character, and a row answering both would open and shut. +func (h *FoldHead) TypedRune(rune) {} + +// TypedKey opens or shuts on the space bar and on both names of Enter. +func (h *FoldHead) TypedKey(event *fyne.KeyEvent) { + if event == nil { + return + } + switch event.Name { + case fyne.KeyReturn, fyne.KeyEnter, fyne.KeySpace: + h.Tapped(nil) + } +} + +// Refresh redraws the state, and recolours the arrow with it - the arrow +// stands in the row's content rather than in this renderer, so it is told +// here rather than by the renderer. widget.Icon repaints itself when its +// resource is set. +func (h *FoldHead) Refresh() { + h.drawArrow() + h.BaseWidget.Refresh() +} + +// drawArrow points the arrow the way the fold is and inks it the way the row +// is: the hint's colour at rest and the foreground under the pointer, which +// is what a glyph button does with its mark. +func (h *FoldHead) drawArrow() { + icon := theme.MenuDropDownIcon() + if !h.fold.open { + icon = theme.MenuExpandIcon() + } + ink := theme.ColorNamePlaceHolder + if h.hovered { + ink = theme.ColorNameForeground + } + h.arrow.SetResource(theme.NewColoredResource(icon, ink)) +} + +func (h *FoldHead) CreateRenderer() fyne.WidgetRenderer { + back := canvas.NewRectangle(color.Transparent) + back.CornerRadius = RadiusField + ring := canvas.NewRectangle(color.Transparent) + ring.CornerRadius = RadiusField + ring.StrokeColor = PaletteColour(theme.ColorNamePrimary, theme.VariantDark) + r := &foldHeadRenderer{head: h, back: back, ring: ring} + r.Refresh() + return r +} + +type foldHeadRenderer struct { + head *FoldHead + back *canvas.Rectangle + ring *canvas.Rectangle +} + +func (r *foldHeadRenderer) Layout(size fyne.Size) { + r.back.Resize(size) + r.ring.Resize(size) +} + +// MinSize is nothing: the row's content in the stack above this decides the +// size, and this takes whatever the stack gives. +func (r *foldHeadRenderer) MinSize() fyne.Size { return fyne.NewSize(0, 0) } + +// Refresh draws the state: a fill under the pointer, a ring for the keyboard, +// and neither at rest. The ring is a line and not a fill, for the reason Ring +// gives. +func (r *foldHeadRenderer) Refresh() { + if r.head.hovered { + r.back.FillColor = PaletteColour(theme.ColorNameHover, theme.VariantDark) + } else { + r.back.FillColor = color.Transparent + } + if r.head.marked { + r.ring.StrokeWidth = ringWidth + } else { + r.ring.StrokeWidth = 0 + } + redraw(r.back, r.ring) +} + +func (r *foldHeadRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.back, r.ring} +} + +func (r *foldHeadRenderer) Destroy() {} + +// overhang lays its one child out reaching a distance to the LEFT of the +// container's own edge, and that much wider, so that what the child keeps as +// room inside its left edge lands on the container's edge. +// +// It exists for the head of a fold. The row keeps TabInset inside its own +// box, the way a word on the strip does, so that the fill under the pointer +// and the ring round the keyboard have room to draw in rather than sitting on +// the ink. But the title has to stay on the edge everything else on the +// screen starts on - TestEverythingAPersonReadsStartsOnOneLeftEdge - and +// moving the words in by TabInset would move them off it. So the row starts +// TabInset to the left instead, into the panel's own inset, which is twice +// as wide. Its right edge is unchanged, so the buttons a batch keeps in its +// head still end where the fields under them end. +type overhang struct{ by float32 } + +func (o overhang) MinSize(objects []fyne.CanvasObject) fyne.Size { + size := fyne.NewSize(0, 0) + for _, child := range objects { + min := child.MinSize() + size.Width = fyne.Max(size.Width, min.Width-o.by) + size.Height = fyne.Max(size.Height, min.Height) + } + return size +} + +func (o overhang) Layout(objects []fyne.CanvasObject, size fyne.Size) { + for _, child := range objects { + child.Move(fyne.NewPos(-o.by, 0)) + child.Resize(fyne.NewSize(size.Width+o.by, size.Height)) + } +} diff --git a/internal/gui/parts/folding.go b/internal/gui/parts/folding.go index c0d2a7c..da1ca1a 100644 --- a/internal/gui/parts/folding.go +++ b/internal/gui/parts/folding.go @@ -3,7 +3,6 @@ package parts import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -30,10 +29,12 @@ import ( // box is in, which is what keeps the objection answered rather than dodged. See // the batch screen's use of it. type Folding struct { - open bool - body fyne.CanvasObject - line *widget.Label - toggle *Button + open bool + title string + body fyne.CanvasObject + line *widget.Label + // head is the one control the whole head row is - see FoldHead. + head *FoldHead // object is the whole thing, rebuilt when the fold moves so the layout // above it is told to take the room back. @@ -84,14 +85,12 @@ func NewInnerFolding(title string, content ...fyne.CanvasObject) *Folding { } func newFolding(title string, head []fyne.CanvasObject, content ...fyne.CanvasObject) *Folding { - f := &Folding{open: true} + f := &Folding{open: true, title: title} f.line = widget.NewLabel("") f.line.Importance = widget.LowImportance f.line.Hide() - f.toggle = NewGlyphButton(theme.MenuDropDownIcon(), func() { f.Set(!f.open) }) - f.body = Column(GapField, content...) // The title first and the arrow after it, which is not where a disclosure @@ -105,16 +104,29 @@ func newFolding(title string, head []fyne.CanvasObject, content ...fyne.CanvasOb // worse - there are more of them and they are what somebody is reading. // The summary line goes through quiet, so it recedes to the hint's colour // rather than the brighter disabled one widget.LowImportance draws (O213). - row := []fyne.CanvasObject{sectionTitle(title), f.toggle, quiet(f.line), layout.NewSpacer()} - row = append(row, head...) - - f.inside = Column(GapField, container.NewHBox(row...), f.body) + // + // The whole row is the control, since O221, and the arrow is its mark + // rather than a button: FoldHead answers to the pointer and the keyboard + // under the title, the arrow and the line, and the row reaches to the + // buttons a batch keeps at its right. The head keeps TabInset inside its + // box for the fill and the ring to draw in, and overhangs the column by + // the same amount so the title's ink does not move - see overhang. + arrow := widget.NewIcon(theme.MenuDropDownIcon()) + f.head = newFoldHead(f, arrow) + words := Padded(TabInset, container.NewHBox(sectionTitle(title), arrow, quiet(f.line))) + row := container.NewBorder(nil, nil, nil, container.NewHBox(head...), container.NewStack(f.head, words)) + + f.inside = Column(GapField, container.New(overhang{by: TabInset}, row), f.body) return f } // Object is the panel to put on a screen. func (f *Folding) Object() fyne.CanvasObject { return f.object } +// Head is the control the head row is, for a screen or a guard that wants to +// press it, hover it or hand it the keyboard. +func (f *Folding) Head() *FoldHead { return f.head } + // Holds says whether a control is somewhere inside this fold. // // Asked of what was built rather than remembered beside it. A screen could keep @@ -173,14 +185,14 @@ func (f *Folding) Set(open bool) { if open { f.body.Show() f.line.Hide() - f.toggle.SetIcon(theme.MenuDropDownIcon()) } else { f.body.Hide() if f.line.Text != "" { f.line.Show() } - f.toggle.SetIcon(theme.MenuExpandIcon()) } + // The head reads the fold's state and points its arrow by it. + f.head.Refresh() f.object.Refresh() if f.OnChange != nil { f.OnChange(open)