Skip to content

Commit 501be20

Browse files
donislawdevclaude
andcommitted
gui: the format list's contents as their own type, the Space that opens it kept out of its filter, and the focus guard asked where the list sends the keyboard
CI on #127 was red on every system with the same three guards: - TestAPressMovesTheKeyboardWithoutDrawingItsMark still asked for the keyboard on the list itself, and the list now hands it to its filter box. It asks OpenList.Keyboard and presses Down on whatever has the keyboard. - OpenList had grown to 24 methods, the fourth type past the crowding line. What the list holds and has drawn moved to listContents, embedded, so Rows, DrawnRows and RowShowing read as before. 24 -> 19. From the review of #127: - Space at the shut Format menu opened the list and then typed a space into its filter (the driver hands the character to whatever has the keyboard after the key moved it). Seen in the real window. FilterBox drops white space while it is empty, held by a new guard. - The heading-shape check fails when no heading was drawn. - CHANGELOG: a kind matches when any of its words starts with what is typed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9cc4919 commit 501be20

6 files changed

Lines changed: 205 additions & 91 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ because it turns other people's test suites red.
2020
list stands under headings - Archives, Documents, Pictures, Sound, Text and
2121
data - each saying how many formats are under it, and a box at its top
2222
narrows it to the formats whose name holds what is typed, or whose kind
23-
starts with it (`gz` finds `targz`, `pict` every picture). The letters that
23+
has a word that starts with it (`gz` finds `targz`, `pict` every picture,
24+
`data` every text and data format). The letters that
2425
matched are drawn in bold, the arrows step over the headings, and typing
2526
at the shut `Format` menu opens the list with those letters in the box, so
2627
`jxl` typed there ends on `jxl`. The shut menu draws the kind of the format

‎internal/guard/formatlist_test.go‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,22 @@ func TestTheFormatListStandsUnderAHeadingForEachKind(t *testing.T) {
131131
if !sort.StringsAreSorted(titles) {
132132
t.Errorf("the headings stand in the order %v, and a closed set is in the order of its words", titles)
133133
}
134+
// Counted, because a loop over the drawn rows passes just as well when no
135+
// heading was drawn at all - an outside review of #127 named it, and it is
136+
// trap 1 of CLAUDE.md: the guard has to be in the state it asks about.
137+
drawnHeadings := 0
134138
for _, row := range list.DrawnRows() {
135-
if row.Heading() && (row.Kind() != nil || row.Marked()) {
139+
if !row.Heading() {
140+
continue
141+
}
142+
drawnHeadings++
143+
if row.Kind() != nil || row.Marked() {
136144
t.Errorf("the heading %q draws a picture or a tick, which says it is a value somebody can take", row.Label())
137145
}
138146
}
147+
if drawnHeadings == 0 {
148+
t.Fatal("no heading row is drawn, so nothing was asked about how a heading looks")
149+
}
139150
}
140151

141152
// TestTypingIntoTheFormatListNarrowsItAndLandsOnWhatStartsWithIt types into
@@ -252,6 +263,40 @@ func TestTypingAtTheShutFormatMenuOpensItsFilter(t *testing.T) {
252263
}
253264
}
254265

266+
// TestTheSpaceThatOpensTheFormatListIsNotTypedIntoItsFilter presses Space at
267+
// the shut menu the way the driver delivers it: the key to whatever has the
268+
// keyboard, then the character to whatever has it NOW (fyne v2.8.1
269+
// internal/driver/glfw/window.go, processKeyPressed and processCharInput both
270+
// ask canvas.Focused()). The key opens the list and hands the keyboard to the
271+
// box, so the character landed in the box.
272+
//
273+
// Reported by an outside review of #127 and seen in the real window through
274+
// tools/pilot.py before this was written: after Space the box lost its
275+
// placeholder and the caret stood one space in, with nothing visible typed.
276+
func TestTheSpaceThatOpensTheFormatListIsNotTypedIntoItsFilter(t *testing.T) {
277+
c, content := screenOnACanvas(t)
278+
menu := chooserUnder(t, content, text.FieldFormat())
279+
c.Focus(menu)
280+
c.Focused().TypedKey(&fyne.KeyEvent{Name: fyne.KeySpace})
281+
list := menu.Opened()
282+
if list == nil || list.Filter() == nil {
283+
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")
284+
}
285+
if c.Focused() != fyne.Focusable(list.Filter()) {
286+
t.Fatalf("after Space the keyboard is on %T rather than in the box, so the character would not reach it", c.Focused())
287+
}
288+
c.Focused().TypedRune(' ')
289+
if got := list.Filter().Text; got != "" {
290+
t.Errorf("the Space that opened the list left %q in its box, which hides the placeholder and moves the caret", got)
291+
}
292+
// A space between words is still a space: only an empty box drops one.
293+
c.Focused().TypedRune('t')
294+
c.Focused().TypedRune(' ')
295+
if got := list.Filter().Text; got != "t " {
296+
t.Errorf("t then Space typed into the box left %q, and a space after a letter is somebody typing", got)
297+
}
298+
}
299+
255300
// TestThePartOfAValueThatMatchedIsDrawnInBold reads a row's drawn words.
256301
func TestThePartOfAValueThatMatchedIsDrawnInBold(t *testing.T) {
257302
_, _, list, filter := openFormatList(t)

‎internal/guard/pointerfocus_test.go‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,26 @@ func TestAPressMovesTheKeyboardWithoutDrawingItsMark(t *testing.T) {
5050
// a press and leaves focusing to the widget, and the switch left it.
5151
// On the list the press opened, which is where the arrows have to work.
5252
// Closing it hands the keyboard back to the menu (see Chooser.giveBack).
53-
if list := menu.Opened(); list == nil || c.Focused() != fyne.Focusable(list) {
54-
t.Errorf("the format menu was pressed and the keyboard is on %T, not on the list it opened", c.Focused())
53+
//
54+
// "On the list" is where the list says the keyboard goes, since the list
55+
// of 2026-09-23 took a filter: the box at its top has the keyboard and
56+
// hands the arrows on (FilterBox.TypedKey). Asked of OpenList.Keyboard
57+
// rather than of a type, and then the arrow is pressed on whatever has the
58+
// keyboard - so the guard holds the promise, not the shape it took.
59+
list := menu.Opened()
60+
if list == nil {
61+
t.Fatal("the format menu was pressed and opened no list")
62+
}
63+
if c.Focused() != list.Keyboard() {
64+
t.Errorf("the format menu was pressed and the keyboard is on %T, not where the list it opened takes it (%T)",
65+
c.Focused(), list.Keyboard())
66+
}
67+
if focused := c.Focused(); focused != nil {
68+
before := list.Active()
69+
focused.TypedKey(&fyne.KeyEvent{Name: fyne.KeyDown})
70+
if list.Active() == before {
71+
t.Errorf("Down pressed on %T moved nothing in the list the press opened - the keyboard is somewhere the arrows do not work", focused)
72+
}
5573
}
5674

5775
// The list the press opened is taken away first. A real press respects what

‎internal/gui/parts/filterbox.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package parts
22

33
import (
4+
"unicode"
5+
46
"fyne.io/fyne/v2"
57

68
"github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
@@ -47,3 +49,25 @@ func (f *FilterBox) TypedKey(event *fyne.KeyEvent) {
4749
}
4850
f.Entry.Entry.TypedKey(event)
4951
}
52+
53+
// TypedRune drops white space typed into an empty box, and takes everything
54+
// else as the box it is.
55+
//
56+
// The case it is for is the Space that OPENS the list. The driver hands the
57+
// key to whatever has the keyboard and then the character to whatever has it
58+
// after that - and the key is what moved the keyboard here (Chooser.TypedKey
59+
// opens the list, the list hands the keyboard to this box). So the character
60+
// of the same press landed in the box: no placeholder, the caret one space in,
61+
// and nothing on screen saying why. Reported by an outside review of #127,
62+
// seen in the real window, held by
63+
// TestTheSpaceThatOpensTheFormatListIsNotTypedIntoItsFilter.
64+
//
65+
// Only while the box is empty, because a space after a word is somebody
66+
// typing - and a leading one means nothing to the list anyway, which trims
67+
// what it narrows by (narrowTo).
68+
func (f *FilterBox) TypedRune(r rune) {
69+
if f.Text == "" && unicode.IsSpace(r) {
70+
return
71+
}
72+
f.Entry.TypedRune(r)
73+
}

‎internal/gui/parts/listcontents.go‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package parts
2+
3+
import (
4+
"fyne.io/fyne/v2/widget"
5+
)
6+
7+
// listContents is what an open list holds and what it has drawn: the values,
8+
// how they are grouped and narrowed, the rows that arrangement comes to, and
9+
// the rows the toolkit has actually built for it.
10+
//
11+
// Its own type rather than more of OpenList, since the list of 2026-09-23
12+
// took headings and a filter. OpenList stood at 24 methods, the fourth type in
13+
// the tree past the crowding line, and TestNoSecondTypeIsCreepingUpOnTheTypeCeilings
14+
// asks for behaviour to move out rather than for the line to move. What moved
15+
// is one question - what is in this list, and what does each row draw - which
16+
// the widget, the keyboard and the layout only ask. OpenList embeds it, so a
17+
// guard still reads list.Rows() and list.DrawnRows() where it always did.
18+
type listContents struct {
19+
options []string
20+
// chosen is the value in the box, marked with a tick. Empty when the box
21+
// shows a default nobody has confirmed - see the note in preset.go about a
22+
// filled field making "I did not say" impossible to express.
23+
chosen string
24+
25+
// headingOf is the heading a value stands under, or nil for a list with no
26+
// headings. See OpenList.GroupUnder.
27+
headingOf func(string) string
28+
// typed is what is in the filter box, or nothing for a list without one.
29+
typed string
30+
// entries is what the list draws now - headings, values and the notice
31+
// that nothing matched - worked out by arrange. Every row number in this
32+
// package is a position in entries, never in options, because the two part
33+
// the moment a list has a heading.
34+
entries []listEntry
35+
36+
// rows is the row showing each position, recorded as the list fills them.
37+
//
38+
// A registry rather than a walk, because a walk cannot get in: widget.List
39+
// keeps the rows it built inside its renderer, so a tree walk stops at the
40+
// list and reports an open list with nothing in it. Measured on 2026-08-18
41+
// while trying to photograph a row under the pointer.
42+
//
43+
// Every entry is current whatever the list has scrolled past, because a
44+
// recycled row is refilled before it is shown and fill is what writes here.
45+
rows map[widget.ListItemID]*ListRow
46+
}
47+
48+
// rearrange works out the rows again after the filter or the grouping
49+
// changed, and forgets the rows it recorded: a row built for the old
50+
// arrangement can still hold a label the list no longer draws, and
51+
// RowShowing would report it.
52+
func (c *listContents) rearrange() {
53+
c.entries = arrange(c.options, c.headingOf, c.typed)
54+
c.rows = map[widget.ListItemID]*ListRow{}
55+
}
56+
57+
// Rows is what this list is showing, for a guard to read, in the order it is
58+
// drawn - headings and the notice included, so that a position from Active
59+
// is a position here.
60+
//
61+
// The toolkit's menu turned items into widgets of an unexported type, so what
62+
// was marked could not be read back off the canvas - only that something was
63+
// open. This is the half of that pair we own, and it says what is in the list
64+
// and which row carries the tick.
65+
func (c *listContents) Rows() []Choice {
66+
out := make([]Choice, 0, len(c.entries))
67+
for _, e := range c.entries {
68+
value := e.kind == entryValue
69+
out = append(out, Choice{Label: e.text, Marked: value && c.isChosen(e.text), Choosable: value})
70+
}
71+
return out
72+
}
73+
74+
// isChosen says whether a value is the one in the box.
75+
//
76+
// One function rather than the same comparison written where a row is filled
77+
// and again where the list reports itself. Two copies is what it was for an
78+
// hour on 2026-08-18, and the mutation runner said so at once: blanking the
79+
// drawn mark left the guard green, because the guard was reading the other
80+
// copy. A rule with two homes is a rule no test can pin down.
81+
func (c *listContents) isChosen(value string) bool { return value == c.chosen }
82+
83+
// DrawnRows is every row the list has actually built, for a guard that has to
84+
// ask what is on the screen rather than what the list holds.
85+
//
86+
// Rows above answers from the options, which is the right half for "what is in
87+
// this list" and the wrong half for "what does a row draw". A picture that
88+
// never reaches a row would pass the first and fail this one.
89+
func (c *listContents) DrawnRows() []*ListRow {
90+
out := make([]*ListRow, 0, len(c.rows))
91+
for _, row := range c.rows {
92+
out = append(out, row)
93+
}
94+
return out
95+
}
96+
97+
// RowShowing is the row currently drawing one value, or nil if that value is
98+
// scrolled out of sight. For a guard that needs to press or hover a real row.
99+
func (c *listContents) RowShowing(label string) *ListRow {
100+
for _, row := range c.rows {
101+
if row.Label() == label {
102+
return row
103+
}
104+
}
105+
return nil
106+
}

‎internal/gui/parts/openlist.go‎

Lines changed: 7 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,13 @@ import (
3939
type OpenList struct {
4040
widget.BaseWidget
4141

42-
options []string
42+
// listContents is what the list holds and what it has drawn - see
43+
// listcontents.go for why it is a type of its own.
44+
listContents
45+
4346
// room is how much height the window has left for this list, or nought for
4447
// no limit. See LimitTo and MinSize.
4548
room float32
46-
// chosen is the value in the box, marked with a tick. Empty when the box
47-
// shows a default nobody has confirmed - see the note in preset.go about a
48-
// filled field making "I did not say" impossible to express.
49-
chosen string
5049
// active is the row the keyboard is on, or -1 when it has not been used,
5150
// and shown is whether that is drawn.
5251
//
@@ -63,41 +62,22 @@ type OpenList struct {
6362
close func(byKeyboard bool)
6463

6564
list *widget.List
66-
// rows is the row showing each position, recorded as the list fills them.
67-
//
68-
// A registry rather than a walk, because a walk cannot get in: widget.List
69-
// keeps the rows it built inside its renderer, so a tree walk stops at the
70-
// list and reports an open list with nothing in it. Measured on 2026-08-18
71-
// while trying to photograph a row under the pointer.
72-
//
73-
// Every entry is current whatever the list has scrolled past, because a
74-
// recycled row is refilled before it is shown and fill is what writes here.
75-
rows map[widget.ListItemID]*ListRow
7665

7766
// KindOf says what picture goes in front of one value, or nil for a list
7867
// whose values are not things of different kinds. Set from outside, because
7968
// only the screen putting values in knows what they are.
8069
KindOf func(string) fyne.Resource
8170

82-
// headingOf is the heading a value stands under, or nil for a list with no
83-
// headings. See GroupUnder.
84-
headingOf func(string) string
8571
// filter is the box at the top that narrows the list, or nil for a list
86-
// without one, and typed is what is in it. See WithFilter.
72+
// without one. What is typed into it is listContents.typed. See WithFilter.
8773
filter *FilterBox
88-
typed string
89-
// entries is what the list draws now - headings, values and the notice
90-
// that nothing matched - worked out by arrange. Every row number in this
91-
// file is a position in entries, never in options, because the two part
92-
// the moment a list has a heading.
93-
entries []listEntry
9474
}
9575

9676
// NewOpenList builds the list. take is called with the value somebody settled
9777
// on, and close when they left without settling on one.
9878
func NewOpenList(options []string, chosen string, take func(string, bool), close func(bool)) *OpenList {
99-
l := &OpenList{options: options, chosen: chosen, active: -1, take: take, close: close,
100-
rows: map[widget.ListItemID]*ListRow{}}
79+
l := &OpenList{active: -1, take: take, close: close,
80+
listContents: listContents{options: options, chosen: chosen, rows: map[widget.ListItemID]*ListRow{}}}
10181
l.entries = arrange(options, nil, "")
10282
l.list = widget.NewList(
10383
func() int { return len(l.entries) },
@@ -138,15 +118,6 @@ func (l *OpenList) Keyboard() fyne.Focusable {
138118
// Filter is the box at the top of the list, or nil, for a guard to type into.
139119
func (l *OpenList) Filter() *FilterBox { return l.filter }
140120

141-
// rearrange works out the rows again after the filter or the grouping
142-
// changed, and forgets the rows it recorded: a row built for the old
143-
// arrangement can still hold a label the list no longer draws, and
144-
// RowShowing would report it.
145-
func (l *OpenList) rearrange() {
146-
l.entries = arrange(l.options, l.headingOf, l.typed)
147-
l.rows = map[widget.ListItemID]*ListRow{}
148-
}
149-
150121
// narrowTo is the filter box reporting what it now holds. The keyboard lands
151122
// where landing says and the bar is drawn, because typing is using the
152123
// keyboard - except when the box has been emptied, where the list goes back
@@ -204,57 +175,6 @@ func (l *OpenList) fill(id widget.ListItemID, row fyne.CanvasObject) {
204175
r.Refresh()
205176
}
206177

207-
// Rows is what this list is showing, for a guard to read, in the order it is
208-
// drawn - headings and the notice included, so that a position from Active
209-
// is a position here.
210-
//
211-
// The toolkit's menu turned items into widgets of an unexported type, so what
212-
// was marked could not be read back off the canvas - only that something was
213-
// open. This is the half of that pair we own, and it says what is in the list
214-
// and which row carries the tick.
215-
func (l *OpenList) Rows() []Choice {
216-
out := make([]Choice, 0, len(l.entries))
217-
for _, e := range l.entries {
218-
value := e.kind == entryValue
219-
out = append(out, Choice{Label: e.text, Marked: value && l.isChosen(e.text), Choosable: value})
220-
}
221-
return out
222-
}
223-
224-
// isChosen says whether a value is the one in the box.
225-
//
226-
// One function rather than the same comparison written where a row is filled
227-
// and again where the list reports itself. Two copies is what it was for an
228-
// hour on 2026-08-18, and the mutation runner said so at once: blanking the
229-
// drawn mark left the guard green, because the guard was reading the other
230-
// copy. A rule with two homes is a rule no test can pin down.
231-
func (l *OpenList) isChosen(value string) bool { return value == l.chosen }
232-
233-
// DrawnRows is every row the list has actually built, for a guard that has to
234-
// ask what is on the screen rather than what the list holds.
235-
//
236-
// Rows above answers from the options, which is the right half for "what is in
237-
// this list" and the wrong half for "what does a row draw". A picture that
238-
// never reaches a row would pass the first and fail this one.
239-
func (l *OpenList) DrawnRows() []*ListRow {
240-
out := make([]*ListRow, 0, len(l.rows))
241-
for _, row := range l.rows {
242-
out = append(out, row)
243-
}
244-
return out
245-
}
246-
247-
// RowShowing is the row currently drawing one value, or nil if that value is
248-
// scrolled out of sight. For a guard that needs to press or hover a real row.
249-
func (l *OpenList) RowShowing(label string) *ListRow {
250-
for _, row := range l.rows {
251-
if row.Label() == label {
252-
return row
253-
}
254-
}
255-
return nil
256-
}
257-
258178
// Choice is one row of an open list, for a guard to read. Choosable is false
259179
// on a heading and on the notice that nothing matched.
260180
type Choice struct {

0 commit comments

Comments
 (0)