From 366053c32bd788edd185e5f9b46bfacdb1c44a1f Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 23 Sep 2026 20:41:18 +0200 Subject: [PATCH] gui: the open list lays out its own rows, so opening it no longer parses the fonts again The rows of an open list were widget.List's under a theme override that took out the room between them. Fyne 2.8.1 gives an override a new scope at construction, at CreateRenderer and at every Refresh, and parses the fonts again for every scope a new string is drawn in - every opening was a new override, and every letter a filter made bold a new string. rowView puts one ListRow at every position of the arrangement, one row apart (rowStack), in a container.Scroll, with no override. Keyboard scrolling follows widget.List's scrollTo with no gap between rows. RowShowing and DrawnRows answer the rows in sight. listRowHeight measures the text instead of building a label on every call (the same number). Measured in the real window, interleaved, three runs after and four before: - ten openings with a new letter in the filter: live heap +158 MB -> +1.5 MB, font sets 24 -> 2, process at the end 326 MB -> 100-115 MB; - the window busy per opening with a letter: 22-28 ms -> 6 ms. Five stored menu screens are pixel for pixel the same, their trees without the toolkit's list. One picture changed: the catalogue's filtered state came up 124 px down with the keyboard's row out of sight, and now comes up at its top, as typing puts a list on the screen. An emptied filter in a list with nothing chosen kept the rows it had narrowed to - on widget.List as well. StartOn now draws either way. New guards: TestTheRowTheKeyboardIsOnIsAlwaysInSight (written before the change, green on widget.List) and TestEmptyingTheFilterOfAListWithNothingChosenDrawsEveryRowAgain. TestNoScreenStandsInAThemeOverride now walks what the open list and the catalogue draw. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 +- internal/guard/formatlist_test.go | 104 ++ internal/guard/testdata/screens/catalogue.png | Bin 2034954 -> 2035030 bytes internal/guard/testdata/screens/catalogue.xml | 967 ++++++++---------- .../screens/generate-menu-hovered.xml | 391 +++---- .../testdata/screens/generate-menu-keyed.xml | 391 +++---- .../guard/testdata/screens/generate-menu.xml | 391 +++---- .../testdata/screens/preset-menu-setting.xml | 391 +++---- .../guard/testdata/screens/preset-menu.xml | 73 +- internal/guard/themescope_test.go | 47 +- internal/gui/catalogue/lists.go | 12 +- internal/gui/parts/listcontents.go | 44 +- internal/gui/parts/listrow.go | 11 +- internal/gui/parts/openlist.go | 100 +- internal/gui/parts/rowview.go | 126 +++ 15 files changed, 1417 insertions(+), 1649 deletions(-) create mode 100644 internal/gui/parts/rowview.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a84e35d..87aa8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -475,15 +475,15 @@ because it turns other people's test suites red. ### Fixed -- **The window uses far less memory, and rebuilding a screen no longer adds - to it.** Every - quiet line on a screen - a subtitle, a caption, the count of bytes beside a - size, the line a folded section keeps, the message under a field - parsed - its own copy of the fonts, and parsed another each time the screen was - rebuilt and the line said something new. After visiting the four tabs the - window held about 290 MB and it now holds about 120 MB, and rebuilding a - screen no longer adds to it. Nothing on the screen looks different. The open - list of formats still does this when you type into its filter, and is next. +- **The window uses far less memory, and rebuilding a screen or opening a + list no longer adds to it.** Every quiet line on a screen - a subtitle, a + caption, the count of bytes beside a size, the line a folded section keeps, + the message under a field - parsed its own copy of the fonts, and parsed + another each time the screen was rebuilt and the line said something new. + The open list of formats did the same every time it opened, and more with + every letter typed into its filter - ten openings kept about 160 MB. After + visiting the four tabs the window held about 290 MB and it now holds about + 120 MB. Nothing on the screen looks different. - **The `Several batches` screen no longer slows down the longer it is used.** Every batch added, removed or copied, every format chosen and every diff --git a/internal/guard/formatlist_test.go b/internal/guard/formatlist_test.go index d2ffcb0..620b5bf 100644 --- a/internal/guard/formatlist_test.go +++ b/internal/guard/formatlist_test.go @@ -235,6 +235,110 @@ func TestTheArrowsInTheFormatListStepOverTheHeadings(t *testing.T) { } } +// TestTheRowTheKeyboardIsOnIsAlwaysInSight walks the keyboard through a list +// taller than the room it was given and asks, after every key, whether the +// row it stands on is drawn whole inside that room - and, on the first value +// of a kind, whether the heading over it is too, since arrowing up to the top +// of a kind is meant to show what kind it was (OpenList.moveTo). +// +// Written 2026-09-23 before the rows left widget.List, because the scrolling +// had no guard of its own: no stored screen scrolls the list, so a list that +// kept the keyboard on a row out of sight would have passed every picture. +// Read off the canvas, row against list, so it asks what a person sees and +// not what the list says it did. +func TestTheRowTheKeyboardIsOnIsAlwaysInSight(t *testing.T) { + _, _, list, _ := openFormatList(t) + entries := list.Rows() + room := list.Size().Height - list.HeadHeight() + if room >= float32(len(entries))*parts.ListRowHeight() { + t.Fatalf("the list has %.0f px for %d rows of %.0f, so it never scrolls and nothing here is asked", + room, len(entries), parts.ListRowHeight()) + } + drv := fyne.CurrentApp().Driver() + inSight := func(label string) bool { + row := list.RowShowing(label) + if row == nil { + return false + } + top := drv.AbsolutePositionForObject(list).Y + list.HeadHeight() + at := drv.AbsolutePositionForObject(row).Y + return at >= top-0.5 && at+row.Size().Height <= top+room+0.5 + } + check := func(key fyne.KeyName) { + t.Helper() + list.TypedKey(&fyne.KeyEvent{Name: key}) + at := list.Active() + if at < 0 || at >= len(entries) { + t.Fatalf("after %s the keyboard stands on no row", key) + } + if !inSight(entries[at].Label) { + t.Fatalf("after %s the keyboard is on %q, which is not drawn whole inside the list", key, entries[at].Label) + } + if at > 0 && !entries[at-1].Choosable && !inSight(entries[at-1].Label) { + t.Fatalf("after %s the keyboard is on %q, the first of its kind, and the heading %q over it is out of sight", + key, entries[at].Label, entries[at-1].Label) + } + } + + check(fyne.KeyEnd) + check(fyne.KeyHome) + values := 0 + for _, e := range entries { + if e.Choosable { + values++ + } + } + for i := 1; i < values; i++ { + check(fyne.KeyDown) + } + if last := entries[len(entries)-1].Label; activeLabel(list) != last { + t.Fatalf("Down %d times from the first value ended on %q, not on the last one, %s", values-1, activeLabel(list), last) + } + for i := 1; i < values; i++ { + check(fyne.KeyUp) + } +} + +// TestEmptyingTheFilterOfAListWithNothingChosenDrawsEveryRowAgain types a +// filter that keeps nothing into a list whose box holds no value yet, empties +// it, and asks what is drawn. +// +// An emptied filter puts the keyboard back on the value in the box - and with +// no value in the box that found nothing, and nothing redrew the rows either: +// the list went on drawing the one row saying nothing matched while it held +// every format again. Found on 2026-09-23 reading the four states of the rows +// leaving widget.List, and red on widget.List as well. A box holding no value +// is real: a chooser starts that way (the catalogue has one of the formats). +func TestEmptyingTheFilterOfAListWithNothingChosenDrawsEveryRowAgain(t *testing.T) { + app := test.NewApp() + app.Settings().SetTheme(parts.Theme()) + t.Cleanup(func() { test.NewApp() }) + + list := parts.NewOpenList(format.IDs(), "", func(string, bool) {}, func(bool) {}) + list.KindOf = parts.KindOfFile + list.GroupUnder(parts.KindHeading) + list.WithFilter() + w := test.NewWindow(list) + t.Cleanup(w.Close) + w.Resize(fyne.NewSize(300, 600)) + + typeInto(list.Filter(), "zz") + if rows := list.DrawnRows(); len(rows) != 1 || !rows[0].Heading() { + t.Fatalf("zz was typed and %d row(s) are drawn, not the one saying nothing matches - the state this asks about was not reached", len(rows)) + } + list.Filter().SetText("") + first := "" + for _, row := range list.Rows() { + if row.Choosable { + first = row.Label + break + } + } + if list.RowShowing(first) == nil { + t.Errorf("the filter was emptied and the list draws %d row(s), none of them %s, its first value", len(list.DrawnRows()), first) + } +} + // 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 diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index 93a3beff5439a39593fa4a0e05c7e5cd869a82fa..4ddc0debf143ff865d06024df256a06234a7af01 100644 GIT binary patch delta 15234 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 K6-wow2LFFwjBO+U delta 15237 zcmZ{r1zZ$e`}YM@5RvW_q@+t}P*Pezx+J7?>0D{)SYf4aL_k`=1!)n9CB>yX7MAXg zrh)xf98-@{v8HN>h`|BM6XYf`~y2O=8yxAp2 zcus94Ob7b;`A{7pH|*77uz|IZHcHkb`k9qMY+97__|P{J$?;$|da8sY?31_dl?o(r z*wlAwYaBKc6ekAqkwH6vvafbC_K=_yNoTn5fr!{nN8~l zT7l=Xv2|u=j@Ei@qaB9qVO`U+1-bJxa0m<}>_sYH^m%Lo0dZ}ZULPsr=HS4+y(OOI z%%8ZnhFgfRgz=gvW9cXf508v@T!1qEi| zgfZkz4FSG&ZElZKl;JI5Q2u;eO0cD|Q&q?CfnT8Ssa8Ya>P(YAIZvjUVg2Wi7VFD> zGy>&UF7Y@&&qj3r7FMeAI93CCje3c-RxgFYy8jvk6u7SZuhhD87p8fdo zBR##irUr$BJ^zq4EYy_{|HpF?9F!9wzOd*N^1@PBba8gr%6C8zq?WdKg-|c#Jq>v9 zG{bYWTtm0a?`i@@Q(TOj3#+Xar=ys9D_sw^gMph_Uoh*NSHKl@F7FB8dK8FDfQjqua=#i z(I0!$pRA8p+sNRwTs2?3x~iTw<0h7@S2Z>Qhi`AA}yeiiNnR9 zTZ!P6@K-vU-@FH;J&UUX&r{t|w0nQb1O`1F&XDBw>ZXHztqMX0N_m z7?dJb=_T#sJ0p3lbjyv2aBdqKO)9I*8r76u3>8hxuhUhJXTG>dQZLji)o*_Ne7DlF zbANxIfB`+RO>lBafbG`{#@LNLS@}>C+6IEmO%TxgRP^dyW$a znx>|vd}E-ajpMzz^5&U3n*nEeO%E}*%d@_Yj<QHBLRJ z`xNyJ{$YerUbC|Da=g0<1kO;RueV8_*;pX0PTPXidb&EJwDpcdCpRCnvr~pcUa|)q ztnfqZ{PgsWyK=USzqzhJJxfXA&~ADP8Zeh1C_OzLx#;72 z!dH^;pu$QkaP=%dM?1Yho~b2)w0{SMusd^IY0D*ul%oBY4>QqHw9cS!aNj0jQ66X^ z>`*w=yzz=djCwO*54k$Ftx&33z)OmIe8h@uy?-zA5!^~^3x_d`PzHyJ&cw|vDuC6+ z!n!{|RI!3Lw$pO&^@2W^=v8;l6L`!Kzk@y*#;dIc@Oy%wXg)mm zojHt>;qIzf!!lN;rskua`B^tw8a(w$$AR<9bE#8gby3mdQ+&u^8VR@!@V_1y@0ZM& zLI-Vu`NgHl^XKMFxo!#2kc#oN%Od5ux#*R_)}tfcaGAl`S?`r!G<(uj#Ls_G#K@o0 zEbX5XNS(YSW$3~l8nQA{peEwxZeLzr?zI-|#8}h8_DppmCk6k4`<6l;;e;T@we$+N zx3;>MK*!sWz1G^N6d*C9Qr(`Fl`#r4V_QeZsX9chd0T{_P5;DZU$_kVWLLUp;N+;c zPg2BpPf#h@)Nhpn27AJ$ezCMQ?Z3I{bbh=&-B@Rm9L?2p7qd1pl9wv(ZoDNKZhNu4 zn7H`S*5zS)3{#t4dF*?F&-z@;`@W~+`M+(4X5Y&$F+=hd5*6~IIv4j?fNlo{x?N^J zd0}<+89Yvd!NqY3e*F>>T*=jT&frkbQKQ&8iz3?IH z0Sj?dw0AcD(u?Gu`;7tmXGVegJkUoUHKZj|l*=5aKWV{Vaa_*$gR2b7$YaJgx2BuU zpJ-;1w37{o$>bi-N;iY9yx+4&vqA=~{n7nd4a)hy9iO&as3b5w5Qp}t-k}0N*+N2=Ep6dwa9O!+(nJp%D?k$1kneLmIWMv3@Pt_l$1gu-8rKi)=HzPf2@bKw9>ij}li(c9~TQE`L zsrxsHM?~D^PDSw;pV){rj<`-h(z2SaPEp`R-{JnO<^tBquCA3eOak0+8F21Pc2ZoJ zVRS)(^{HSLy(QlVA5*dzZaDUAPI)=~>7sT)O$}>S|MEV;sEK~+k_b;mA5TTYjzo(D zq(*@P~$S}@MZA#Zx=+*32#47 z%+WsZV}$lZw0#VCn*|K+*Bop%bQ{Qv#OO}WBiGL_<1gZx^+cBJgdUTZ1ZLZ2a?nw|4)*7}+i+A{IgtmM|OB3kIK^xZ9x^c?m2$%qjs7 zAL$vH+da;E$!C}EWc^#bzQgMN%#6c^MEyN$)w8#|=ZnGqlRZA@X;bYyVmo7wzO3;#L$urFxmYEh^Ft=F6@^?^LmS(t1 zEDLsw2-cp9`qtS;@Vij%?>P^jot+`yP%Q8I9BqT!Rz1A7!e+O{1AHtj7EI(d>3zu| zyF8@AHr#Muk2+(itZc`p6k&v>72W|7FejrILF1Qp>AY!E-s`%RhGh-YTO@2!^=^yH zm*?()y`%W2>|mhmPDmu^bRX3+Bs9CSl9Bpm{mdKHALw>*5;?z<81Sj52X((t1AMkK zUm^2OE#vMhohF}M2i(XXxvuQA|2Uwo3`FGclMt6(N`-7s`n>m$ojnx{W6hF&%e^+M z2g2*onGa~5ZjJA!&u$B${YAa#(iZ3#I*ikNS-WWdElPNEgi?dur{@NDn?7peQY} zABd-`5TaHnf$h{rUfD@XN*1aI65};{Y)+_F`mm+=aVEXH&uCa?mJAOpK2uCn%u9Oq z%s~hb>R>5E?IuDCGi)*}o2&`ko=JcinxG}1xmB2CDusza^S&uN)8U`25d%xFI^1%c zZ1%9LGA`%h=JuT7oTI15*U(rG|IsHK*&&Y7;#QOKWTCgiE8>qGHCcnc5PQB&_jIjwV)nC6R4uDB#Tk5rn_=JQ!b{F&p zvL##iHiwEdY7_T&=6T~37Q2#G)6x=ZogcoX?F#<)ef&A{qUgO=(XR%u-9y;rmDXf2 zV)YZ^yrV;bUfP*X;$$m|q;qo9D}X8)*)e78>$tILe;Da=_fOVj6+am&Bw z5GxibwB`9R3RGuYa=7krTAf|%G8=28l>8M+O;0~QHD&vhX7@XDC$OPBGm&A094Vi) zloqtXYaZENVj7DOW+3VG-2h#w5;RyBH}#Fd^?rE84KZJc1`AV)=4+Xo&#P8`NWlKj zw!jkgrxLBM77Psy?Tn|3``_nR51I*Oo@PX01BF@xhq^sFER&6#VW6Xn)5x7RZ2(_A z4*Li@O)US{9fVtZcU9lE1kd-omfIA{KG@ zs)G*~PD~<5^ zxaca9%A+G6v0&lpx|85A8U%&jx_)UuG8Hp1R3jQ2?LgHA*~$^0C|@0|V(zmHK554zuj+?KMdUI^uF1$9CLIv)u=>n=UW0_UNy( zG&e4jEStRl9zxsFQuf#DAd~5ujdg5g6%}gg=Kb@8;$pp^pQO4D>N+~|&s#4tge^BW z*%TCQbbEP8_5A;6=h zFCAA+B}?Korfr`6@nd=#m9v1E%*;%nzUAdzd?mK>6v6L-oX6Eudp=o%x)Wm%zb;s} zm%%X+MjmYm;9YJpm?vQujQslb(qTO}I~x%fcODU0QDF!ZaOc|annYjD9v>Y&5oXx& znF@ZUr#D&@AOFn8=F1n?qze~*&$TOj2IrEBtNNlM1Ds0Gl0SZ=tSMCUoPtd610<75 zuz)lA_aM=S4_(o5lngmIx7C+W1HdTW(=PZ1jxB5fzitgV9p}%Mv*5~7x0!;!C`8n{ zuvKk+vP+*vdkdtgvikd<3kb^CkVJ*`S@#SKK%r26xjPgRdF9sS<*Z?!iwq{mzE$MT zPgYftMdW&WW>M2On;JD3%w(`Q2#lvF=R4NGFLkwx-cJFwB$}n(FwH>#ov$XOeAde% zDua{b0#D`6nXb!&)zFLi{F0WE0xtI5U8r4ZGFa|SdsJ(&HC30FCoEu8{cU1f8oUq>xr+1MZ@8SX?YjpK#AS%~55Oerc&kHuu-u+)GVOb2~fuOj930 z;2eCVzS(IKb$N->8Fr}D=P_*r*3(NYe|L)hMHV*l7)fT)@xw6hBKbVcxHgq!xA`CWTU%yJi;yq+T!=!>TIcCz@b_giO&!ww zW^|7nQ>8mfudWWiVLE7g={9e`@Q8Ew1=(cncA^dN8#^5HpgQfxP96vK5)c0#nsg9a zMmfGQIy|qQ!omo1%6V5gsY?nW?tHrwUBQ`cRK8|H6_!$I) zmz2pj{JOx~6EGvG>->MGQN(ZI3Njc#Lhk@yZ|#I!C*uNV>gj{fR@>;H430&Scxlux zuGTLMDI$6{1D_x9Z>k&MEr|72J!6VdtNG-b$AqPv>eJe!cR`?X5Fhr2y2&CjeV**z zU8uGqIl@kO4L5??dguFU{oS^W33pYa95KQ&_x5LdXbB2Qltdx;J7kbL{2XvhK9b{B=B&oZVp4C;C`#1N7Gs!@z?x+?QB|6d@OC1U zpuMUpH6_KHARh+PF?HslquazLz~>n5e}Q9i%piW`EF!^t48@4RBZdCubNjYX4dB)F zWFg+K9LqO$*1?(gYIu}1|N4ufv-9J6dJ&J6GKbOSt=X&L^+SccP8(}HgwT={g#Q7_ zQz2^6sGk=|BudImPej6MGT!{>K-M{OsA#dCKC$BB*tq#(c(_C%_a&i>j|8uw?$M8M zUL@=FrK%Db$e$L_faY>Uq4ei?hOvfY*zRtEW#wug6C0^tUaelO(|LDF#gol9J>C5S z;0n8$z;n}_^m-rU3ps>XMuvB6d^1#iHn!7d=@{C7b(aq8U;}#EQB_qpwT8aTop}2M z>R<#0&o=q%O0}GI^;{*o`q)}AXR6PtvQk7w&TegQ@B4^xNC!FGzuTkVZ6#3PTl-SK z#cfd$(a71N2$B7t8)Ai35yyNozLAT!v$BUlhKMEy9a!T6-ufO4ZiT}KlHqq4fBukJ z>rEqAT4qSgIM>zDQQH@YOSc)dF;JI)+D@e6FYTvom z-@1c6rT7a`y9J!jdUDdt*3K@;WjJc|Jh_-7CZ?!6s|&SX>%!ZkcHQEHGHYS1k*tNS zYU<1a68yWSt0yOlxi51T$97LPvcy|r1 zb&nE!HHmz6)u%9um7;{z-Ex&;$UVNH%(Oc_n76H}0Y05;otT^Ze&(m?O#H`3%9cGl zUY7%Xu@0%{$Rc`JWD?fFsOur(t~j7LR4)-S5Otg$jDEr=}{_WA>J`tb7= z=C--k)=)^u3D%>y!QIrSy>eJ)-PG*n1>pClF1)kb`*WLhjVIrq;Uh0*lib|~(Kd*! zS&h!coRVSt-LdAnx`FEPV^-FV^}~*d3Gb$+igw}uHs4GX`e$a)7Z(&7*;eTB?ZPLV zDbsCjc|J~Nd4u`}r`Eow>%Z(hJU*FTv4p+kaY^Yh%GHrB`l`v{^z-V!O|n%&kPJ?{ zMUg&NyAgIbO1iG`!q@X~vZSO$uOfm$D!|>`oWJ=i^b0=`nR2ExkLa|m+is8XR@>^T zhQFVmx_VG%*5$^^3MJ?v_P_6_a;8EGe1s!oxoQQXa4h6*q|*^P((!b@;*bMR!agfaRSTD@foy~ z)PI+!Zn}$#%9jn|E3OnIF71DGknrOkBeRmglS9q?Dg>s+*!Z}K8E*#^s8{{}sM}gR z(F6t?P#wWSSs?IgYa5U)f%4`sFkHF1x>A}c1I5VV8eUHGfNV{#Cv-aKl-LZf88n)3 zW~p~tW+XFH(fA*?h6*oWrQ%_cf-T{v6wu5Tw3BlxG_|>Sm`4T&Fu5U+?G_(?QZsbm zTs;r%cogPBfb^aa!7 zMXT`%j?wi-c5-9e+eyEC8`>@*yxU}dhgxcAK;26zDo~yh`v7O>5jVexq!w56T_pTi z$Vma+&5Cfo3fh`tn|9WN&SBmr10!VBpPs{tJ2Z08znX&Pz6XyArVUh$SJcD<6DLQ? z(4h>>kW3~_ruC8HOst%#K;K9ljbFZ&XVT)9V%~ftL7%aDg)TV7#ho=$P&5-huZnL2 z5`na((e@)<|AG2RGU4OjsN-f;t9?&g7Y6S*NaY;=$_+jA%i|li$G_NezB-$!Dm^_-c=e>&twq1l&S2!nkIrH-?%4t;KNOejY35)Gu-;!hKz5?n z4l~haKOPB3=vP;}=y9}g@tBg!yjK>ciL0O81_yv09l6pn#ldLzQ+nw0&^zR!u^*8@ zaiXH`nFx=J7?0shnhM4)yW!Im?msA4*A?+*3k=X6t8b1;2pTM+gvz-QaB$ zl<2QJK z>ztBAvF1qEGPQo?=EA3C=G&5~VhdGI7Nj1C7a_*tp)XH=-($kYQn5g46 zCJq-C&nxvy#~pz%SN8)bQSKd55m`8uCY(sdb|SW*uXI*u?$rBAWpi+)-fd${+S%#azQ-2{}g4jg|EP=w2;EX=UvC4kcd^A=@zRCdI<3uWqgL*p z@lc;gcUjUtD18^9Q~rQa)wiSWqg*%WLbFbbxELf zxp#0<|GL9OB~gx~MBZt$a@^Zn)-TibW!sv%2e=!0#Q&IH)Td^QgRqkA^e*BOa zh`~-b9Glc1WCYxTFg_q*8+Zx=;;y{|U>!}ZQGCVL**})qo1RnxKR-Xj98psfw8=Eb z7|KVEL8@VcC%V79G&27whK3f8uA5X_9}X31^O|lrJM4E;L?)<_@H8S(H>qAmpJzd>c+Ar38D?sFUWGaB2_+Ca|Y9(0y%uF+>y1Ke& z`Q>1#KU|IpM{!ThDhx;^>$dno#A4QY(Brv$>=)j8YoxHq`PWPq% zJsTX?^r^j}B_C zj6ouMoipmm;j!}6s7-sBcWZ0Kf#=P(wkM^ZtPBK-V6{0-faD|aQnya(+Bn`o~;1wv0P`wcFmdi1jGros! zXwguqA!?k?KbNd0BUWFM=GPt{zhDM|k0%P$w*4hKUwM8jEj^fN@)1a*UAnxk9`HW0 z>t1AA!m0W|Cp{J+Za8v@B(AvFrvHS4lM@i2n3=Wo48ITJGA^&+1L>MfJ)Lo;-o|=Hy&ip$b|2{dmlp{M2URrNpxKUO?=e zBsUMws;a8CX6W7zkcAc4(GMIe#(#>3022-r%1Z23hjTq}B}ioA34fr(QTtgCZ%EeO z-tWiS1yjv|Mp7pZ9|!e?&gO1oUJFhl2Um6~Ev0{2RLaMYFE97oJ4>-s{E;(&Z9aCv zH@FO{J{J`&jg*msgt&=trl_g4mKJ1zuyyT|C+prDC;AnmvyHx%S7_b}lSNWH^`vFe zRhu)?RcXM^9cA4XdAm4U$o-}&-|jVO!?XMnicJE73=bF%78U8wIDN7FSf(mjsxu0o z33$&=mF07{NRu#`u?sA@$KtxiY$~I{U$MM#0xgo=?INa|o#V{262Cb6H$6HD$YE=e zgi_=X{HyQwRGJCZ5y!ju=JW6xF!c~=^yS;Vv0K%ZPy#Hhf%QFW-)gZv>+3I9FCp`f z(!g1A>KcuS6C*o2NoX|M2X`P_NBHT}#e4s5a02!x$}Bu{aL^LU%j@BJ$m&X9B}{wW|z{QnGRV0eoWOjK}B4*_j653~P@} z1pljp_|Io-JW<7j_)O_5a|u{^ZLPP31>jj)_&J6sY;kl((=uF}ikqb+?UJiH-BvU2 zp#I7T<#gQzJB@M2r=~URO38vYGYphIf-|;*^KDp>_)X**tfK#W+sr_M8>iOR+AMYf z5y5>%B&beie(L1JHsBjLFfc7Gji9Eswr2@@*VH{GCWnU?yuUJdyc_Xf4*tJg{^}X5 zqG~zA!;!Tv;H;j*fdO#9?t(Gv{#jp!nPkv~Y(O&@W_URP8`z%q|1xk~T4JDAT@KC1 zpd>s%s}c&xAMd(09~7&b;+JH5J}jdot2iI z9j)RVc>n&sTO1&5fTHpKtF;Q7x|9&gFhgqJPC>A zxT6_P?khu1(*6fveEbMP9^((2TU(EzwF?B=oi4KYNMI%Pue%41Xv^i~`1oRif~C#z zmEMQO##_A*Hk#w@irzjlz}kw4h}fDkKwkzAfcN$}{g3yF?dZO{U5rNtV-P@=YrYmv zJa}qs!#E}D&pstd+MC;EN=$kOds%vC2n^R|BWjCzIG-Csf=?_k%hv69umyV%4MWVtVei8Ate% zel}lE3N4EwTod&E=75q`RwukYzOm?dv;W55MqptDrgRg~ z&}5Y9I(Qwe&&&|eO9hArRX{NgfSkKyvDmm*8*-%rOGcDMOAdKi#nsoQOO*hB^p72N zQhDGa$Bmd1gD=(l>s?(oY|z$~71XZfe~*5-B$C_D^Q#PtN;|wqr}t3|1&GzArY6CG zzFy$PQ}wG5sja-Mt-Ph7G2q{BVdqV8-F*Qi#Xov6ila;EG(A8qDp2j7Q$T<~^DF4{ z4h+)!b(bui*SFXZ3x#Mdd;r%eAwMnLul{*7*J`x6xw&idrNs!;9-=?4FauSEy1<+QYCb9-#&zR}08 z=Qh~9Ugg4`4oT+RqEU=oICqiF-usr7FwKOmaYSt_n{x)rFGVNR8{pF1uPMVZSgU_x^ zopI1*J!FyIL5RxJ6a1_2Bb5Ic2IZT@3dsI)M4Ou=eM0RQSM905pzP!$!>^nU2 z%Q;=?>awaz6TENT$MN~|iB=(E8M)Ha-Rm$?%8Ge<>bta*6V^6gqDMy^SMbq-gs7{3 zWP7TvZ++9rxn}*)4`K&1&5>ug2ifH&HCZqO>6Tx7{^r9n9c3*d+rCd!Ph0vTjNu}I z@8Ds9vU+NcRG`-t+NGof%o@gJu$QaKn%DBK!E0;EH=}IzWY_FB0x@h)#`x)*Px$bJ z&)U!`r(QX|_>r~Fy(xE+t+RLpT`nk zviDX&5x)xn(aTxUhn>>^uLjw7{NDgs6I+Th;`i?`8W3RjPlznwFoJxE+xV!csMJw$ zxx0B~Wt#^|kfd}{9ky~7Hd=f=9lEWx`X)z%B={>XXM#vVEkpZyca-y+rDNTavfN-v z*SQ940{U_jhmXgV#CUgClW${&uK$#~C+VZiy!Z9ZPd-%{OcLA&zi58!YjEEO`Z}rR z&pITb%ta7bXV@It1M^r-mh^8X9(4V<-Rn4W5kAZCxlGb??-L6tuG4h=W8vdKKL$?a ze9#_J>8rSqCS{}Gf|XV_N;JNw`}ZK$$6@;wVkl7uCWVe{{e;L1z5VmcNPN2SN=p@M z>wo z1Y3hlv(49rJZc*v8|Mfh&v{6}>Jm!oL+<$W5*7kwV(!bw!u*-Jw^1nN+?T(X_I)Ji z>uHH{25evx%F4P*%*p-zH7xEeasEht|Gt!( z^5mE-i(kgQI1;{d+Ea7>?SnXh3AY@@O%mrdQ z%bGNIx|63keR(P6M@XVu0ys^aCTsXbMcWs54a?$y46m~M8U2)seBiXS?ZGFMF{){M z<*HAg`$S2P2vAG^)w2tzM*#DK)BrZg}vN^B2Qj`)q}2~gkUwKdg!wb ztr+#{_dJEzAudrH<~{dy)?paAqc6B$en3`2ug-Tjf)uKG7bBhu!|@5<1{)LHZ=4e7=@{+u`t+9i_}Gk zN!{;)^*UMEN7dEtPEKX?8m7#|fb7`S^Kf_5%uzh>?6R*zlV|CLqHJ1l zJ!V>@X%k#p6#%{J&X=pMq&BYxlR$fDT7jtK{;=ue~W z@}G*=eE3Ajq8x40Qy~zb@?n<@33o#gsc$xSO#ig15#afWN=8gg4UKwNm#mjeOi=d- zC@Ne4PUXQtHv`F8g!9x&MR_?F2Zy6$DIk)Nlnl)MwE)O^_(TkJJU8Hwm-6!CWk$0s zdx-_(j)q)Eg=gCQYTAe+!#F4*&23UJJ_|~oMNv*!C1!GxSIWP}_x$L4mUy#1*T|sT z_&WEif#a{hJU%#x-P9xuh@3!ncB@Eu1k!2u99;C7{n+pR;_evI>f_9Z0z1lx&P81? z2M<*JZ+D&O|9S8BYXIN=SOXaP^oL0NBGf`CD)G^%vfZzj>@T9gRP{m+WLiUBx8{DM zSVaQ*Wa*!|4?PGuJV5LsFD8^ipcOU+ciaFFO5ij|B-^ z+eQL_W5bvcbaXrr%65A2vz%@j_eH&)9tZoyEk&Ug>z!c6yqej@JP`n#f4-C`dsur$8%E zTe||;rrX=yt>>$B{m+H<_ddpwA}39?$0|YJmGId_Gm%0|%!)Xg=V|H7szslm%Jq#6 znXev|3{PwanM%mYCRC3{0*-!8vb>54VQ3E!KLYL-OSq8Z59I|!7wjg+dixT*P4ohkx91L((Sr>ndo;-7)%2MD=+}(m zVtJ+{)|SY(qEBRS2Hc${C8XMb|2AR&%O9Qe*l80<^pTmFC`yHPK8`)Czk{t4Ou{F` z=qu>=YwtX;_bxqv?%u(G-BG{nI&G`{>&u-7FH`F7s^W!I7AgEg5?6Z% zi+i2!-Eg8{fTs*%tKz*(vVz=TIt*+e`Se?WlV5B_-|)x508d%PQN_cF>;>Q8Cm1+i z!S~1b=%4*w^#GLOwNQ-2`()6oh6i_`)WJAxc%k>#0&Y@24+c_0F);3_;@zp!h2L;B zjC%lo5Df6#Pundw+#vXxQ(@q$;(eG=iMio#FmM6R5)5!j{@hPD+%5Q;^JCms#q0kI z^7DoVV%!I~WH7+xei+}q;ZecYTnPhD6_1{7bm@ktVBi5Ac}(7!LsM6b$e;nL|1^ye{~f`(og$;u+>M7~b%9419ox2Lt>Av48mu9}EUK zV zLV&}90p7mKc6!4xLauo$29YWrNttxU4S#@11pM^eBUQY2zo8#)FjWYEq2n0Ds(4~M zQ2ZOtf=LYUrC@;nRo&Ec!}&w5`7Q>DDxPzH>f{ZV#3TXuWiY_YI!e}WxKhY9zlTYx ziud_aBjSeZV3GoyECk^6F0>>!+&tu(Gh&jd;z1NeD{r_HCKfo-n5IO+YJxLBnP;B2*BSkKI6OLNg>x<6O%#}4|(UC$_>xLqyV@{2*A(s`2IPH zibAgW%N`6>yhX|(!JG6jOiF-zhES^F>EpNmvj???T=QT|DpkCt$)J#%^nI9A0FMg+ zIJS10`VAisx#q4f?=wLgFsV84@@Av>Z%!^@QUfRRL#S2pqJ&dDZuoA - + - - - - - - - png - - - - - - - jpg - - - - - - avif - - - - - - - - - - - - - + + + png + + + + + jpg + + + + avif @@ -933,40 +912,19 @@ - + - - - - - - - png - - - - - - jpg - - - - - - avif - - - - - - - - - - - - - + + + png + + + + jpg + + + + avif @@ -988,68 +946,66 @@ - - - - - - - - - avif - - - - - - bmp - - - - - - csv - - - - - - docx - - - - - - gif - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + avif + + + + bmp + + + + csv + + + + docx + + + + gif + + + + html + + + + ico + + + + jpg + + + + + json + + + + jxl + + + + log + + + + md + + + + + + + + @@ -1069,134 +1025,68 @@ - + - - - - - - - - avif - - - - - - - bmp - - - - - - - csv - - - - - - - - docx - - - - - - - gif - - - - - - - html - - - - - - - ico - - - - - - - jpg - - - - - - - json - - - - - - - jxl - - - - - - - log - - - - - - - md - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + avif + + + + + bmp + + + + + csv + + + + + + docx + + + + + gif + + + + + html + + + + + ico + + + + + jpg + + + + + json + + + + + jxl + + + + + log + + + + + md @@ -1218,32 +1108,16 @@ - + - - - - - - - Write a label inside each generated file, including the ones that are far too small to hold it - - - - - - - png - - - - - - - - - - + + + Write a label inside each generated file, including the ones that are far too small to hold it + + + + + png @@ -1266,130 +1140,168 @@ - - - - - - - - - Archives · 2 - - - - - - - targz - - - - - - - zip - - - - - - Documents · 4 - - - - - - - docx - - - - - - - pdf - - - - - - - pptx - - - - - - - xlsx - - - - - - Pictures · 10 - - - - - - - avif - - - - - - - bmp - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Archives · 2 + + + + + targz + + + + + zip + + + + Documents · 4 + + + + + docx + + + + + pdf + + + + + pptx + + + + + xlsx + + + + Pictures · 10 + + + + + avif + + + + + bmp + + + + + gif + + + + + ico + + + + + jpg + + + + + jxl + + + + + + png + + + + + svg + + + + + tiff + + + + + webp + + + + Sound · 1 + + + + + wav + + + + Text and data · 9 + + + + + csv + + + + + html + + + + + json + + + + + log + + + + + md + + + + + toml + + + + + txt + + + + + xml + + + + + yaml + + + + + + + + @@ -1427,144 +1339,109 @@ - - - - - - - - - - - p - ptx - - - - - - Pictures · 10 - - - - - - - avif - - - - - - - bm - p - - - - - - - - gif - - - - - - - ico - - - - - - - j - p - g - - - - - - - jxl - - - - - - - - - p - ng - - - - - - - svg - - - - - - - tiff - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Archives · 1 + + + + + zi + p + + + + + Documents · 2 + + + + + + p + df + + + + + + p + ptx + + + + Pictures · 10 + + + + + avif + + + + + bm + p + + + + + + gif + + + + + ico + + + + + j + p + g + + + + + jxl + + + + + + + p + ng + + + + + svg + + + + + tiff + + + + + web + p + + + + + + + + + @@ -1599,19 +1476,11 @@ - + - - - - - - - Nothing matches - - - - + + + Nothing matches diff --git a/internal/guard/testdata/screens/generate-menu-hovered.xml b/internal/guard/testdata/screens/generate-menu-hovered.xml index d4d7039..6e557f0 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -489,239 +489,168 @@ - - - - - - - - - Archives · 2 - - - - - - - targz - - - - - - - zip - - - - - - Documents · 4 - - - - - - - docx - - - - - - - pdf - - - - - - - pptx - - - - - - - xlsx - - - - - - Pictures · 10 - - - - - - - - avif - - - - - - - bmp - - - - - - - gif - - - - - - - ico - - - - - - - jpg - - - - - - - jxl - - - - - - - png - - - - - - - svg - - - - - - - tiff - - - - - - - webp - - - - - - Sound · 1 - - - - - - - wav - - - - - - Text and data · 9 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Archives · 2 + + + + + targz + + + + + zip + + + + Documents · 4 + + + + + docx + + + + + pdf + + + + + pptx + + + + + xlsx + + + + Pictures · 10 + + + + + + avif + + + + + bmp + + + + + gif + + + + + ico + + + + + jpg + + + + + jxl + + + + + png + + + + + svg + + + + + tiff + + + + + webp + + + + Sound · 1 + + + + + wav + + + + Text and data · 9 + + + + + csv + + + + + html + + + + + json + + + + + log + + + + + md + + + + + toml + + + + + txt + + + + + xml + + + + + yaml + + + + + + + + diff --git a/internal/guard/testdata/screens/generate-menu-keyed.xml b/internal/guard/testdata/screens/generate-menu-keyed.xml index ce19722..aaed063 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -489,239 +489,168 @@ - - - - - - - - - Archives · 2 - - - - - - - targz - - - - - - - zip - - - - - - Documents · 4 - - - - - - - docx - - - - - - - pdf - - - - - - - pptx - - - - - - - xlsx - - - - - - Pictures · 10 - - - - - - - - avif - - - - - - - bmp - - - - - - - gif - - - - - - - ico - - - - - - - jpg - - - - - - - jxl - - - - - - - png - - - - - - - svg - - - - - - - tiff - - - - - - - webp - - - - - - Sound · 1 - - - - - - - wav - - - - - - Text and data · 9 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Archives · 2 + + + + + targz + + + + + zip + + + + Documents · 4 + + + + + docx + + + + + pdf + + + + + pptx + + + + + xlsx + + + + Pictures · 10 + + + + + + avif + + + + + bmp + + + + + gif + + + + + ico + + + + + jpg + + + + + jxl + + + + + png + + + + + svg + + + + + tiff + + + + + webp + + + + Sound · 1 + + + + + wav + + + + Text and data · 9 + + + + + csv + + + + + html + + + + + json + + + + + log + + + + + md + + + + + toml + + + + + txt + + + + + xml + + + + + yaml + + + + + + + + diff --git a/internal/guard/testdata/screens/generate-menu.xml b/internal/guard/testdata/screens/generate-menu.xml index 4ba61bd..d91504e 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -489,239 +489,168 @@ - - - - - - - - - Archives · 2 - - - - - - - targz - - - - - - - zip - - - - - - Documents · 4 - - - - - - - docx - - - - - - - pdf - - - - - - - pptx - - - - - - - xlsx - - - - - - Pictures · 10 - - - - - - - - avif - - - - - - - bmp - - - - - - - gif - - - - - - - ico - - - - - - - jpg - - - - - - - jxl - - - - - - - png - - - - - - - svg - - - - - - - tiff - - - - - - - webp - - - - - - Sound · 1 - - - - - - - wav - - - - - - Text and data · 9 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Archives · 2 + + + + + targz + + + + + zip + + + + Documents · 4 + + + + + docx + + + + + pdf + + + + + pptx + + + + + xlsx + + + + Pictures · 10 + + + + + + avif + + + + + bmp + + + + + gif + + + + + ico + + + + + jpg + + + + + jxl + + + + + png + + + + + svg + + + + + tiff + + + + + webp + + + + Sound · 1 + + + + + wav + + + + Text and data · 9 + + + + + csv + + + + + html + + + + + json + + + + + log + + + + + md + + + + + toml + + + + + txt + + + + + xml + + + + + yaml + + + + + + + + diff --git a/internal/guard/testdata/screens/preset-menu-setting.xml b/internal/guard/testdata/screens/preset-menu-setting.xml index 4c6533b..3dc1f4c 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -452,239 +452,168 @@ - - - - - - - - - Archives · 2 - - - - - - - targz - - - - - - - zip - - - - - - Documents · 4 - - - - - - - docx - - - - - - - - pdf - - - - - - - pptx - - - - - - - xlsx - - - - - - Pictures · 10 - - - - - - - avif - - - - - - - bmp - - - - - - - gif - - - - - - - ico - - - - - - - jpg - - - - - - - jxl - - - - - - - png - - - - - - - svg - - - - - - - tiff - - - - - - - webp - - - - - - Sound · 1 - - - - - - - wav - - - - - - Text and data · 9 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Archives · 2 + + + + + targz + + + + + zip + + + + Documents · 4 + + + + + docx + + + + + + pdf + + + + + pptx + + + + + xlsx + + + + Pictures · 10 + + + + + avif + + + + + bmp + + + + + gif + + + + + ico + + + + + jpg + + + + + jxl + + + + + png + + + + + svg + + + + + tiff + + + + + webp + + + + Sound · 1 + + + + + wav + + + + Text and data · 9 + + + + + csv + + + + + html + + + + + json + + + + + log + + + + + md + + + + + toml + + + + + txt + + + + + xml + + + + + yaml + + + + + + + + diff --git a/internal/guard/testdata/screens/preset-menu.xml b/internal/guard/testdata/screens/preset-menu.xml index e658e2b..af811ac 100644 --- a/internal/guard/testdata/screens/preset-menu.xml +++ b/internal/guard/testdata/screens/preset-menu.xml @@ -395,59 +395,28 @@ - + - - - - - - - - empty-and-minimal - - - - - - size-boundaries - - - - - - tabular-import - - - - - - text-encoding - - - - - - upload-validation - - - - - - - - - - - - - - - - - - - + + + + empty-and-minimal + + + + size-boundaries + + + + tabular-import + + + + text-encoding + + + + upload-validation diff --git a/internal/guard/themescope_test.go b/internal/guard/themescope_test.go index a48c840..184255e 100644 --- a/internal/guard/themescope_test.go +++ b/internal/guard/themescope_test.go @@ -24,10 +24,12 @@ import ( // area, caption and count of bytes was under one. Without them: 2 sets, 27 MB, // and nothing added by the rebuilds. // -// The open list of formats is not on this screen - it is a popup - and it -// still draws its rows under one (parts/openlist.go, rowTheme). That is the -// next thing the same document names, and it is named here so that nobody -// reads this guard as covering it. +// The open list of formats is asked too, since its rows left one on +// 2026-09-23 (section 4e): every opening was a new override and the letters +// its filter makes bold were new strings, so ten openings kept 158 MB. It is +// a popup rather than part of a screen, and it keeps its rows inside its +// renderer, where the walk of the screens does not go - so it is opened, typed +// into, and walked through what it draws. func TestNoScreenStandsInAThemeOverride(t *testing.T) { content, _ := laidOutWindow(t) @@ -51,9 +53,21 @@ func TestNoScreenStandsInAThemeOverride(t *testing.T) { cat := test.NewWindow(catalogue.Screen()) t.Cleanup(cat.Close) - for name, root := range map[string]fyne.CanvasObject{"the window": content, "the catalogue": cat.Content()} { + _, _, list, filter := openFormatList(t) + typeInto(filter, "p") + rows := 0 + walkDrawn(list, func(o fyne.CanvasObject) { + if _, is := o.(*parts.ListRow); is { + rows++ + } + }) + if rows == 0 { + t.Fatal("the open list of formats draws no row, so its rows were not looked at") + } + + for name, root := range map[string]fyne.CanvasObject{"the window": content, "the catalogue": cat.Content(), "the open list of formats": list} { overrides := 0 - walk(root, func(o fyne.CanvasObject) { + walkDrawn(root, func(o fyne.CanvasObject) { if _, is := o.(*container.ThemeOverride); is { overrides++ } @@ -64,3 +78,24 @@ func TestNoScreenStandsInAThemeOverride(t *testing.T) { } } } + +// walkDrawn visits everything a tree draws: into a container's objects and into +// every widget's renderer, which is where a widget keeps what it built - the +// rows of an open list among them. walk stops at a widget it has no case for, +// and that is the half this guard needs. +func walkDrawn(o fyne.CanvasObject, visit func(fyne.CanvasObject)) { + if o == nil { + return + } + visit(o) + switch v := o.(type) { + case *fyne.Container: + for _, child := range v.Objects { + walkDrawn(child, visit) + } + case fyne.Widget: + for _, child := range test.WidgetRenderer(v).Objects() { + walkDrawn(child, visit) + } + } +} diff --git a/internal/gui/catalogue/lists.go b/internal/gui/catalogue/lists.go index 0f9b6d8..a0df300 100644 --- a/internal/gui/catalogue/lists.go +++ b/internal/gui/catalogue/lists.go @@ -20,12 +20,12 @@ import ( // list about a short window. // // Nor has it a width of its own. On a form the list is as wide as the box it -// drops from, and the toolkit's list measures its width off an empty template -// row, so a list stood here bare was a 42 px strip with the first letter of -// each value on it - seen on the render of 2026-09-15, and accepted with the -// rest of the catalogue before anybody read it at that height. Each state -// is drawn as wide as the box a menu of the same values would be, which is -// the rule the form follows. +// drops from, and on its own it is as wide as a row with no words, so a list +// stood here bare was a 42 px strip with the first letter of each value on +// it - seen on the render of 2026-09-15, and accepted with the rest of the +// catalogue before anybody read it at that height. Each state is drawn as +// wide as the box a menu of the same values would be, which is the rule the +// form follows. func openList() Entry { few := []string{"png", "jpg", "avif"} many := []string{"avif", "bmp", "csv", "docx", "gif", "html", "ico", "jpg", "json", "jxl", "log", "md"} diff --git a/internal/gui/parts/listcontents.go b/internal/gui/parts/listcontents.go index d874e5d..423f1b7 100644 --- a/internal/gui/parts/listcontents.go +++ b/internal/gui/parts/listcontents.go @@ -1,12 +1,8 @@ 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. +// the rows on the screen showing 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 @@ -33,25 +29,21 @@ type listContents struct { // 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 + // view is the rows on the screen. Asked rather than walked, because a + // walk cannot get in: the rows are inside the list's 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, when the rows were widget.List's. + view *rowView } // 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. +// changed, and forgets the rows on the screen until they are filled again: a +// row filled 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{} + c.view.shown = 0 } // Rows is what this list is showing, for a guard to read, in the order it is @@ -80,16 +72,18 @@ func (c *listContents) Rows() []Choice { // 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. +// DrawnRows is every row in sight, 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) + var out []*ListRow + for at, row := range c.view.built[:c.view.shown] { + if c.view.inSight(at) { + out = append(out, row) + } } return out } @@ -97,7 +91,7 @@ func (c *listContents) DrawnRows() []*ListRow { // 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 { + for _, row := range c.DrawnRows() { if row.Label() == label { return row } diff --git a/internal/gui/parts/listrow.go b/internal/gui/parts/listrow.go index ee31ca5..15ef90c 100644 --- a/internal/gui/parts/listrow.go +++ b/internal/gui/parts/listrow.go @@ -17,11 +17,12 @@ import ( // and draw its own surface for the three states a row has - plain, under the // pointer, and holding the keyboard. // -// It is built once per visible row and refilled as the list scrolls, which is -// what makes the list cost the same at thirteen values and at a hundred -// thousand. Measured in tools/probes/fynelist on 2026-08-18: widget.List is -// flat at 0.1 MB across 1000, 10 000 and 100 000 rows, where a box holding -// every row costs 449 MB at the largest. +// It is built once per position of the list and refilled when what the list +// holds changes. Once per VISIBLE row until 2026-09-23, inside widget.List, +// which is what made a list cost the same at thirteen values and at a hundred +// thousand - measured in tools/probes/fynelist on 2026-08-18, flat at 0.1 MB +// where a box holding every row costs 449 MB at the largest. Why every +// position now, and when that stops being right, is in rowView. type ListRow struct { widget.BaseWidget diff --git a/internal/gui/parts/openlist.go b/internal/gui/parts/openlist.go index 934fedb..5cdad04 100644 --- a/internal/gui/parts/openlist.go +++ b/internal/gui/parts/openlist.go @@ -1,7 +1,6 @@ package parts import ( - "image/color" "math" "strings" @@ -61,8 +60,6 @@ type OpenList struct { take func(value string, byKeyboard bool) close func(byKeyboard bool) - list *widget.List - // 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. @@ -77,13 +74,8 @@ type OpenList struct { // 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{active: -1, take: take, close: close, - listContents: listContents{options: options, chosen: chosen, rows: map[widget.ListItemID]*ListRow{}}} + listContents: listContents{options: options, chosen: chosen, view: newRowView()}} l.entries = arrange(options, nil, "") - l.list = widget.NewList( - func() int { return len(l.entries) }, - func() fyne.CanvasObject { return newListRow() }, - l.fill, - ) l.ExtendBaseWidget(l) return l } @@ -125,12 +117,7 @@ func (l *OpenList) Filter() *FilterBox { return l.filter } 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) + l.view.toTop() if strings.TrimSpace(typed) == "" { l.active = -1 l.StartOn(l.chosen) @@ -141,18 +128,15 @@ func (l *OpenList) narrowTo(typed string) { return } l.active = -1 - l.list.Refresh() + l.view.show(len(l.entries), l.fill) } // 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 - } +// given is recycled - the same row shows whatever stands at its position in +// the arrangement of the moment - so every field is set every time: a row left +// holding the last value it had is the classic defect of a list that reuses +// its rows. It is drawn by whoever asked for the fill. +func (l *OpenList) fill(id int, r *ListRow) { entry := l.entries[id] r.label = entry.text r.heading = entry.kind != entryValue @@ -171,8 +155,6 @@ func (l *OpenList) fill(id widget.ListItemID, row fyne.CanvasObject) { r.active = l.shown && id == l.active r.onTap = func() { l.take(value, false) } } - l.rows[id] = r - r.Refresh() } // Choice is one row of an open list, for a guard to read. Choosable is false @@ -217,7 +199,7 @@ func (l *OpenList) MinSize() fyne.Size { if height < head+listRowHeight() { height = head + listRowHeight() } - return fyne.NewSize(l.list.MinSize().Width, height) + return fyne.NewSize(l.view.scroll.MinSize().Width, height) } // HeadHeight is the room the filter box takes at the top of the list, with @@ -252,10 +234,12 @@ func ListCeiling(canvasHeight float32) float32 { } func (l *OpenList) CreateRenderer() fyne.WidgetRenderer { + l.view.drawn = true + l.view.show(len(l.entries), l.fill) // 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. - rows := container.NewThemeOverride(l.list, rowTheme{}) + rows := l.view.scroll if l.filter == nil { return widget.NewSimpleRenderer(container.NewStack(floatingSurface(), rows)) } @@ -263,52 +247,19 @@ func (l *OpenList) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(container.NewStack(floatingSurface(), container.NewBorder(head, nil, nil, nil, rows))) } -// rowTheme is our theme with the room between rows taken out. -// -// It exists because a sentence written in theme.go on 2026-08-12 was wrong, -// and the way it was wrong is the expensive kind. That note said the theme is -// asked for a size by NAME and not by widget, so "it is the only knob there -// is" - and the first half is true while the conclusion is not. A theme can be -// replaced for a SUBTREE with container.NewThemeOverride, which nobody had -// looked for, so a list was tightened by moving the padding of the entire form. -// -// Measured: widget.List spaces its rows by theme.SizeNamePadding, called -// separatorThickness in list.go - the same 6 px the form is built from. With -// the override the rows sit against each other and the row height is the whole -// of the pitch. -type rowTheme struct{} - -func (rowTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { - return Theme().Color(n, v) -} -func (rowTheme) Font(s fyne.TextStyle) fyne.Resource { return Theme().Font(s) } -func (rowTheme) Icon(n fyne.ThemeIconName) fyne.Resource { return Theme().Icon(n) } -func (rowTheme) Size(n fyne.ThemeSizeName) float32 { - switch n { - case theme.SizeNamePadding: - // Rows sit against each other. What separates them is the surface a - // row draws when the pointer or the keyboard is on it, which is a - // thing somebody can see, rather than a gap, which is not. - return 0 - case theme.SizeNameSeparatorThickness: - // And no rule between them either. widget.List draws a hairline - // between rows, which the room between them used to hide - with the - // room gone it came out as a line every 28 px and the list read as a - // ruled table rather than as a menu. Seen on the render, which is the - // only place it could have been seen: the tree says a separator is - // present either way. - return 0 - } - return Theme().Size(n) -} - // ListRowHeight is one row, for a guard asking how many rows fit in a height. func ListRowHeight() float32 { return listRowHeight() } // listRowHeight is one row, worked out rather than typed: whatever is taller // out of the text and the mark, plus our own room above and below. +// +// The text measured rather than a label built and asked, since 2026-09-23: +// every row asks this whenever it is sized, and a label built each time was +// about 2 MB an opening that stayed for the minute the toolkit keeps a +// renderer (docs/GUI-MEMORY-2026-09-23.md section 2.5). The same number - a +// label's height less its inner padding is its text's. func listRowHeight() float32 { - text := widget.NewLabel("Ag").MinSize().Height - 2*Theme().Size(theme.SizeNameInnerPadding) + text := fyne.MeasureText("Ag", Theme().Size(theme.SizeNameText), fyne.TextStyle{}).Height icon := Theme().Size(theme.SizeNameInlineIcon) tall := text if icon > tall { @@ -410,10 +361,10 @@ func (l *OpenList) moveTo(at int) { l.active = at l.shown = true if at > 0 && l.entries[at-1].kind == entryHeading { - l.list.ScrollTo(at - 1) + l.view.bringIntoView(at - 1) } - l.list.ScrollTo(at) - l.list.Refresh() + l.view.bringIntoView(at) + l.view.show(len(l.entries), l.fill) } // Active is the row the keyboard is on, or -1, for a guard. @@ -427,10 +378,13 @@ func (l *OpenList) StartOn(value string) { if e.kind == entryValue && e.text == value { l.moveTo(i) l.shown = false - l.list.Refresh() - return + break } } + // Drawn whether or not the value was found. A box holding no value finds + // nothing, and an emptied filter lands here with the rows of what it had + // narrowed to still drawn. + l.view.show(len(l.entries), l.fill) } // Showing says whether the keyboard position is drawn, for a guard. diff --git a/internal/gui/parts/rowview.go b/internal/gui/parts/rowview.go new file mode 100644 index 0000000..3c6d6b3 --- /dev/null +++ b/internal/gui/parts/rowview.go @@ -0,0 +1,126 @@ +package parts + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" +) + +// rowView is the rows of an open list on the screen: one ListRow for every +// position of the arrangement, against each other in a scroll. +// +// Ours since 2026-09-23. It was widget.List until then, under a theme +// override that took out the room the toolkit puts between rows - and Fyne +// 2.8.1 gives an override a new scope at construction, at CreateRenderer and +// at every Refresh, and parses the fonts again for every scope a new string is +// drawn in. Every opening of a list was a new override, and the letters a +// filter makes bold are new strings: ten openings of the format list with a +// new letter each kept 158 MB that nothing gave back, measured in the real +// window (docs/GUI-MEMORY-2026-09-23.md section 4e). Rows we lay out ourselves +// are spaced the way we say, and no override is needed. +// +// Every position gets a row, where widget.List built only the rows in sight. +// The longest list in the window is the formats under their headings, about +// thirty rows, and building them is not what an opening costs (section 4f of +// the same document). A menu of hundreds of values is where that stops being +// true, and the answer then is to build only the rows in sight. +type rowView struct { + scroll *container.Scroll + stack *fyne.Container + // built is every row made so far, kept for the next arrangement, and + // shown how many of them the current one uses. + built []*ListRow + shown int + // drawn says the list has a renderer. Rows are built only from then on, + // as widget.List built them. + drawn bool +} + +func newRowView() *rowView { + v := &rowView{stack: container.New(rowStack{})} + v.scroll = container.NewVScroll(v.stack) + return v +} + +// show puts a row on the screen for each of n positions, filled by fill, and +// draws them once. +func (v *rowView) show(n int, fill func(at int, row *ListRow)) { + if !v.drawn { + return + } + for len(v.built) < n { + v.built = append(v.built, newListRow()) + } + objects := make([]fyne.CanvasObject, n) + for at, row := range v.built[:n] { + fill(at, row) + objects[at] = row + } + v.stack.Objects = objects + v.shown = n + // The scroll first takes the new height of the rows, then refreshes them. + v.scroll.Refresh() +} + +// bringIntoView scrolls the least that shows the row at this position whole: +// up to it when it is above what is in sight, and until it is the last row in +// sight when it is below. widget.List's scrollTo, with no room between rows. +// Takes effect at the next show. +// +// Nothing happens before the list has a height, and that is a difference +// from widget.List. A list filtered before it is laid out - the catalogue +// does that - came up from widget.List 124 px down, with the row the keyboard +// was on above what was in sight (the stored catalogue tree until 2026-09-23). +// Here it comes up at its top, which is where typing puts a list on the +// screen. +func (v *rowView) bringIntoView(at int) { + if v.scroll.Size().Height <= 0 { + return + } + row := listRowHeight() + top := float32(at) * row + switch { + case top < v.scroll.Offset.Y: + v.scroll.Offset.Y = top + case top+row > v.scroll.Offset.Y+v.scroll.Size().Height: + v.scroll.Offset.Y = top + row - v.scroll.Size().Height + } +} + +// toTop scrolls back to the first row. Takes effect at the next show. +func (v *rowView) toTop() { v.scroll.Offset.Y = 0 } + +// inSight says whether any of the row at this position is inside the room the +// list has, which is what widget.List built a row for. +func (v *rowView) inSight(at int) bool { + row := listRowHeight() + top := float32(at) * row + return top+row > v.scroll.Offset.Y && top < v.scroll.Offset.Y+v.scroll.Size().Height +} + +// rowStack puts the rows of an open list against each other: each one row +// tall and as wide as the list, in the order given. +// +// Against each other is the point. What separates two rows is the surface a +// row draws under the pointer or the keyboard, which is a thing somebody can +// see, rather than a gap, which is not - and no rule between them either: +// widget.List drew a hairline in its gap, and with the gap taken out it came +// out as a line every 28 px, so the list read as a ruled table rather than as +// a menu. Measured before any of this existed: thirteen formats made a list +// 476 px tall, 78 px of which was the gap between rows (OpenList). +type rowStack struct{} + +// MinSize is every row, as wide as a row with no words - which is what +// widget.List measured its width off, an empty template row. The list is +// drawn at the width of the box it drops from, and the box is sized for the +// longest word (RowWidthFor). +func (rowStack) MinSize(objects []fyne.CanvasObject) fyne.Size { + return fyne.NewSize(RowWidthFor(0, false), float32(len(objects))*listRowHeight()) +} + +func (rowStack) Layout(objects []fyne.CanvasObject, size fyne.Size) { + row := listRowHeight() + for at, o := range objects { + o.Move(fyne.NewPos(0, float32(at)*row)) + o.Resize(fyne.NewSize(size.Width, row)) + } +}