diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9bc66..9166021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,19 @@ because it turns other people's test suites red. ### Changed +- **The list of formats is grouped by kind and can be filtered.** The open + list stands under headings - Archives, Documents, Pictures, Sound, Text and + data - each saying how many formats are under it, and a box at its top + narrows it to the formats whose name holds what is typed, or whose kind + has a word that starts with it (`gz` finds `targz`, `pict` every picture, + `data` every text and data format). The letters that + matched are drawn in bold, the arrows step over the headings, and typing + at the shut `Format` menu opens the list with those letters in the box, so + `jxl` typed there ends on `jxl`. The shut menu draws the kind of the format + it holds, and archives are drawn as a folder rather than as three bars that + looked like text. The command line lists the formats in one alphabetical + order as before. + - **The window lays its forms out in columns.** A field now takes as many columns of the form as its value needs and no more, so `Format`, `Size`, `How many files` and `Damage` stand in one row instead of one under another, diff --git a/internal/guard/dropdown_test.go b/internal/guard/dropdown_test.go index c5c6427..2bab554 100644 --- a/internal/guard/dropdown_test.go +++ b/internal/guard/dropdown_test.go @@ -5,6 +5,8 @@ import ( "testing" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/test" "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" @@ -24,11 +26,10 @@ import ( // starting with it, collapsed or expanded - and NN/g lists typing a letter // among the things a dropdown has to support. func TestALetterTypedAtTheShutListMovesToThatValue(t *testing.T) { - _, content := screenOnACanvas(t) - menu := chooserUnder(t, content, text.FieldFormat()) + menu, _ := aMenuWithoutAFilter(t) - // csv is where a fresh screen starts, so p has to reach pdf and png rather - // than the first value in the list. + // csv is where it starts, so p has to reach pdf and png rather than the + // first value in the list. menu.TypedRune('p') if menu.Selected != "pdf" { t.Errorf("p was typed at a list showing csv and it holds %q, where pdf is the first value starting with p", menu.Selected) @@ -46,7 +47,7 @@ func TestALetterTypedAtTheShutListMovesToThatValue(t *testing.T) { // clearing it. menu.TypedRune('q') if menu.Selected != "png" { - t.Errorf("a letter no format starts with changed the value to %q", menu.Selected) + t.Errorf("a letter no value starts with changed the value to %q", menu.Selected) } } @@ -56,8 +57,7 @@ func TestALetterTypedAtTheShutListMovesToThatValue(t *testing.T) { // nothing is settled until Enter, which is what the ARIA practices ask for and // what stops a held key from committing a value nobody looked at. func TestALetterTypedAtTheOpenListMovesTheKeyboard(t *testing.T) { - _, content := screenOnACanvas(t) - menu := chooserUnder(t, content, text.FieldFormat()) + menu, _ := aMenuWithoutAFilter(t) menu.Tapped(&fyne.PointEvent{}) list := menu.Opened() @@ -76,6 +76,31 @@ func TestALetterTypedAtTheOpenListMovesTheKeyboard(t *testing.T) { } } +// aMenuWithoutAFilter is a menu of a few values sharing first letters, in a +// window, showing csv. +// +// A menu of its own rather than the format menu on a screen, since +// 2026-09-23: the list of formats has a filter now, and a letter typed at it +// goes into the filter (formatlist_test.go). Every other menu in the window +// keeps the jump these two guards are about - and a subset of the formats is +// exactly a menu without one, which this asserts rather than assumes. +func aMenuWithoutAFilter(t *testing.T) (*parts.Chooser, fyne.Window) { + t.Helper() + app := test.NewApp() + app.Settings().SetTheme(parts.Theme()) + t.Cleanup(func() { test.NewApp() }) + + menu := parts.NewChooser([]string{"csv", "pdf", "png", "txt", "wav"}, nil) + if menu.Filtered { + t.Fatal("a menu of five values opens with a filter, so these guards would be asking about the filter") + } + menu.SetSelected("csv") + w := test.NewWindow(container.NewVBox(menu)) + t.Cleanup(w.Close) + w.Resize(fyne.NewSize(400, 600)) + return menu, w +} + // A press opens the list without painting the keyboard's place in it. // // The same rule as everywhere else in this window, and it needs saying here @@ -154,11 +179,13 @@ func formatListRowsShownIn(t *testing.T, height float32) float32 { menu := chooserUnder(t, content, text.FieldFormat()) menu.Tapped(&fyne.PointEvent{}) pop := popUpIn(canvas.Overlays().Top()) - if pop == nil { + if pop == nil || menu.Opened() == nil { t.Fatalf("the press opened no list on the canvas %.0f px tall", height) } + // The filter box at the top is not a row, so the rows are what is under + // it. Since 2026-09-23 - see parts.RoomForList for the head. tall := pop.Size().Height - shown := tall / row + shown := (tall - menu.Opened().HeadHeight()) / row if tall > height/2+0.5 { t.Errorf("the open list is %.0f px tall in a window %.0f px tall, which is more than half of it.\n"+ "Reason: an open list that covers the form takes the context away from the person reading it.\n"+ diff --git a/internal/guard/filekindicon_test.go b/internal/guard/filekindicon_test.go index e1c221c..4407264 100644 --- a/internal/guard/filekindicon_test.go +++ b/internal/guard/filekindicon_test.go @@ -105,9 +105,19 @@ func TestTheFormatMenuDrawsThePictureOfEachKind(t *testing.T) { if len(rows) == 0 { t.Fatal("the list that dropped down is drawing no rows") } + values := 0 for _, row := range rows { + // A heading names a kind and draws no picture of one, on purpose - + // see TestTheFormatListStandsUnderAHeadingForEachKind. + if row.Heading() { + continue + } + values++ if row.Kind() == nil { t.Errorf("the row for %q draws no picture, so the kinds stop at the table", row.Label()) } } + if values == 0 { + t.Fatal("the list drew headings and no value, so no picture was asked about") + } } diff --git a/internal/guard/formatlist_test.go b/internal/guard/formatlist_test.go new file mode 100644 index 0000000..d2ffcb0 --- /dev/null +++ b/internal/guard/formatlist_test.go @@ -0,0 +1,354 @@ +package guard + +import ( + "sort" + "strings" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/widget" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// The list of formats, grouped by kind and filtered by what is typed, since +// 2026-09-23. Twenty six formats of which eighteen fitted in the open list, +// and the owner's decisions on the running window: a heading over each kind, +// a box at the top that narrows the list, the letters that matched in bold, +// a count beside each heading, and a letter typed at the shut menu opening +// the list with that letter in the box. docs/FORMAT-MENU-2026-09-23.md has +// the analysis, the measurements and what was turned down. + +// openFormatList presses the format menu of the single batch screen and +// answers the menu, the list it opened and the box at the top of the list - +// failing, rather than answering nil, when any of the three is missing. +func openFormatList(t *testing.T) (fyne.Canvas, *parts.Chooser, *parts.OpenList, *parts.FilterBox) { + t.Helper() + c, content := screenOnACanvas(t) + menu := chooserUnder(t, content, text.FieldFormat()) + if !menu.Filtered { + t.Fatal("the format menu has no filter, so every guard in this file would be asking about another list") + } + menu.Tapped(&fyne.PointEvent{}) + list := menu.Opened() + if list == nil { + t.Fatal("pressing the format menu opened no list") + } + filter := list.Filter() + if filter == nil { + t.Fatal("the list of formats opened without the box that narrows it") + } + return c, menu, list, filter +} + +// typeInto replaces what the filter box holds, the way a person clearing it +// and typing would end up. +func typeInto(filter *parts.FilterBox, typed string) { + filter.SetText("") + for _, r := range typed { + filter.TypedRune(r) + } +} + +// valuesOf is the values a list is showing, headings and the notice left out. +func valuesOf(list *parts.OpenList) []string { + var out []string + for _, row := range list.Rows() { + if row.Choosable { + out = append(out, row.Label) + } + } + return out +} + +// activeLabel is the value the keyboard is on, or "" when it is on nothing. +func activeLabel(list *parts.OpenList) string { + rows := list.Rows() + if at := list.Active(); at >= 0 && at < len(rows) { + return rows[at].Label + } + return "" +} + +// TestTheFormatListStandsUnderAHeadingForEachKind asks the whole list, with +// nothing typed: every registered format exactly once, each under the heading +// of its own kind, the headings in the order of their words, and each heading +// counting the formats under it. +func TestTheFormatListStandsUnderAHeadingForEachKind(t *testing.T) { + _, _, list, _ := openFormatList(t) + + // The rows cut into groups at each heading. + type group struct { + heading string + values []string + } + var groups []group + for _, row := range list.Rows() { + if !row.Choosable { + groups = append(groups, group{heading: row.Label}) + continue + } + if len(groups) == 0 { + t.Fatalf("%s stands above the first heading", row.Label) + } + groups[len(groups)-1].values = append(groups[len(groups)-1].values, row.Label) + } + if len(groups) < 2 { + t.Fatalf("the list has %d heading(s), so there is no grouping to ask about", len(groups)) + } + + seen := map[string]int{} + var titles []string + for _, g := range groups { + if len(g.values) == 0 { + t.Errorf("the heading %q stands over nothing", g.heading) + continue + } + title := parts.KindHeading(g.values[0]) + titles = append(titles, title) + for _, v := range g.values { + seen[v]++ + if parts.KindHeading(v) != title { + t.Errorf("%s stands under %q with the %s, and it is one of the %s", v, g.heading, title, parts.KindHeading(v)) + } + } + if want := text.ListHeadingCount(title, len(g.values)); g.heading != want { + t.Errorf("the heading over %d format(s) of one kind reads %q rather than %q", len(g.values), g.heading, want) + } + } + for _, id := range format.IDs() { + if seen[id] != 1 { + t.Errorf("%s is in the list %d time(s), and every format belongs there once", id, seen[id]) + } + } + if len(seen) != len(format.IDs()) { + t.Errorf("the list holds %d formats and the registry %d", len(seen), len(format.IDs())) + } + if !sort.StringsAreSorted(titles) { + t.Errorf("the headings stand in the order %v, and a closed set is in the order of its words", titles) + } + // Counted, because a loop over the drawn rows passes just as well when no + // heading was drawn at all - an outside review of #127 named it, and it is + // trap 1 of CLAUDE.md: the guard has to be in the state it asks about. + drawnHeadings := 0 + for _, row := range list.DrawnRows() { + if !row.Heading() { + continue + } + drawnHeadings++ + if row.Kind() != nil || row.Marked() { + t.Errorf("the heading %q draws a picture or a tick, which says it is a value somebody can take", row.Label()) + } + } + if drawnHeadings == 0 { + t.Fatal("no heading row is drawn, so nothing was asked about how a heading looks") + } +} + +// TestTypingIntoTheFormatListNarrowsItAndLandsOnWhatStartsWithIt types into +// the box and reads what is left. +// +// Anywhere in the name ("gz" keeps targz), and a word of the heading from its +// start ("pict" keeps every picture) but not from its middle - "t" is inside +// "Documents", and a filter that matched that would keep every document for +// one letter. The keyboard lands on the first value STARTING with what was +// typed, nothing is taken until Enter, and a filter that leaves nothing says +// so and takes nothing. +func TestTypingIntoTheFormatListNarrowsItAndLandsOnWhatStartsWithIt(t *testing.T) { + _, menu, list, filter := openFormatList(t) + before := menu.Selected + + typeInto(filter, "gz") + if got := valuesOf(list); strings.Join(got, ",") != "targz" { + t.Errorf("gz was typed and the list keeps %v, where targz is the one format holding it", got) + } + + typeInto(filter, "p") + if got := activeLabel(list); !strings.HasPrefix(got, "p") { + t.Errorf("p was typed and the keyboard is on %q, not on a format starting with p", got) + } + + typeInto(filter, "pict") + pictures := 0 + for _, id := range format.IDs() { + if parts.KindHeading(id) == text.ListKindPictures() { + pictures++ + } + } + if got := valuesOf(list); len(got) != pictures { + t.Errorf("pict was typed and the list keeps %d formats, where %d are pictures: %v", len(got), pictures, got) + } + + typeInto(filter, "t") + for _, v := range valuesOf(list) { + if v == "xlsx" { + t.Errorf("t was typed and xlsx is kept - neither its name nor a word of its heading starts with t, " + + "so a heading is being matched in the middle of a word") + } + } + + typeInto(filter, "zz") + if got := valuesOf(list); len(got) != 0 { + t.Errorf("zz was typed and the list keeps %v", got) + } + rows := list.Rows() + if len(rows) != 1 || rows[0].Choosable || rows[0].Label != text.ListNothingMatches() { + t.Errorf("a filter that keeps nothing draws %v rather than saying nothing matches", rows) + } + filter.TypedKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) + if menu.Selected != before || menu.Opened() == nil { + t.Errorf("Enter on a list with nothing in it took %q (the box held %q) or closed the list", menu.Selected, before) + } +} + +// TestTheArrowsInTheFormatListStepOverTheHeadings walks a narrowed list. +// +// "j" leaves jpg and jxl under one heading and json under another, so Down +// from jxl has a heading in its way - the cost the deferral of 2026-08-25 +// wrote down, before there were any headings to step over. +func TestTheArrowsInTheFormatListStepOverTheHeadings(t *testing.T) { + _, _, list, filter := openFormatList(t) + typeInto(filter, "j") + walk := []struct { + key fyne.KeyName + want string + }{ + {fyne.KeyDown, "jxl"}, + {fyne.KeyDown, "json"}, + {fyne.KeyDown, "json"}, + {fyne.KeyUp, "jxl"}, + {fyne.KeyUp, "jpg"}, + {fyne.KeyUp, "jpg"}, + } + if got := activeLabel(list); got != "jpg" { + t.Fatalf("j was typed and the keyboard is on %q, not on jpg", got) + } + for i, step := range walk { + filter.TypedKey(&fyne.KeyEvent{Name: step.key}) + if got := activeLabel(list); got != step.want { + t.Fatalf("step %d, %s: the keyboard is on %q, where %s is the next value", i+1, step.key, got, step.want) + } + } +} + +// TestTypingAtTheShutFormatMenuOpensItsFilter types a whole name at the menu +// with its list shut. One letter at a time used to walk the values starting +// with each letter in turn, so "jxl" ended on log. Now the first letter opens +// the list with that letter in the box, the keyboard goes to the box, and the +// rest of the name follows it there. +func TestTypingAtTheShutFormatMenuOpensItsFilter(t *testing.T) { + c, content := screenOnACanvas(t) + menu := chooserUnder(t, content, text.FieldFormat()) + menu.TypedRune('j') + list := menu.Opened() + if list == nil || list.Filter() == nil { + t.Fatal("a letter typed at the shut format menu opened no list with a box to narrow it") + } + filter := list.Filter() + if filter.Text != "j" { + t.Errorf("the box holds %q after j was typed at the shut menu", filter.Text) + } + if c.Focused() != fyne.Focusable(filter) { + t.Errorf("the keyboard went to %T, so the next letter would not reach the box", c.Focused()) + } + filter.TypedRune('x') + filter.TypedRune('l') + filter.TypedKey(&fyne.KeyEvent{Name: fyne.KeyReturn}) + if menu.Selected != "jxl" { + t.Errorf("jxl was typed at the shut menu and Enter pressed, and the menu holds %q", menu.Selected) + } +} + +// TestTheSpaceThatOpensTheFormatListIsNotTypedIntoItsFilter presses Space at +// the shut menu the way the driver delivers it: the key to whatever has the +// keyboard, then the character to whatever has it NOW (fyne v2.8.1 +// internal/driver/glfw/window.go, processKeyPressed and processCharInput both +// ask canvas.Focused()). The key opens the list and hands the keyboard to the +// box, so the character landed in the box. +// +// Reported by an outside review of #127 and seen in the real window through +// tools/pilot.py before this was written: after Space the box lost its +// placeholder and the caret stood one space in, with nothing visible typed. +func TestTheSpaceThatOpensTheFormatListIsNotTypedIntoItsFilter(t *testing.T) { + c, content := screenOnACanvas(t) + menu := chooserUnder(t, content, text.FieldFormat()) + c.Focus(menu) + c.Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeySpace}) + list := menu.Opened() + if list == nil || list.Filter() == nil { + t.Fatal("Space at the shut format menu opened no list with a box to narrow it, so this guard is not in the state it asks about") + } + if c.Focused() != fyne.Focusable(list.Filter()) { + t.Fatalf("after Space the keyboard is on %T rather than in the box, so the character would not reach it", c.Focused()) + } + c.Focused().TypedRune(' ') + if got := list.Filter().Text; got != "" { + t.Errorf("the Space that opened the list left %q in its box, which hides the placeholder and moves the caret", got) + } + // A space between words is still a space: only an empty box drops one. + c.Focused().TypedRune('t') + c.Focused().TypedRune(' ') + if got := list.Filter().Text; got != "t " { + t.Errorf("t then Space typed into the box left %q, and a space after a letter is somebody typing", got) + } +} + +// TestThePartOfAValueThatMatchedIsDrawnInBold reads a row's drawn words. +func TestThePartOfAValueThatMatchedIsDrawnInBold(t *testing.T) { + _, _, list, filter := openFormatList(t) + typeInto(filter, "gz") + row := list.RowShowing("targz") + if row == nil { + t.Fatal("gz was typed and no row is drawing targz") + } + var bold, plain []string + for _, o := range test.WidgetRenderer(row).Objects() { + words, ok := o.(*canvas.Text) + if !ok || !words.Visible() || words.Text == "" { + continue + } + if words.TextStyle.Bold { + bold = append(bold, words.Text) + } else { + plain = append(plain, words.Text) + } + } + if strings.Join(bold, "") != "gz" || strings.Join(plain, "") != "tar" { + t.Errorf("targz with gz typed draws %v in bold and %v plain, where gz is what matched", bold, plain) + } +} + +// TestTheShutFormatMenuDrawsThePictureOfItsValue reads the closed box: the +// picture of the value's kind in front of the words, and a new picture when +// the value changes. Until 2026-09-23 the kind was on the screen only while +// the list was open. +func TestTheShutFormatMenuDrawsThePictureOfItsValue(t *testing.T) { + _, content := screenOnACanvas(t) + menu := chooserUnder(t, content, text.FieldFormat()) + for _, value := range []string{"zip", "png"} { + menu.SetSelected(value) + var picture *canvas.Image + var words *widget.RichText + for _, o := range test.WidgetRenderer(menu).Objects() { + switch drawn := o.(type) { + case *canvas.Image: + if drawn.Visible() && drawn.Resource != nil { + picture = drawn + } + case *widget.RichText: + words = drawn + } + } + if picture == nil || picture.Resource.Name() != parts.KindOfFile(value).Name() { + t.Errorf("the shut menu holding %s draws %v, not the picture of its kind", value, picture) + continue + } + if words == nil || words.Position().X < picture.Position().X+picture.Size().Width { + t.Errorf("the shut menu holding %s starts its words over the picture", value) + } + } +} diff --git a/internal/guard/listedge_test.go b/internal/guard/listedge_test.go index 4262638..2879ed0 100644 --- a/internal/guard/listedge_test.go +++ b/internal/guard/listedge_test.go @@ -183,7 +183,7 @@ func TestAListOpensDownwardWheneverAFewRowsFitUnderTheBox(t *testing.T) { {"two rows below and sixteen above", 600, 480, 24, 10, false, "fewer than the threshold below, and the ceiling of a 600 px window is ten rows"}, } { - height, at := parts.RoomForList(tc.canvas, tc.top, box, tc.wantedRows*row) + height, at := parts.RoomForList(tc.canvas, tc.top, box, tc.wantedRows*row, 0) downward := at >= tc.top+box if downward != tc.wantDownward { t.Errorf("%s: the list opens %s and should open %s (%s)", tc.name, diff --git a/internal/guard/listwords_test.go b/internal/guard/listwords_test.go index 77e0d8a..a12026b 100644 --- a/internal/guard/listwords_test.go +++ b/internal/guard/listwords_test.go @@ -72,6 +72,11 @@ func TestTheWordsInAnOpenListStartWhereTheWordInTheBoxDoes(t *testing.T) { t.Fatalf("the %s list is drawing no row at all", tc.field) } for _, row := range rows { + // A heading is its words at the gutter and nothing else - no tick, + // no picture. Its shape is TestTheFormatListStandsUnderAHeadingForEachKind's. + if row.Heading() { + continue + } words, tick, picture := piecesOfARow(t, row) if tc.pictured { // Tick, picture, words: each starts where the one before it @@ -133,7 +138,12 @@ func piecesOfARow(t *testing.T, row *parts.ListRow) (words *canvas.Text, tick, p for _, o := range test.WidgetRenderer(row).Objects() { switch drawn := o.(type) { case *canvas.Text: - words = drawn + // The first text, which holds the words whole while nothing is + // typed into the list's filter. Two more follow it for the bold + // part and the rest, empty and hidden at rest. + if words == nil { + words = drawn + } case *canvas.Image: if drawn.Resource != nil && drawn.Resource.Name() == theme.ConfirmIcon().Name() { tick = drawn diff --git a/internal/guard/openlist_test.go b/internal/guard/openlist_test.go index a1ee97b..75e076c 100644 --- a/internal/guard/openlist_test.go +++ b/internal/guard/openlist_test.go @@ -121,8 +121,16 @@ func TestTheOpenListMarksTheValueThatIsChosen(t *testing.T) { // other: this one reaches the values below the ceiling, which no picture // can show, and the picture reaches the drawing, which no list can promise. rows := list.Rows() - if len(rows) != len(picker.Options) { - t.Errorf("the list holds %d values and the menu offers %d", len(rows), len(picker.Options)) + // Values only: since 2026-09-23 the list of formats carries a heading + // over each kind, which is a row and not a value. + values := 0 + for _, row := range rows { + if row.Choosable { + values++ + } + } + if values != len(picker.Options) { + t.Errorf("the list holds %d values and the menu offers %d", values, len(picker.Options)) } marked := 0 for _, row := range rows { diff --git a/internal/guard/pointerfocus_test.go b/internal/guard/pointerfocus_test.go index 06f9370..4413691 100644 --- a/internal/guard/pointerfocus_test.go +++ b/internal/guard/pointerfocus_test.go @@ -50,8 +50,26 @@ func TestAPressMovesTheKeyboardWithoutDrawingItsMark(t *testing.T) { // a press and leaves focusing to the widget, and the switch left it. // On the list the press opened, which is where the arrows have to work. // Closing it hands the keyboard back to the menu (see Chooser.giveBack). - if list := menu.Opened(); list == nil || c.Focused() != fyne.Focusable(list) { - t.Errorf("the format menu was pressed and the keyboard is on %T, not on the list it opened", c.Focused()) + // + // "On the list" is where the list says the keyboard goes, since the list + // of 2026-09-23 took a filter: the box at its top has the keyboard and + // hands the arrows on (FilterBox.TypedKey). Asked of OpenList.Keyboard + // rather than of a type, and then the arrow is pressed on whatever has the + // keyboard - so the guard holds the promise, not the shape it took. + list := menu.Opened() + if list == nil { + t.Fatal("the format menu was pressed and opened no list") + } + if c.Focused() != list.Keyboard() { + t.Errorf("the format menu was pressed and the keyboard is on %T, not where the list it opened takes it (%T)", + c.Focused(), list.Keyboard()) + } + if focused := c.Focused(); focused != nil { + before := list.Active() + focused.TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown}) + if list.Active() == before { + t.Errorf("Down pressed on %T moved nothing in the list the press opened - the keyboard is somewhere the arrows do not work", focused) + } } // The list the press opened is taken away first. A real press respects what diff --git a/internal/guard/regressiontable_test.go b/internal/guard/regressiontable_test.go index 6527f8a..7e73162 100644 --- a/internal/guard/regressiontable_test.go +++ b/internal/guard/regressiontable_test.go @@ -50,7 +50,6 @@ var notYetJustified = []string{ "actionbar_test.go", "darkmenus_test.go", "doccomments_test.go", - "dropdown_test.go", "everyfield_test.go", "exeproperties_test.go", "foldedsections_test.go", diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index a3e2b95..93a3bef 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 17918d6..5450baf 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -1,7 +1,7 @@ - + - - + + @@ -376,12 +376,12 @@ - - - - + + + + Chooser - + @@ -538,11 +538,37 @@ + + + + + + every format, showing the kind of its value + + + + + + + + + + + zip + + + + + + + + + - + @@ -666,7 +692,7 @@ - + @@ -768,7 +794,7 @@ - + @@ -920,12 +946,12 @@ - - - - + + + + OpenList - + @@ -1324,11 +1350,399 @@ + + + + + + every format, under the heading of its kind, with a box to filter + + + + + + + + + + + + + + + + + + + Archives · 2 + + + + + + + targz + + + + + + + zip + + + + + + Documents · 4 + + + + + + + docx + + + + + + + pdf + + + + + + + pptx + + + + + + + xlsx + + + + + + Pictures · 10 + + + + + + + avif + + + + + + + bmp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + type to filter + + + + + + + + + + + + + + + + + + + + typed into, with the letters that matched in bold + + + + + + + + + + + + + + + + + + + + + p + ptx + + + + + + Pictures · 10 + + + + + + + avif + + + + + + + bm + p + + + + + + + + gif + + + + + + + ico + + + + + + + j + p + g + + + + + + + jxl + + + + + + + + + p + ng + + + + + + + svg + + + + + + + tiff + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + p + + + + + + + + + + + + + + + + + typed into, with nothing left + + + + + + + + + + + + + + + + + + + Nothing matches + + + + + + + + + + + + + + + zz + + + + + + + + + + + - + @@ -1514,7 +1928,7 @@ - + @@ -1980,7 +2394,7 @@ - + @@ -2226,7 +2640,7 @@ - + @@ -2368,7 +2782,7 @@ - + @@ -2488,7 +2902,7 @@ - + @@ -2583,7 +2997,7 @@ - + @@ -2638,7 +3052,7 @@ - + @@ -3217,7 +3631,7 @@ - + @@ -3633,7 +4047,7 @@ - + @@ -4159,7 +4573,7 @@ - + @@ -4239,7 +4653,7 @@ - + @@ -4947,7 +5361,7 @@ - + @@ -5655,7 +6069,7 @@ - + @@ -5800,7 +6214,7 @@ - + diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png index af20412..0aac1df 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 2b3178b..53378a3 100644 --- a/internal/guard/testdata/screens/generate-chosen-by-key.xml +++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml @@ -100,12 +100,13 @@ - + png + diff --git a/internal/guard/testdata/screens/generate-chosen.png b/internal/guard/testdata/screens/generate-chosen.png index 38030ae..45b9efa 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 c299131..59cab24 100644 --- a/internal/guard/testdata/screens/generate-chosen.xml +++ b/internal/guard/testdata/screens/generate-chosen.xml @@ -100,12 +100,13 @@ - + png + diff --git a/internal/guard/testdata/screens/generate-empty.png b/internal/guard/testdata/screens/generate-empty.png index 060010b..324100b 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 23aad1b..db2047c 100644 --- a/internal/guard/testdata/screens/generate-empty.xml +++ b/internal/guard/testdata/screens/generate-empty.xml @@ -100,12 +100,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png index 6ec51a4..8bdffd1 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 e278d7c..04c979f 100644 --- a/internal/guard/testdata/screens/generate-focused.xml +++ b/internal/guard/testdata/screens/generate-focused.xml @@ -100,12 +100,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/generate-hovered.png b/internal/guard/testdata/screens/generate-hovered.png index cf89094..48334c8 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 e7108ed..90c189a 100644 --- a/internal/guard/testdata/screens/generate-hovered.xml +++ b/internal/guard/testdata/screens/generate-hovered.xml @@ -100,12 +100,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index b50344e..0f5a4fc 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 73a1e2d..6cc8440 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -100,12 +100,13 @@ - + avif + @@ -496,270 +497,264 @@ - - - - - - - - - - - - - - - - avif + + + + + + + + + + + + + + + Archives · 2 + - - - - - - bmp + + + + + targz + - - - - - - csv + + + + + zip + - - - - - - docx + + + + Documents · 4 + - - - - - - gif + + + + + docx + - - - - - - html + + + + + pdf + - - - - - - ico + + + + + pptx + - - - - - - jpg + + + + + xlsx + - - - - - - json + + + + Pictures · 10 + - - - - - - jxl + + + + + + avif + - - - - - - log + + + + + bmp + - - - - - - md + + + + + gif + - - - - - - pdf + + + + + ico + - - - - - - png + + + + + jpg + - - - - - - pptx + + + + + jxl + - - - - - - svg + + + + + png + - - - - - - targz + + + + + svg + - - - - - - tiff + + + + + tiff + - - - - - - toml + + + + + webp + - - - - - - txt + + + + Sound · 1 + - - - - - - wav + + + + + wav + - - - - - - webp + + + + Text and data · 9 + - - - - - - xlsx + + - - - - - - xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - + + + + + + + + + + + type to filter + + + + + - + diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index bbb1520..5861cda 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 ceee587..200cfe0 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -100,12 +100,13 @@ - + avif + @@ -496,270 +497,264 @@ - - - - - - - - - - - - - - - - avif + + + + + + + + + + + + + + + Archives · 2 + - - - - - - bmp + + + + + targz + - - - - - - csv + + + + + zip + - - - - - - docx + + + + Documents · 4 + - - - - - - gif + + + + + docx + - - - - - - html + + + + + pdf + - - - - - - ico + + + + + pptx + - - - - - - jpg + + + + + xlsx + - - - - - - json + + + + Pictures · 10 + - - - - - - jxl + + + + + + avif + - - - - - - log + + + + + bmp + - - - - - - md + + + + + gif + - - - - - - pdf + + + + + ico + - - - - - - png + + + + + jpg + - - - - - - pptx + + + + + jxl + - - - - - - svg + + + + + png + - - - - - - targz + + + + + svg + - - - - - - tiff + + + + + tiff + - - - - - - toml + + + + + webp + - - - - - - txt + + + + Sound · 1 + - - - - - - wav + + + + + wav + - - - - - - webp + + + + Text and data · 9 + - - - - - - xlsx + + - - - - - - xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - + + + + + + + + + + + type to filter + + + + + - + diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index b8ed6b7..26d7d95 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 170f5a6..40fa0b6 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -100,12 +100,13 @@ - + avif + @@ -496,270 +497,264 @@ - - - - - - - - - - - - - - - - avif + + + + + + + + + + + + + + + Archives · 2 + - - - - - - bmp + + + + + targz + - - - - - - csv + + + + + zip + - - - - - - docx + + + + Documents · 4 + - - - - - - gif + + + + + docx + - - - - - - html + + + + + pdf + - - - - - - ico + + + + + pptx + - - - - - - jpg + + + + + xlsx + - - - - - - json + + + + Pictures · 10 + - - - - - - jxl + + + + + + avif + - - - - - - log + + + + + bmp + - - - - - - md + + + + + gif + - - - - - - pdf + + + + + ico + - - - - - - png + + + + + jpg + - - - - - - pptx + + + + + jxl + - - - - - - svg + + + + + png + - - - - - - targz + + + + + svg + - - - - - - tiff + + + + + tiff + - - - - - - toml + + + + + webp + - - - - - - txt + + + + Sound · 1 + - - - - - - wav + + + + + wav + - - - - - - webp + + + + Text and data · 9 + - - - - - - xlsx + + - - - - - - xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - + + + + + + + + + + + type to filter + + + + + - + diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png index d80a421..3d41439 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 1040631..a327525 100644 --- a/internal/guard/testdata/screens/generate-refused-both.xml +++ b/internal/guard/testdata/screens/generate-refused-both.xml @@ -100,12 +100,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png index 0fb0f78..aab0086 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 aa6f73e..696f034 100644 --- a/internal/guard/testdata/screens/generate-refused-setting.xml +++ b/internal/guard/testdata/screens/generate-refused-setting.xml @@ -100,12 +100,13 @@ - + png + diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png index 653e68e..9befb0b 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 bd9deb6..2b3bdfd 100644 --- a/internal/guard/testdata/screens/generate-refused.xml +++ b/internal/guard/testdata/screens/generate-refused.xml @@ -100,12 +100,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png index 65b6614..17a29dd 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 f70b1c0..4b17e1b 100644 --- a/internal/guard/testdata/screens/generate-switch-by-key.xml +++ b/internal/guard/testdata/screens/generate-switch-by-key.xml @@ -100,12 +100,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/generate-typed.png b/internal/guard/testdata/screens/generate-typed.png index 20cb884..6040746 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 c214095..e31c12d 100644 --- a/internal/guard/testdata/screens/generate-typed.xml +++ b/internal/guard/testdata/screens/generate-typed.xml @@ -100,12 +100,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/generate-unchecked.png b/internal/guard/testdata/screens/generate-unchecked.png index 93f2e94..b23799a 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 5fd5423..a3f14b0 100644 --- a/internal/guard/testdata/screens/generate-unchecked.xml +++ b/internal/guard/testdata/screens/generate-unchecked.xml @@ -100,12 +100,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png index b2ea022..c8eb13d 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 1ad1c75..6dd1326 100644 --- a/internal/guard/testdata/screens/generate.xml +++ b/internal/guard/testdata/screens/generate.xml @@ -100,12 +100,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png index 1dd604c..070f40f 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 c369f90..42fcc20 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -290,12 +290,13 @@ - + pdf + @@ -469,270 +470,264 @@ - - - - - - - - - - - - - - - avif + + + + + + + + + + + + + + + Archives · 2 + - - - - - - bmp + + + + + targz + - - - - - - csv + + + + + zip + - - - - - - docx + + + + Documents · 4 + - - - - - - gif + + + + + docx + - - - - - - html + + + + + + pdf + - - - - - - ico + + + + + pptx + - - - - - - jpg + + + + + xlsx + - - - - - - json + + + + Pictures · 10 + - - - - - - jxl + + + + + avif + - - - - - - log + + + + + bmp + - - - - - - md + + + + + gif + - - - - - - - pdf + + + + + ico + - - - - - - png + + + + + jpg + - - - - - - pptx + + + + + jxl + - - - - - - svg + + + + + png + - - - - - - targz + + + + + svg + - - - - - - tiff + + + + + tiff + - - - - - - toml + + + + + webp + - - - - - - txt + + + + Sound · 1 + - - - - - - wav + + + + + wav + - - - - - - webp + + + + Text and data · 9 + - - - - - - xlsx + + - - - - - - xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - + + + + + + + + + + + type to filter + + + + + - + diff --git a/internal/guard/testdata/screens/preset-refused.png b/internal/guard/testdata/screens/preset-refused.png index 90f677f..9e5d48d 100644 Binary files a/internal/guard/testdata/screens/preset-refused.png and b/internal/guard/testdata/screens/preset-refused.png differ diff --git a/internal/guard/testdata/screens/preset-refused.xml b/internal/guard/testdata/screens/preset-refused.xml index 7beea65..9b359ba 100644 --- a/internal/guard/testdata/screens/preset-refused.xml +++ b/internal/guard/testdata/screens/preset-refused.xml @@ -301,12 +301,13 @@ - + pdf + diff --git a/internal/guard/testdata/screens/recipe-contents.png b/internal/guard/testdata/screens/recipe-contents.png index 0e6370b..c4881f7 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 b76371e..4687fb4 100644 --- a/internal/guard/testdata/screens/recipe-contents.xml +++ b/internal/guard/testdata/screens/recipe-contents.xml @@ -174,12 +174,13 @@ - + zip + diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.png b/internal/guard/testdata/screens/recipe-on-a-preset.png index daf21b5..c836a26 100644 Binary files a/internal/guard/testdata/screens/recipe-on-a-preset.png and b/internal/guard/testdata/screens/recipe-on-a-preset.png differ diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.xml b/internal/guard/testdata/screens/recipe-on-a-preset.xml index cf853bd..11adb13 100644 --- a/internal/guard/testdata/screens/recipe-on-a-preset.xml +++ b/internal/guard/testdata/screens/recipe-on-a-preset.xml @@ -238,12 +238,13 @@ - + avif + 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 16674ca..cd31795 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 6120a32..6f6b4f8 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 @@ -179,12 +179,13 @@ - + avif + @@ -505,12 +506,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/recipe-refused.png b/internal/guard/testdata/screens/recipe-refused.png index e37fe56..8d256ae 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 53654ec..d159e50 100644 --- a/internal/guard/testdata/screens/recipe-refused.xml +++ b/internal/guard/testdata/screens/recipe-refused.xml @@ -174,12 +174,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/recipe-two-batches.png b/internal/guard/testdata/screens/recipe-two-batches.png index 15cde6f..7b1d049 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 4e2d988..d12eabf 100644 --- a/internal/guard/testdata/screens/recipe-two-batches.xml +++ b/internal/guard/testdata/screens/recipe-two-batches.xml @@ -179,12 +179,13 @@ - + avif + @@ -472,12 +473,13 @@ - + avif + diff --git a/internal/guard/testdata/screens/recipe.png b/internal/guard/testdata/screens/recipe.png index b30c21d..8267e89 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 3532cfd..d824209 100644 --- a/internal/guard/testdata/screens/recipe.xml +++ b/internal/guard/testdata/screens/recipe.xml @@ -174,12 +174,13 @@ - + avif + diff --git a/internal/gui/catalogue/controls.go b/internal/gui/catalogue/controls.go index ca3ed57..feebc6b 100644 --- a/internal/gui/catalogue/controls.go +++ b/internal/gui/catalogue/controls.go @@ -5,6 +5,7 @@ import ( "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" + "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" ) @@ -126,6 +127,14 @@ func chooser() Entry { c.SetSelected(longText) return parts.Menu(c) }}, + {"every format, showing the kind of its value", func() fyne.CanvasObject { + // The one menu that draws a picture in the shut box - see + // menuLook.placeKind - and the widest, because its open list + // carries headings, a filter and letters in bold. + c := parts.NewChooser(format.IDs(), func(string) {}) + c.SetSelected("zip") + return parts.Menu(c) + }}, }} } diff --git a/internal/gui/catalogue/lists.go b/internal/gui/catalogue/lists.go index 8bd9bbf..0f9b6d8 100644 --- a/internal/gui/catalogue/lists.go +++ b/internal/gui/catalogue/lists.go @@ -4,6 +4,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/driver/desktop" + "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" ) @@ -32,7 +33,7 @@ func openList() Entry { // value the state is given, like the words above, not a look. Tall enough // for a few rows of the list and not for all twelve. const shortWindow = 240 - return Entry{Name: "OpenList", Covers: []string{"ListRow", "KindOfFile"}, Natural: true, States: []State{ + return Entry{Name: "OpenList", Covers: []string{"ListRow", "KindOfFile", "FilterBox", "KindHeading"}, Natural: true, States: []State{ {"a few values, one chosen", func() fyne.CanvasObject { return asWideAsItsBox(few, parts.NewOpenList(few, "jpg", func(string, bool) {}, func(bool) {})) }}, @@ -56,9 +57,39 @@ func openList() Entry { values := []string{longText, "png"} return asWideAsItsBox(values, parts.NewOpenList(values, "png", func(string, bool) {}, func(bool) {})) }}, + {"every format, under the heading of its kind, with a box to filter", func() fyne.CanvasObject { + return everyFormat("") + }}, + {"typed into, with the letters that matched in bold", func() fyne.CanvasObject { + return everyFormat("p") + }}, + {"typed into, with nothing left", func() fyne.CanvasObject { + return everyFormat("zz") + }}, }} } +// everyFormat is the list the format menu drops down - every format, grouped, +// with its filter - holding what was typed into the filter. As tall as a short +// window lets it be, the way the form's own list is. +func everyFormat(typed string) fyne.CanvasObject { + ids := format.IDs() + l := parts.NewOpenList(ids, "png", func(string, bool) {}, func(bool) {}) + l.KindOf = parts.KindOfFile + l.GroupUnder(parts.KindHeading) + l.WithFilter() + l.LimitTo(parts.ListCeiling(everyFormatWindow)) + if typed != "" { + l.Filter().SetText(typed) + } + return asWideAsItsBox(ids, l) +} + +// everyFormatWindow is the height of the window the list of every format +// stands in here - a value the state is given, like the words above, not a +// look. Tall enough to show a few headings and the rows under them. +const everyFormatWindow = 640 + // asWideAsItsBox gives an open list the width of the menu it would drop from: // the box a Chooser of the same values is given by parts.Menu, which is as // wide as the widest value plus the arrow. diff --git a/internal/gui/parts/filekind.go b/internal/gui/parts/filekind.go index 5fa017d..f22bce7 100644 --- a/internal/gui/parts/filekind.go +++ b/internal/gui/parts/filekind.go @@ -3,6 +3,8 @@ package parts import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/theme" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) // KindOfFile is the picture drawn in front of a format in a menu. @@ -13,12 +15,15 @@ import ( // abbreviations, and eight of the twenty fit on the screen at once. Reported in // the design audit of 2026-08-20. // -// A picture rather than a grouping, and that is a constraint rather than a -// preference. The order of a closed set is a written rule with a guard behind -// it - one order in every surface, so the menu, "tfg formats" and the wording -// of a refusal cannot describe one format three ways. Grouping the menu would -// break that. An icon changes nothing about the order and nothing about the -// height of a row. +// The list is grouped by these kinds as well since 2026-09-23, under a heading +// each (KindHeading), and that reverses a sentence this comment used to carry: +// "grouping the menu would break one order in every surface". It would not, +// and the owner decided so when the formats reached twenty six. The order that +// rule protects is the REGISTERED order - the registry, "tfg formats", its +// JSON and the wording of a refusal all keep one alphabetical order, held by +// TestEveryClosedSetIsRegisteredInOrder - and nothing here touches it. What +// changes is where the window draws a value, which is presentation (D1 is +// about what a surface can DO). Recorded in docs/FORMAT-MENU-2026-09-23.md. // // The toolkit's own file icons were tried first and are useless for this: at // the size a row draws them, FileImageIcon, FileTextIcon and DocumentIcon are @@ -44,7 +49,13 @@ func KindOfFile(id string) fyne.Resource { case kindDocument: return theme.DocumentIcon() case kindArchive: - return theme.StorageIcon() + // A folder, because an archive is a thing that holds other files. + // It was StorageIcon until 2026-09-23, and on the render that is + // three stacked bars - the same shape as ListIcon, which the text + // formats draw, so targz read as one more kind of text. The guard + // compared the two by NAME and stayed green, which is why it now + // compares the pixels. + return theme.FolderIcon() case kindSound: return theme.MediaMusicIcon() case kindMoving: @@ -59,6 +70,33 @@ func KindOfFile(id string) fyne.Resource { return nil } +// KindHeading is the heading a format stands under in an open list, or the +// empty string for a format whose kind nobody declared - which the list draws +// with no heading rather than under a made up one. +// +// The same switch as KindOfFile rather than a second table, so a kind cannot +// have a picture and no heading, or the other way round: a kind added to the +// type reddens the exhaustive switch in both places at once. +func KindHeading(id string) string { + switch fileKinds[id] { + case kindPicture: + return text.ListKindPictures() + case kindDocument: + return text.ListKindDocuments() + case kindArchive: + return text.ListKindArchives() + case kindSound: + return text.ListKindSound() + case kindMoving: + return text.ListKindVideo() + case kindWords: + return text.ListKindText() + case kindUnknown: + // See the same case in KindOfFile. + } + return "" +} + type fileKind int const ( diff --git a/internal/gui/parts/filterbox.go b/internal/gui/parts/filterbox.go new file mode 100644 index 0000000..32f89c7 --- /dev/null +++ b/internal/gui/parts/filterbox.go @@ -0,0 +1,73 @@ +package parts + +import ( + "unicode" + + "fyne.io/fyne/v2" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// FilterBox is the box at the top of an open list of formats that narrows it +// to the formats holding what was typed. +// +// Inside the open list and never in place of the closed box, and that is the +// answer to the objection that deferred it on 2026-08-25: a filter is a box to +// type in, and the closed menu had just been made to look unlike one. The +// closed menu still looks like a menu. What changed is the list it opens, +// twenty six formats long on 2026-09-23 and eighteen of them in sight - see +// docs/FORMAT-MENU-2026-09-23.md. +// +// It follows the ARIA pattern for a combobox whose list is filtered by an +// editable box: the keyboard stays in the box, the arrows move through the +// list, Enter takes the value the list is on and Escape closes. Home and End +// stay the box's own, because in a box to type in they move the caret. +type FilterBox struct { + Entry + + list *OpenList +} + +func newFilterBox(l *OpenList) *FilterBox { + f := &FilterBox{list: l} + f.PlaceHolder = text.PlaceholderFilter() + f.OnChanged = l.narrowTo + f.ExtendBaseWidget(f) + return f +} + +// TypedKey hands the list the keys that move through it, take and close, and +// keeps the rest - letters arrive through TypedRune and never come here. +func (f *FilterBox) TypedKey(event *fyne.KeyEvent) { + if event == nil { + return + } + switch event.Name { + case fyne.KeyUp, fyne.KeyDown, fyne.KeyReturn, fyne.KeyEnter, fyne.KeyEscape: + f.list.TypedKey(event) + return + } + f.Entry.Entry.TypedKey(event) +} + +// TypedRune drops white space typed into an empty box, and takes everything +// else as the box it is. +// +// The case it is for is the Space that OPENS the list. The driver hands the +// key to whatever has the keyboard and then the character to whatever has it +// after that - and the key is what moved the keyboard here (Chooser.TypedKey +// opens the list, the list hands the keyboard to this box). So the character +// of the same press landed in the box: no placeholder, the caret one space in, +// and nothing on screen saying why. Reported by an outside review of #127, +// seen in the real window, held by +// TestTheSpaceThatOpensTheFormatListIsNotTypedIntoItsFilter. +// +// Only while the box is empty, because a space after a word is somebody +// typing - and a leading one means nothing to the list anyway, which trims +// what it narrows by (narrowTo). +func (f *FilterBox) TypedRune(r rune) { + if f.Text == "" && unicode.IsSpace(r) { + return + } + f.Entry.TypedRune(r) +} diff --git a/internal/gui/parts/listcontents.go b/internal/gui/parts/listcontents.go new file mode 100644 index 0000000..d874e5d --- /dev/null +++ b/internal/gui/parts/listcontents.go @@ -0,0 +1,106 @@ +package parts + +import ( + "fyne.io/fyne/v2/widget" +) + +// listContents is what an open list holds and what it has drawn: the values, +// how they are grouped and narrowed, the rows that arrangement comes to, and +// the rows the toolkit has actually built for it. +// +// Its own type rather than more of OpenList, since the list of 2026-09-23 +// took headings and a filter. OpenList stood at 24 methods, the fourth type in +// the tree past the crowding line, and TestNoSecondTypeIsCreepingUpOnTheTypeCeilings +// asks for behaviour to move out rather than for the line to move. What moved +// is one question - what is in this list, and what does each row draw - which +// the widget, the keyboard and the layout only ask. OpenList embeds it, so a +// guard still reads list.Rows() and list.DrawnRows() where it always did. +type listContents struct { + options []string + // chosen is the value in the box, marked with a tick. Empty when the box + // shows a default nobody has confirmed - see the note in preset.go about a + // filled field making "I did not say" impossible to express. + chosen string + + // headingOf is the heading a value stands under, or nil for a list with no + // headings. See OpenList.GroupUnder. + headingOf func(string) string + // typed is what is in the filter box, or nothing for a list without one. + typed string + // entries is what the list draws now - headings, values and the notice + // that nothing matched - worked out by arrange. Every row number in this + // package is a position in entries, never in options, because the two part + // the moment a list has a heading. + entries []listEntry + + // rows is the row showing each position, recorded as the list fills them. + // + // A registry rather than a walk, because a walk cannot get in: widget.List + // keeps the rows it built inside its renderer, so a tree walk stops at the + // list and reports an open list with nothing in it. Measured on 2026-08-18 + // while trying to photograph a row under the pointer. + // + // Every entry is current whatever the list has scrolled past, because a + // recycled row is refilled before it is shown and fill is what writes here. + rows map[widget.ListItemID]*ListRow +} + +// rearrange works out the rows again after the filter or the grouping +// changed, and forgets the rows it recorded: a row built for the old +// arrangement can still hold a label the list no longer draws, and +// RowShowing would report it. +func (c *listContents) rearrange() { + c.entries = arrange(c.options, c.headingOf, c.typed) + c.rows = map[widget.ListItemID]*ListRow{} +} + +// Rows is what this list is showing, for a guard to read, in the order it is +// drawn - headings and the notice included, so that a position from Active +// is a position here. +// +// The toolkit's menu turned items into widgets of an unexported type, so what +// was marked could not be read back off the canvas - only that something was +// open. This is the half of that pair we own, and it says what is in the list +// and which row carries the tick. +func (c *listContents) Rows() []Choice { + out := make([]Choice, 0, len(c.entries)) + for _, e := range c.entries { + value := e.kind == entryValue + out = append(out, Choice{Label: e.text, Marked: value && c.isChosen(e.text), Choosable: value}) + } + return out +} + +// isChosen says whether a value is the one in the box. +// +// One function rather than the same comparison written where a row is filled +// and again where the list reports itself. Two copies is what it was for an +// hour on 2026-08-18, and the mutation runner said so at once: blanking the +// drawn mark left the guard green, because the guard was reading the other +// copy. A rule with two homes is a rule no test can pin down. +func (c *listContents) isChosen(value string) bool { return value == c.chosen } + +// DrawnRows is every row the list has actually built, for a guard that has to +// ask what is on the screen rather than what the list holds. +// +// Rows above answers from the options, which is the right half for "what is in +// this list" and the wrong half for "what does a row draw". A picture that +// never reaches a row would pass the first and fail this one. +func (c *listContents) DrawnRows() []*ListRow { + out := make([]*ListRow, 0, len(c.rows)) + for _, row := range c.rows { + out = append(out, row) + } + return out +} + +// RowShowing is the row currently drawing one value, or nil if that value is +// scrolled out of sight. For a guard that needs to press or hover a real row. +func (c *listContents) RowShowing(label string) *ListRow { + for _, row := range c.rows { + if row.Label() == label { + return row + } + } + return nil +} diff --git a/internal/gui/parts/listrow.go b/internal/gui/parts/listrow.go index 363eebd..ee31ca5 100644 --- a/internal/gui/parts/listrow.go +++ b/internal/gui/parts/listrow.go @@ -32,6 +32,13 @@ type ListRow struct { // kind is the picture drawn in front of the words, or nil for a list whose // values are not things of different kinds. kind fyne.Resource + // heading says this row names the kind of the values under it, or says + // that the filter left nothing. It draws its words and nothing else, and + // answers neither the pointer nor a press. + heading bool + // from and to are where what was typed into the list's filter stands in + // the words, drawn in bold. Equal when nothing is to be bold. + from, to int hovered bool } @@ -53,6 +60,10 @@ func (r *ListRow) Marked() bool { return r.marked } // Nil where the list does not sort its values into kinds. func (r *ListRow) Kind() fyne.Resource { return r.kind } +// Heading says whether this row is a heading or the notice that nothing +// matched, rather than a value somebody can take - for a guard. +func (r *ListRow) Heading() bool { return r.heading } + func newListRow() *ListRow { r := &ListRow{} r.ExtendBaseWidget(r) @@ -65,7 +76,12 @@ func (r *ListRow) Tapped(*fyne.PointEvent) { } } +// MouseIn lights a value up under the pointer. A heading stays dark: lit, it +// would say a press there takes something. func (r *ListRow) MouseIn(*desktop.MouseEvent) { + if r.heading { + return + } r.hovered = true r.Refresh() } @@ -84,17 +100,26 @@ func (r *ListRow) CreateRenderer() fyne.WidgetRenderer { kind := canvas.NewImageFromResource(nil) label := canvas.NewText("", Theme().Color(theme.ColorNameForeground, theme.VariantDark)) label.TextSize = Theme().Size(theme.SizeNameText) - rr := &listRowRenderer{row: r, back: back, tick: tick, kind: kind, label: label} + strong := canvas.NewText("", Theme().Color(theme.ColorNameForeground, theme.VariantDark)) + strong.TextStyle = fyne.TextStyle{Bold: true} + rest := canvas.NewText("", Theme().Color(theme.ColorNameForeground, theme.VariantDark)) + rr := &listRowRenderer{row: r, back: back, tick: tick, kind: kind, label: label, strong: strong, rest: rest} rr.Refresh() return rr } +// listRowRenderer draws a row's words as up to three pieces: label holds all +// of them, or only what comes before the part that matched the filter, which +// strong draws in bold, with rest after it. One text when nothing matched, so +// a list nobody typed into draws exactly what it drew before the filter. type listRowRenderer struct { - row *ListRow - back *canvas.Rectangle - tick *canvas.Image - kind *canvas.Image - label *canvas.Text + row *ListRow + back *canvas.Rectangle + tick *canvas.Image + kind *canvas.Image + label *canvas.Text + strong *canvas.Text + rest *canvas.Text } func (r *listRowRenderer) Layout(size fyne.Size) { @@ -124,6 +149,18 @@ func (r *listRowRenderer) Layout(size fyne.Size) { // Either way the row is the same width for a chosen value as for any // other, because the tick's column is kept in both shapes. left, right := float32(rowGutter), float32(rowGutter) + if r.row.heading { + // A heading is its words at the gutter, as wide as the row. It has no + // tick column: nothing under it is chosen, and a column kept empty in + // front of a heading would move it off the edge the values' ticks + // stand on. + r.tick.Move(fyne.NewPos(left, (size.Height-icon)/2)) + r.kind.Resize(fyne.NewSquareSize(0)) + text := r.label.MinSize() + r.label.Move(fyne.NewPos(left, (size.Height-text.Height)/2)) + r.label.Resize(fyne.NewSize(size.Width-left-right, text.Height)) + return + } if r.row.kind != nil { r.tick.Move(fyne.NewPos(left, (size.Height-icon)/2)) left += icon + rowGap @@ -136,13 +173,79 @@ func (r *listRowRenderer) Layout(size fyne.Size) { r.kind.Resize(fyne.NewSquareSize(0)) } + r.placeWords(left, size.Width-left-right, size.Height) +} + +// placeWords lays the pieces of the words end to end from left, the last of +// them taking what is left of the row. +func (r *listRowRenderer) placeWords(left, room, height float32) { text := r.label.MinSize() - r.label.Move(fyne.NewPos(left, (size.Height-text.Height)/2)) - r.label.Resize(fyne.NewSize(size.Width-left-right, text.Height)) + y := (height - text.Height) / 2 + if !r.strong.Visible() { + r.label.Move(fyne.NewPos(left, y)) + r.label.Resize(fyne.NewSize(room, text.Height)) + return + } + before, match := text.Width, r.strong.MinSize().Width + r.label.Move(fyne.NewPos(left, y)) + r.label.Resize(fyne.NewSize(before, text.Height)) + r.strong.Move(fyne.NewPos(left+before, y)) + r.strong.Resize(fyne.NewSize(match, text.Height)) + r.rest.Move(fyne.NewPos(left+before+match, y)) + r.rest.Resize(fyne.NewSize(fyne.Max(0, room-before-match), text.Height)) +} + +// splitWords cuts the words where the filter matched them, or leaves them +// whole. A span that does not fit the words - a row refilled with a shorter +// value - is no span, rather than a slice out of range. +func (r *listRowRenderer) splitWords() { + words, from, to := r.row.label, r.row.from, r.row.to + if r.row.heading || from < 0 || to <= from || to > len(words) { + r.strong.Text, r.rest.Text = "", "" + r.strong.Hide() + r.rest.Hide() + return + } + r.label.Text, r.strong.Text, r.rest.Text = words[:from], words[from:to], words[to:] + for _, piece := range []*canvas.Text{r.strong, r.rest} { + piece.Color = r.label.Color + piece.TextSize = r.label.TextSize + piece.Show() + } +} + +// wordsWidth is how wide the words are drawn, all pieces together. +func (r *listRowRenderer) wordsWidth() float32 { + width := r.label.MinSize().Width + if r.strong.Visible() { + width += r.strong.MinSize().Width + r.rest.MinSize().Width + } + return width } func (r *listRowRenderer) MinSize() fyne.Size { - return fyne.NewSize(RowWidthFor(r.label.MinSize().Width, r.row.kind != nil), ListRowHeight()) + if r.row.heading { + return fyne.NewSize(HeadingRowWidthFor(r.label.MinSize().Width), ListRowHeight()) + } + return fyne.NewSize(RowWidthFor(r.wordsWidth(), r.row.kind != nil), ListRowHeight()) +} + +// headingText and headingStyle are how a heading in an open list is drawn: +// the caption size, in bold. Named once, because the menu that opens the list +// measures a heading with them to know how wide the box has to be. +const headingText = TextCaption + +var headingStyle = fyne.TextStyle{Bold: true} + +// HeadingRowWidthFor is the room a heading row needs for words that wide: +// the words between two gutters and nothing else. +func HeadingRowWidthFor(words float32) float32 { + return rowGutter + words + rowGutter +} + +// headingWidth is how wide one heading's words are drawn. +func headingWidth(heading string) float32 { + return fyne.MeasureText(heading, headingText, headingStyle).Width } // RowWidthFor is the room one row of an open list needs for a word that wide. @@ -181,8 +284,20 @@ func (r *listRowRenderer) Refresh() { r.kind.Resource = r.row.kind r.label.Color = Theme().Color(theme.ColorNameForeground, theme.VariantDark) r.label.TextSize = Theme().Size(theme.SizeNameText) + r.label.TextStyle = fyne.TextStyle{} + if r.row.heading { + // The look a field's name has - a step quieter than a value - in + // bold at the caption size, so a heading reads as the name over a + // group and not as one more value to take. + r.label.Color = PaletteColour(ColorNameLabel, theme.VariantDark) + r.label.TextSize = headingText + r.label.TextStyle = headingStyle + } + r.splitWords() switch { + case r.row.heading: + r.back.FillColor = color.Transparent case r.row.active: r.back.FillColor = Theme().Color(theme.ColorNameSelection, theme.VariantDark) case r.row.hovered: @@ -205,14 +320,14 @@ func (r *listRowRenderer) Refresh() { r.kind.Hide() } - redraw(r.back, r.tick, r.kind, r.label) + redraw(r.back, r.tick, r.kind, r.label, r.strong, r.rest) // The width a row asks for changes with the picture, and the row is laid // out by the list rather than by this renderer. r.Layout(r.row.Size()) } func (r *listRowRenderer) Objects() []fyne.CanvasObject { - return []fyne.CanvasObject{r.back, r.tick, r.kind, r.label} + return []fyne.CanvasObject{r.back, r.tick, r.kind, r.label, r.strong, r.rest} } func (r *listRowRenderer) Destroy() {} diff --git a/internal/gui/parts/menulook.go b/internal/gui/parts/menulook.go index aaa4d22..6e90849 100644 --- a/internal/gui/parts/menulook.go +++ b/internal/gui/parts/menulook.go @@ -2,6 +2,7 @@ package parts import ( "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -25,20 +26,96 @@ import ( // is put back after every refresh, because the toolkit's refresh sets the // resource it chose again. A disabled menu keeps the toolkit's disabled arrow, // so a menu frozen for a run does not look live. +// +// A menu whose values are kinds of file also draws the picture of its value +// in front of the word, the same picture the value's row carries in the open +// list. Until 2026-09-23 the closed box said "avif" and nothing else, so the +// kind of the file being made was on the screen only while the list was open. func (c *Chooser) CreateRenderer() fyne.WidgetRenderer { look := &menuLook{WidgetRenderer: c.Select.CreateRenderer(), menu: c} + look.objects = look.WidgetRenderer.Objects() + if c.KindOf != nil { + look.kind = canvas.NewImageFromResource(nil) + look.kind.FillMode = canvas.ImageFillContain + look.objects = append(append([]fyne.CanvasObject{}, look.objects...), look.kind) + } look.accent() + look.showKind() return look } type menuLook struct { fyne.WidgetRenderer menu *Chooser + // kind is the picture of the value in the box, nil on a menu without + // pictures, and objects is the toolkit's objects with it added. + kind *canvas.Image + objects []fyne.CanvasObject +} + +func (m *menuLook) Objects() []fyne.CanvasObject { return m.objects } + +// Layout lets the toolkit place its objects and then makes room for the +// picture. After, because the toolkit's Layout puts the value back where it +// keeps it every time it runs. +func (m *menuLook) Layout(size fyne.Size) { + m.WidgetRenderer.Layout(size) + m.placeKind(size) } func (m *menuLook) Refresh() { + // The toolkit's Refresh lays itself out again, which moves the value back + // under the picture - so the picture's room is made again after it. m.WidgetRenderer.Refresh() m.accent() + m.showKind() + m.placeKind(m.menu.Size()) +} + +// showKind puts the picture of the value now in the box into the box, or +// hides it when the value has none. +func (m *menuLook) showKind() { + if m.kind == nil { + return + } + m.kind.Resource = m.menu.KindOf(m.menu.Selected) + if m.kind.Resource == nil { + m.kind.Hide() + } else { + m.kind.Show() + } + m.kind.Refresh() +} + +// placeKind stands the picture where the toolkit starts the value's words and +// moves the words along by the picture and a gap. The words are found by type, +// the way accent finds the arrow: the toolkit's renderer holds exactly one +// RichText (fyne v2.8.1 widget/select.go, CreateRenderer). +// +// Placed by the toolkit's own arithmetic rather than by moving whatever is +// there, so running it twice gives the same picture: the words start at the +// padding and end at the arrow, which stands the inner padding in from the +// right edge (selectRenderer.Layout, same file), and the words draw their +// own inset of the padding inside that. +func (m *menuLook) placeKind(size fyne.Size) { + if m.kind == nil || !m.kind.Visible() { + return + } + icon := Theme().Size(theme.SizeNameInlineIcon) + pad := Theme().Size(theme.SizeNamePadding) + arrow := size.Width - icon - Theme().Size(theme.SizeNameInnerPadding) + for _, o := range m.WidgetRenderer.Objects() { + words, ok := o.(*widget.RichText) + if !ok { + continue + } + m.kind.Resize(fyne.NewSquareSize(icon)) + m.kind.Move(fyne.NewPos(2*pad, (size.Height-icon)/2)) + shift := icon + rowGap + words.Move(fyne.NewPos(pad+shift, words.Position().Y)) + words.Resize(fyne.NewSize(arrow-pad-shift, words.Size().Height)) + return + } } // accent colours the arrow, unless the menu is disabled. diff --git a/internal/gui/parts/narrow.go b/internal/gui/parts/narrow.go new file mode 100644 index 0000000..85078fc --- /dev/null +++ b/internal/gui/parts/narrow.go @@ -0,0 +1,192 @@ +package parts + +import ( + "sort" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// What an open list draws, worked out without the toolkit. +// +// Kept apart from OpenList so that the rules - which values a typed filter +// keeps, which heading a value stands under, where the keyboard lands - can be +// asked directly, and so that the widget is only the drawing of an answer made +// here (GUI rule 15). Nothing in this file knows what a row looks like. + +// entryKind says what one row of an open list is. +type entryKind int + +const ( + // entryValue is a value somebody can choose. + entryValue entryKind = iota + // entryHeading names the kind of the values under it. Nobody chooses it, + // and the keyboard steps over it. + entryHeading + // entryNotice says the filter left nothing. Nobody chooses it either - it + // is there so an empty list reads as an answer rather than as a fault. + entryNotice +) + +// listEntry is one row of an open list. On a value, from and to are where +// what was typed stands in its words, drawn in bold - equal when nothing in +// the words matched, which is a value kept for the heading it stands under. +type listEntry struct { + kind entryKind + text string + from, to int +} + +// arrange is what an open list draws: the values the typed text keeps, each +// under the heading of its kind when the list has kinds. +// +// Headings are in the order of their own words, which is the rule every closed +// set in this window follows (TestEveryClosedSetIsRegisteredInOrder) rather +// than an order somebody preferred - and it is the order of the words on the +// screen, so a translation reorders them with it. The values under a heading +// keep the order they were given in, which for formats is the registry's. +// +// A heading with nothing under it is not drawn: a kind with no format yet, or +// one the filter emptied. A value whose heading is empty stands first, with no +// heading over it, rather than under a heading made up here. +func arrange(values []string, headingOf func(string) string, typed string) []listEntry { + kept := narrow(values, headingOf, typed) + if len(kept) == 0 { + if strings.TrimSpace(typed) == "" { + return nil + } + return []listEntry{{kind: entryNotice, text: text.ListNothingMatches()}} + } + if headingOf == nil { + out := make([]listEntry, 0, len(kept)) + for _, v := range kept { + out = append(out, valueEntry(v, typed)) + } + return out + } + + groups := map[string][]string{} + headings := []string{} + for _, v := range kept { + h := headingOf(v) + if _, seen := groups[h]; !seen { + headings = append(headings, h) + } + groups[h] = append(groups[h], v) + } + sort.Strings(headings) + + out := make([]listEntry, 0, len(kept)+len(headings)) + for _, h := range headings { + if h != "" { + out = append(out, listEntry{kind: entryHeading, text: text.ListHeadingCount(h, len(groups[h]))}) + } + for _, v := range groups[h] { + out = append(out, valueEntry(v, typed)) + } + } + return out +} + +// valueEntry is one value's row, with what was typed found in its words. +// Found only where lowering the words keeps their length, so the span marks +// the same letters in the words as drawn - true of every format name, and a +// value for which it is not simply gets no bold. +func valueEntry(v, typed string) listEntry { + e := listEntry{kind: entryValue, text: v} + want := strings.ToLower(strings.TrimSpace(typed)) + lower := strings.ToLower(v) + if want == "" || len(lower) != len(v) { + return e + } + if at := strings.Index(lower, want); at >= 0 { + e.from, e.to = at, at+len(want) + } + return e +} + +// narrow keeps the values holding what was typed, in the order they came in. +// +// Anywhere in the value rather than only at its start, and without regard to +// case, so "gz" finds targz and "X" finds docx, pptx and xlsx. Where the +// keyboard lands is a separate and stricter question - see landing. Space +// around what was typed is not part of it: a filter holding only a space keeps +// everything rather than nothing. +// +// A value is kept for its heading as well - "pict" keeps every picture - but +// only where a WORD of the heading starts with what was typed. Anywhere in the +// heading, one letter would keep nearly every kind: "t" is in Pictures, +// Documents, Text and data. Decided by the owner on 2026-09-23. +func narrow(values []string, headingOf func(string) string, typed string) []string { + want := strings.ToLower(strings.TrimSpace(typed)) + if want == "" { + return values + } + out := make([]string, 0, len(values)) + for _, v := range values { + if strings.Contains(strings.ToLower(v), want) || (headingOf != nil && aWordStartsWith(headingOf(v), want)) { + out = append(out, v) + } + } + return out +} + +// landing is the row the keyboard goes to after something was typed: the +// first value STARTING with it, and failing that the first value the filter +// kept, and -1 when there is none. +// +// Two rules rather than the first match, because the first match in a list +// grouped by kind is often the wrong one: "p" keeps zip under Archives before +// pdf under Documents, and somebody typing p means a value that starts with p. +func landing(entries []listEntry, typed string) int { + want := strings.ToLower(strings.TrimSpace(typed)) + first := -1 + for i, e := range entries { + if e.kind != entryValue { + continue + } + if first < 0 { + first = i + } + if want != "" && strings.HasPrefix(strings.ToLower(e.text), want) { + return i + } + } + return first +} + +// nextValue is the value row after from in the direction step (+1 or -1), +// stepping over headings, or from itself when there is none that way - a list +// that stops at its ends rather than wrapping, see OpenList.moveTo. +func nextValue(entries []listEntry, from, step int) int { + for at := from + step; at >= 0 && at < len(entries); at += step { + if entries[at].kind == entryValue { + return at + } + } + return from +} + +// edgeValue is the first value row from one end: the top for step +1, the +// bottom for step -1. -1 when the list holds no value at all. +func edgeValue(entries []listEntry, step int) int { + start := -1 + if step < 0 { + start = len(entries) + } + if at := nextValue(entries, start, step); at != start { + return at + } + return -1 +} + +// aWordStartsWith says whether a word of a heading starts with what was typed, +// which is already lower case. +func aWordStartsWith(heading, want string) bool { + for _, word := range strings.Fields(strings.ToLower(heading)) { + if strings.HasPrefix(word, want) { + return true + } + } + return false +} diff --git a/internal/gui/parts/openlist.go b/internal/gui/parts/openlist.go index ff69894..934fedb 100644 --- a/internal/gui/parts/openlist.go +++ b/internal/gui/parts/openlist.go @@ -7,6 +7,7 @@ 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" ) @@ -38,14 +39,13 @@ import ( type OpenList struct { widget.BaseWidget - options []string + // listContents is what the list holds and what it has drawn - see + // listcontents.go for why it is a type of its own. + listContents + // room is how much height the window has left for this list, or nought for // no limit. See LimitTo and MinSize. room float32 - // chosen is the value in the box, marked with a tick. Empty when the box - // shows a default nobody has confirmed - see the note in preset.go about a - // filled field making "I did not say" impossible to express. - chosen string // active is the row the keyboard is on, or -1 when it has not been used, // and shown is whether that is drawn. // @@ -62,30 +62,25 @@ type OpenList struct { close func(byKeyboard bool) list *widget.List - // rows is the row showing each position, recorded as the list fills them. - // - // A registry rather than a walk, because a walk cannot get in: widget.List - // keeps the rows it built inside its renderer, so a tree walk stops at the - // list and reports an open list with nothing in it. Measured on 2026-08-18 - // while trying to photograph a row under the pointer. - // - // Every entry is current whatever the list has scrolled past, because a - // recycled row is refilled before it is shown and fill is what writes here. - rows map[widget.ListItemID]*ListRow // KindOf says what picture goes in front of one value, or nil for a list // whose values are not things of different kinds. Set from outside, because // only the screen putting values in knows what they are. KindOf func(string) fyne.Resource + + // filter is the box at the top that narrows the list, or nil for a list + // without one. What is typed into it is listContents.typed. See WithFilter. + filter *FilterBox } // NewOpenList builds the list. take is called with the value somebody settled // on, and close when they left without settling on one. func NewOpenList(options []string, chosen string, take func(string, bool), close func(bool)) *OpenList { - l := &OpenList{options: options, chosen: chosen, active: -1, take: take, close: close, - rows: map[widget.ListItemID]*ListRow{}} + l := &OpenList{active: -1, take: take, close: close, + listContents: listContents{options: options, chosen: chosen, rows: map[widget.ListItemID]*ListRow{}}} + l.entries = arrange(options, nil, "") l.list = widget.NewList( - func() int { return len(l.options) }, + func() int { return len(l.entries) }, func() fyne.CanvasObject { return newListRow() }, l.fill, ) @@ -93,79 +88,99 @@ func NewOpenList(options []string, chosen string, take func(string, bool), close return l } -// fill puts one option into one row. The row it is given is recycled, so every -// field is set every time - a row left holding the last value it had is the -// classic defect of a list that only builds what it can see. -func (l *OpenList) fill(id widget.ListItemID, row fyne.CanvasObject) { - r, ok := row.(*ListRow) - if !ok || id < 0 || id >= len(l.options) { - return - } - value := l.options[id] - r.label = value - r.kind = nil - if l.KindOf != nil { - r.kind = l.KindOf(value) - } - r.marked = l.isChosen(value) - r.active = l.shown && id == l.active - r.onTap = func() { l.take(value, false) } - l.rows[id] = r - r.Refresh() +// GroupUnder puts every value under a heading, the one headingOf gives it. +// Called before the list is shown, the way KindOf is set. +func (l *OpenList) GroupUnder(headingOf func(string) string) { + l.headingOf = headingOf + l.rearrange() } -// Rows is what this list is showing, for a guard to read. +// WithFilter gives the list a box at the top that narrows it to the values +// holding what is typed there. The box takes the keyboard when the list +// opens - see Keyboard. // -// The toolkit's menu turned items into widgets of an unexported type, so what -// was marked could not be read back off the canvas - only that something was -// open. This is the half of that pair we own, and it says what is in the list -// and which row carries the tick. -func (l *OpenList) Rows() []Choice { - out := make([]Choice, 0, len(l.options)) - for _, value := range l.options { - out = append(out, Choice{Label: value, Marked: l.isChosen(value)}) +// Only a long list gets one, and which lists are long is the Chooser's call, +// not this list's: a filter over five values is a box to type in that +// answers a question nobody has. +func (l *OpenList) WithFilter() { + l.filter = newFilterBox(l) +} + +// Keyboard is what the keyboard goes to when the list opens: the filter box +// when there is one, the list itself when there is not. +func (l *OpenList) Keyboard() fyne.Focusable { + if l.filter != nil { + return l.filter } - return out + return l } -// isChosen says whether a value is the one in the box. -// -// One function rather than the same comparison written where a row is filled -// and again where the list reports itself. Two copies is what it was for an -// hour on 2026-08-18, and the mutation runner said so at once: blanking the -// drawn mark left the guard green, because the guard was reading the other -// copy. A rule with two homes is a rule no test can pin down. -func (l *OpenList) isChosen(value string) bool { return value == l.chosen } - -// DrawnRows is every row the list has actually built, for a guard that has to -// ask what is on the screen rather than what the list holds. -// -// Rows above answers from the options, which is the right half for "what is in -// this list" and the wrong half for "what does a row draw". A picture that -// never reaches a row would pass the first and fail this one. -func (l *OpenList) DrawnRows() []*ListRow { - out := make([]*ListRow, 0, len(l.rows)) - for _, row := range l.rows { - out = append(out, row) +// Filter is the box at the top of the list, or nil, for a guard to type into. +func (l *OpenList) Filter() *FilterBox { return l.filter } + +// narrowTo is the filter box reporting what it now holds. The keyboard lands +// where landing says and the bar is drawn, because typing is using the +// keyboard - except when the box has been emptied, where the list goes back +// to how it opened: on the value in the box, with nothing drawn. +func (l *OpenList) narrowTo(typed string) { + l.typed = typed + l.rearrange() + // ScrollToOffset rather than ScrollToTop: the toolkit's ScrollToTop reaches + // for the list's scroller without asking whether it exists yet, and it does + // not until the list is first drawn - fyne v2.8.1 widget/list.go, line 358 + // against 366. A filter set before that (the catalogue does) took the + // process down. + l.list.ScrollToOffset(0) + if strings.TrimSpace(typed) == "" { + l.active = -1 + l.StartOn(l.chosen) + return + } + if at := landing(l.entries, typed); at >= 0 { + l.moveTo(at) + return } - return out + l.active = -1 + l.list.Refresh() } -// RowShowing is the row currently drawing one value, or nil if that value is -// scrolled out of sight. For a guard that needs to press or hover a real row. -func (l *OpenList) RowShowing(label string) *ListRow { - for _, row := range l.rows { - if row.Label() == label { - return row +// fill puts one row of the arrangement into one row of the list. The row it is +// given is recycled, so every field is set every time - a row left holding the +// last value it had is the classic defect of a list that only builds what it +// can see. +func (l *OpenList) fill(id widget.ListItemID, row fyne.CanvasObject) { + r, ok := row.(*ListRow) + if !ok || id < 0 || id >= len(l.entries) { + return + } + entry := l.entries[id] + r.label = entry.text + r.heading = entry.kind != entryValue + r.from, r.to = entry.from, entry.to + r.kind = nil + r.marked = false + r.active = false + r.onTap = nil + r.hovered = r.hovered && !r.heading + if entry.kind == entryValue { + value := entry.text + if l.KindOf != nil { + r.kind = l.KindOf(value) } + r.marked = l.isChosen(value) + r.active = l.shown && id == l.active + r.onTap = func() { l.take(value, false) } } - return nil + l.rows[id] = r + r.Refresh() } -// Choice is one row of an open list, for a guard to read. +// Choice is one row of an open list, for a guard to read. Choosable is false +// on a heading and on the notice that nothing matched. type Choice struct { - Label string - Marked bool + Label string + Marked bool + Choosable bool } // MinSize is as wide as the widest value and as tall as all of them, cut to @@ -178,12 +193,19 @@ type Choice struct { // that opens it does, and tells it through LimitTo. Until 2026-09-15 a count // of rows lived here instead, which made the list 224 px tall in every window // there is (O203). +// +// All of them means the whole arrangement with nothing typed, and not what the +// filter has left. The list does not shrink while somebody types into it: a +// list that opened upward would pull its bottom edge away from the box it +// belongs to with every letter, and nothing on this screen jumps under a +// person's hands (GUI rule 3). func (l *OpenList) MinSize() fyne.Size { - rows := len(l.options) + rows := len(arrange(l.options, l.headingOf, "")) if rows < 1 { rows = 1 } - height := float32(rows) * listRowHeight() + head := l.HeadHeight() + height := head + float32(rows)*listRowHeight() // The room the window has left is the whole of the ceiling, and this has // to happen in MinSize rather than by resizing the popup afterwards, // because a popup is never laid out smaller than its content's minimum. @@ -192,12 +214,22 @@ func (l *OpenList) MinSize() fyne.Size { if l.room > 0 && height > l.room { height = l.room } - if height < listRowHeight() { - height = listRowHeight() + if height < head+listRowHeight() { + height = head + listRowHeight() } return fyne.NewSize(l.list.MinSize().Width, height) } +// HeadHeight is the room the filter box takes at the top of the list, with +// the space round it, or nought for a list without one. RoomForList is told +// it, so that the rows under it still end on a row's edge. +func (l *OpenList) HeadHeight() float32 { + if l.filter == nil { + return 0 + } + return l.filter.MinSize().Height + 2*filterInset +} + // LimitTo tells the list how much room it has - the share of the window it // may cover, cut further to the room on the side it opens on. Nought means no // limit. @@ -223,7 +255,12 @@ func (l *OpenList) CreateRenderer() fyne.WidgetRenderer { // The surface is drawn here rather than left to the popup, so that the // colour a guard measures for "an open list is told from the form behind // it" is the colour actually on the screen. - return widget.NewSimpleRenderer(container.NewStack(floatingSurface(), container.NewThemeOverride(l.list, rowTheme{}))) + rows := container.NewThemeOverride(l.list, rowTheme{}) + if l.filter == nil { + return widget.NewSimpleRenderer(container.NewStack(floatingSurface(), rows)) + } + head := container.New(layout.NewCustomPaddedLayout(filterInset, filterInset, filterInset, filterInset), l.filter) + return widget.NewSimpleRenderer(container.NewStack(floatingSurface(), container.NewBorder(head, nil, nil, nil, rows))) } // rowTheme is our theme with the room between rows taken out. @@ -293,26 +330,38 @@ func listRowHeight() float32 { func (l *OpenList) FocusGained() {} func (l *OpenList) FocusLost() {} -// TypedKey moves, takes and closes. +// TypedKey moves, takes and closes. Headings and the notice are stepped over: +// the keyboard only ever stands on a value somebody can take. func (l *OpenList) TypedKey(event *fyne.KeyEvent) { switch event.Name { case fyne.KeyDown: - l.moveTo(l.active + 1) + l.step(+1) case fyne.KeyUp: - l.moveTo(l.active - 1) + l.step(-1) case fyne.KeyHome: - l.moveTo(0) + l.moveTo(edgeValue(l.entries, +1)) case fyne.KeyEnd: - l.moveTo(len(l.options) - 1) + l.moveTo(edgeValue(l.entries, -1)) case fyne.KeyEscape: l.close(true) case fyne.KeyReturn, fyne.KeyEnter, fyne.KeySpace: - if l.active >= 0 && l.active < len(l.options) { - l.take(l.options[l.active], true) + if l.active >= 0 && l.active < len(l.entries) && l.entries[l.active].kind == entryValue { + l.take(l.entries[l.active].text, true) } } } +// step moves the keyboard one value up or down. From nowhere, either arrow +// goes to the first value - which is where Down went before this list had +// headings, and Up from nowhere was clamped to the same row. +func (l *OpenList) step(by int) { + if l.active < 0 { + l.moveTo(edgeValue(l.entries, +1)) + return + } + l.moveTo(nextValue(l.entries, l.active, by)) +} + // TypedRune jumps to the next value starting with the letter typed. // // From the row after the current one and wrapping, so pressing the same letter @@ -320,14 +369,25 @@ func (l *OpenList) TypedKey(event *fyne.KeyEvent) { // menu. One letter rather than a typed prefix: a prefix needs a timer to know // when the word ended, and a timer in a control is a thing that behaves // differently on a slow machine. +// +// A list with a filter box has a better answer than one letter, so a letter +// that reaches the list itself - the keyboard moved off the box with Tab - is +// put in the box, and the box gets the keyboard back. func (l *OpenList) TypedRune(r rune) { + if l.filter != nil { + l.filter.TypedRune(r) + if surface := fyne.CurrentApp().Driver().CanvasForObject(l); surface != nil { + surface.Focus(l.filter) + } + return + } want := strings.ToLower(string(r)) - for step := 1; step <= len(l.options); step++ { - at := (l.active + step) % len(l.options) + for step := 1; step <= len(l.entries); step++ { + at := (l.active + step) % len(l.entries) if at < 0 { - at += len(l.options) + at += len(l.entries) } - if strings.HasPrefix(strings.ToLower(l.options[at]), want) { + if e := l.entries[at]; e.kind == entryValue && strings.HasPrefix(strings.ToLower(e.text), want) { l.moveTo(at) return } @@ -337,18 +397,21 @@ func (l *OpenList) TypedRune(r rune) { // moveTo puts the keyboard on one row and scrolls it into view. Clamped rather // than wrapped, because a list that jumps from the last value to the first // under a held arrow key is a list somebody overshoots in both directions. +// +// A value standing first under its heading brings the heading into view with +// it, so arrowing up to the top of a kind shows what kind it was. func (l *OpenList) moveTo(at int) { - if len(l.options) == 0 { + if at < 0 || len(l.entries) == 0 { return } - if at < 0 { - at = 0 - } - if at >= len(l.options) { - at = len(l.options) - 1 + if at >= len(l.entries) { + at = len(l.entries) - 1 } l.active = at l.shown = true + if at > 0 && l.entries[at-1].kind == entryHeading { + l.list.ScrollTo(at - 1) + } l.list.ScrollTo(at) l.list.Refresh() } @@ -360,8 +423,8 @@ func (l *OpenList) Active() int { return l.active } // it, so that opening a list of thirteen and pressing Down once does not go to // the first value while the box shows the ninth. func (l *OpenList) StartOn(value string) { - for i, option := range l.options { - if option == value { + for i, e := range l.entries { + if e.kind == entryValue && e.text == value { l.moveTo(i) l.shown = false l.list.Refresh() diff --git a/internal/gui/parts/ring.go b/internal/gui/parts/ring.go index 6da1245..e64461d 100644 --- a/internal/gui/parts/ring.go +++ b/internal/gui/parts/ring.go @@ -10,6 +10,8 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) // Ring is the line round a control that says something about its state. @@ -185,6 +187,12 @@ type Chooser struct { // KindOf says what picture goes in front of a value. Nil on a menu whose // values are not things of different kinds, which is most of them. KindOf func(string) fyne.Resource + // HeadingOf is the heading a value stands under in the open list, and + // Filtered says the list opens with a box to narrow it. Both are set on + // the one menu that is long - the whole list of formats - and on no other. + // See NewChooser. + HeadingOf func(string) string + Filtered bool // opened is the list this menu last dropped down, and it is here for a // guard: the canvas says whether a list appeared, and this says what was // in it. Neither alone is worth anything - a list built correctly and never @@ -223,6 +231,13 @@ func NewChooser(options []string, changed func(string)) *Chooser { c.OnChanged = changed if IsEveryFormat(options) { c.KindOf = KindOfFile + // Grouped and filtered for the same reason and on the same test as + // the pictures, decided by the owner on 2026-09-23 with twenty six + // formats of which eighteen fitted in the open list: every list of + // formats in the window gets it at once, including the ones that did + // not exist when it was written. + c.HeadingOf = KindHeading + c.Filtered = true } c.ExtendBaseWidget(c) return c @@ -283,9 +298,13 @@ func menuWidth(c *Chooser) float32 { // Nothing of ours ever sets a placeholder on a menu - it would be a word a // person reads coming from outside the text package - so there is no // placeholder of ours to measure either. + // In bold on a menu with a filter, because the filter draws the part of a + // value that matched in bold, and bold letters are wider - so a value + // typed in full is the widest that value is ever drawn. + style := fyne.TextStyle{Bold: c.Filtered} var widest float32 for _, option := range c.Options { - if w := fyne.MeasureText(option, size, fyne.TextStyle{}).Width; w > widest { + if w := fyne.MeasureText(option, size, style).Width; w > widest { widest = w } } @@ -321,9 +340,17 @@ func menuWidth(c *Chooser) float32 { // guard about a preference. pad := th.Size(theme.SizeNameInnerPadding) box := widest + pad*4 + th.Size(theme.SizeNameInlineIcon) + if c.KindOf != nil { + // The closed box draws the picture of its value in front of the word + // - see menuLook.placeKind. + box += th.Size(theme.SizeNameInlineIcon) + rowGap + } if row := RowWidthFor(widest, c.KindOf != nil); row > box { box = row } + if open := openListWidth(c); open > box { + box = open + } // And never narrower than the narrowest box on these screens. // // The owner's report of 2026-08-28, from the running window: the format menu @@ -340,6 +367,37 @@ func menuWidth(c *Chooser) float32 { return fyne.Max(NumericWidth, box) } +// openListWidth is the room the rows of an open list need that are not +// values: the headings, the notice that nothing matched and the filter box +// with the words standing in it. Nought for a list that has none of them. +// +// The box has to cover these for the reason it covers a row - the list opens +// at the box's width - and they are measured here rather than read off the +// list, because the list does not exist until somebody presses the box. +func openListWidth(c *Chooser) float32 { + var widest float32 + if c.HeadingOf != nil { + // With the count each heading carries when nothing is typed, which is + // the most it ever carries. + counts := map[string]int{} + for _, option := range c.Options { + counts[c.HeadingOf(option)]++ + } + for heading, count := range counts { + widest = fyne.Max(widest, HeadingRowWidthFor(headingWidth(text.ListHeadingCount(heading, count)))) + } + } + if c.Filtered { + // The words in the empty box, inside the box's own room on both + // sides, inside the room round the box. + inner := Theme().Size(theme.SizeNameInnerPadding) + words := fyne.MeasureText(text.PlaceholderFilter(), theme.TextSize(), fyne.TextStyle{}).Width + widest = fyne.Max(widest, words+4*inner+2*filterInset) + widest = fyne.Max(widest, HeadingRowWidthFor(headingWidth(text.ListNothingMatches()))) + } + return widest +} + // useRing takes the ring and asks it for a line at rest as well as the two it // already draws. The border a menu wears is the ring at its quietest, so there // is exactly one edge round this control in every state. @@ -446,6 +504,12 @@ func (c *Chooser) drop(surface fyne.Canvas) { c.giveBack(surface, byKeyboard) }) list.KindOf = c.KindOf + if c.HeadingOf != nil { + list.GroupUnder(c.HeadingOf) + } + if c.Filtered { + list.WithFilter() + } pop = widget.NewPopUp(list, surface) c.opened = list @@ -453,7 +517,7 @@ func (c *Chooser) drop(surface fyne.Canvas) { // As wide as the box, so the list reads as belonging to that field. How // tall and which side of the box it goes on is worked out from the room // that is actually left - see roomForList. - height, top := RoomForList(surface.Size().Height, at.Y, c.Size().Height, list.MinSize().Height) + height, top := RoomForList(surface.Size().Height, at.Y, c.Size().Height, list.MinSize().Height, list.HeadHeight()) // Told to the list rather than only to the popup, because a popup is never // laid out smaller than its content's minimum - so resizing alone left the // list its full height and the shortening did nothing. @@ -461,7 +525,7 @@ func (c *Chooser) drop(surface fyne.Canvas) { pop.Resize(fyne.NewSize(c.Size().Width, height)) pop.ShowAtPosition(fyne.NewPos(at.X, top)) - surface.Focus(list) + surface.Focus(list.Keyboard()) // On the value already in the box, so that pressing Down once does not go // to the first value while the box shows the ninth. list.StartOn(c.Selected) @@ -498,17 +562,36 @@ func (c *Chooser) drop(surface fyne.Canvas) { // exported for that check. The screen level guard opens a real menu and // measures the overlay, which is the half that catches this being wired up // wrongly. -func RoomForList(canvasHeight, boxTop, boxHeight, wanted float32) (height, top float32) { - if ceiling := ListCeiling(canvasHeight); wanted > ceiling { - wanted = ceiling +// +// head is the filter box at the top of a list that has one, and nought for the +// rest. It is taken off the room before the rows are counted, so everything +// above still holds for the rows under it - they end on a row's edge, and the +// whole list, box and rows together, stays inside the share of the window. +// The ceiling never falls under one row, for the reason ListCeiling keeps +// one: a filter over no rows is a box that narrows nothing anybody can see. +// The room beside the box can still be less than that in a window cramped +// enough, and then the list is cut to it exactly as before there was a head. +func RoomForList(canvasHeight, boxTop, boxHeight, wanted, head float32) (height, top float32) { + rows := wanted - head + // From the share itself rather than from ListCeiling, which is the share + // already cut to whole rows - cutting twice, once for the share and once + // for what the head leaves, lost up to a row more than "no less than the + // share less a row" allows. Measured on a 650 px canvas: 292 px of list + // against a promise of 297. With no head this is ListCeiling exactly. + ceiling := wholeRows(canvasHeight*listShare - head) + if ceiling < listRowHeight() { + ceiling = listRowHeight() + } + if rows > ceiling { + rows = ceiling } - below := wholeRows(canvasHeight - (boxTop + boxHeight) - listEdgeGap) - above := wholeRows(boxTop - listEdgeGap) + below := wholeRows(canvasHeight - (boxTop + boxHeight) - listEdgeGap - head) + above := wholeRows(boxTop - listEdgeGap - head) - if wanted <= below || below >= listOpensDownwardFrom*listRowHeight() || below >= above { - return fyne.Min(wanted, below), boxTop + boxHeight + if rows <= below || below >= listOpensDownwardFrom*listRowHeight() || below >= above { + return head + fyne.Min(rows, below), boxTop + boxHeight } - height = fyne.Min(wanted, above) + height = head + fyne.Min(rows, above) return height, boxTop - height } @@ -596,6 +679,18 @@ func (c *Chooser) TypedRune(r rune) { if !c.marked { c.mark() } + // A menu with a filter opens with the letter in it instead. One letter at + // a time walked the values starting with each letter in turn, so "jxl" + // typed at the shut format menu ended on log - j to jpg, x to xlsx, l to + // log. Read off this function on 2026-09-23 and ordered by the owner with + // the filter: typing at the shut menu is the same as typing into it. + if c.Filtered { + c.Tapped(nil) + if c.opened != nil && c.opened.Filter() != nil { + c.opened.Filter().TypedRune(r) + } + return + } want := strings.ToLower(string(r)) from := c.SelectedIndex() for step := 1; step <= len(c.Options); step++ { diff --git a/internal/gui/parts/tokens.go b/internal/gui/parts/tokens.go index 1917759..ad5cca1 100644 --- a/internal/gui/parts/tokens.go +++ b/internal/gui/parts/tokens.go @@ -273,6 +273,10 @@ const ( // its second step now, so a row is 4 px wider than it was. rowGutter = space2 rowGap = space2 + // filterInset is the room round the filter box at the top of an open + // list, so the box reads as standing inside the list rather than as a + // second field glued onto its top edge. + filterInset = space1 ) // EdgeWidth is the line round a control at rest, for a guard measuring diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index cdfe3fe..c423e61 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -305,6 +305,38 @@ "description": "Shown in the window.", "other": "Point your test at the manifest. For every file it says what the system under test should do with it - accept it, reject it or sanitize it - or records the outcome as unspecified, where the right answer belongs to the application's own policy." }, + "ListHeadingCount": { + "description": "Shown inside an open list somebody chooses from. Carries these values, each of which has to stay spelled exactly that way: {{.Kind}}, {{.Count}}.", + "other": "{{.Kind}} · {{.Count}}" + }, + "ListKindArchives": { + "description": "A heading inside an open list of formats, over the formats of one kind of file.", + "other": "Archives" + }, + "ListKindDocuments": { + "description": "A heading inside an open list of formats, over the formats of one kind of file.", + "other": "Documents" + }, + "ListKindPictures": { + "description": "A heading inside an open list of formats, over the formats of one kind of file.", + "other": "Pictures" + }, + "ListKindSound": { + "description": "A heading inside an open list of formats, over the formats of one kind of file.", + "other": "Sound" + }, + "ListKindText": { + "description": "A heading inside an open list of formats, over the formats of one kind of file.", + "other": "Text and data" + }, + "ListKindVideo": { + "description": "A heading inside an open list of formats, over the formats of one kind of file.", + "other": "Video" + }, + "ListNothingMatches": { + "description": "Shown inside an open list somebody chooses from.", + "other": "Nothing matches" + }, "ManifestNamed": { "description": "Shown in the window. Carries one value, {{.Name}}, which has to stay spelled exactly that way.", "other": "Manifest: {{.Name}}" @@ -333,6 +365,10 @@ "description": "Shown in the window.", "other": "Nothing was produced." }, + "PlaceholderFilter": { + "description": "Stands in an empty box, in a quieter colour than a value.", + "other": "type to filter" + }, "PlaceholderLeftEmpty": { "description": "Stands in an empty box, in a quieter colour than a value. Carries one value, {{.Value}}, which has to stay spelled exactly that way.", "other": "default: {{.Value}}" diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 88752f4..8c22a17 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -252,6 +252,34 @@ func PresetCatchesHeading() string { return say("PresetCatchesHeading", "Typical // comes from the size that was asked for. func PlaceholderWorkedOut() string { return say("PlaceholderWorkedOut", "worked out from the size") } +// PlaceholderFilter stands in the box at the top of an open list of formats, +// where typing narrows the list to the formats whose name holds what was +// typed. +func PlaceholderFilter() string { return say("PlaceholderFilter", "type to filter") } + +// ListNothingMatches stands in an open list whose filter left no value. It is +// a row nobody can choose, so the list does not look broken while it is +// empty. +func ListNothingMatches() string { return say("ListNothingMatches", "Nothing matches") } + +// The headings inside an open list of formats, one over each kind of file. +// The list puts the kinds in the order of these words, so a translation +// reorders the headings with it rather than leaving them in English order. +func ListKindArchives() string { return say("ListKindArchives", "Archives") } +func ListKindDocuments() string { return say("ListKindDocuments", "Documents") } +func ListKindPictures() string { return say("ListKindPictures", "Pictures") } +func ListKindSound() string { return say("ListKindSound", "Sound") } +func ListKindText() string { return say("ListKindText", "Text and data") } +func ListKindVideo() string { return say("ListKindVideo", "Video") } + +// ListHeadingCount is one of those headings with how many formats stand under +// it in the list as it is drawn - after the filter, so the number is what the +// eye can count below it. The separator is the one the line at the foot of a +// screen uses between the parts of what a run comes to. +func ListHeadingCount(kind string, count int) string { + return sayf("ListHeadingCount", "{{.Kind}} · {{.Count}}", map[string]any{"Kind": kind, "Count": count}) +} + // UseSmallestSize is the button under a refusal about a size below what the // format can make. It puts the smallest size that works into the box, so the // count of bytes in the refusal does not have to be copied by hand.