From 198516382795438b66b553fa1b3c4fd1932ed417 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 6 Aug 2026 23:26:03 +0200 Subject: [PATCH] fix(functional): form field labels no longer overprint the field above (S15) FormFieldText treats its origin as the control's top-left - size and in_object both measure down from it - but drew the label by calling Text.render at that origin, and Text anchors on the baseline. The label's glyphs therefore landed above the origin, outside the box the control claims, on top of whatever was there. In a stacked form that is the preceding field's input box, which is what example_10_forms.png showed: every label but the first crowding the box above it. The label is now offset down by its ascent, so it occupies the space the control accounts for. Height derives from the label's ink height rather than the nominal font size, which had also eaten into the 5px gap between label and box. LABEL_GAP names that gap and field_area_offset gives the distance from the origin to the top of the input box; render, handle_click and the height calculation now share it instead of each recomputing font_size + 5. Also recorded under S12: the broken process pool is not merely wasted work. It forks from a process that already has threads, and tests/layout/test_ereader_image_rendering.py hangs at interpreter exit roughly one run in four - every test passes, then the process never returns. --- docs/LAYOUT_REMEDIATION_SPEC.md | 54 ++++++++ docs/images/example_10_forms.png | Bin 31587 -> 31342 bytes pyWebLayout/concrete/functional.py | 49 ++++++-- tests/concrete/test_concrete_functional.py | 7 +- tests/concrete/test_form_field_geometry.py | 140 +++++++++++++++++++++ 5 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 tests/concrete/test_form_field_geometry.py diff --git a/docs/LAYOUT_REMEDIATION_SPEC.md b/docs/LAYOUT_REMEDIATION_SPEC.md index cc347e8..74838d6 100644 --- a/docs/LAYOUT_REMEDIATION_SPEC.md +++ b/docs/LAYOUT_REMEDIATION_SPEC.md @@ -29,6 +29,7 @@ It is independent of every other spec here. | [S12](#s12--background-rendering) | Background rendering | 4 | | [S13](#s13--word-spacing-and-alignment) | Word spacing and alignment | 0 | | [S14](#s14--vertical-centring-in-buttons-and-fields) | Vertical centring in buttons and fields | 0 | +| [S15](#s15--form-field-label-geometry) | Form field label geometry | 0 | ## Design invariants @@ -1016,6 +1017,20 @@ a full page laid out in the worker — then the result is thrown away by 4-core Pi with 512MB this is actively harmful: four interpreter copies plus four copies of the book, to populate a cache that never populates. +It is also not inert. The pool is started from `PageBuffer.initialize` inside a +process that already has threads, and CPython warns about exactly this: + +``` +DeprecationWarning: This process (pid=...) is multi-threaded, +use of fork() may lead to deadlocks in the child. +``` + +`tests/layout/test_ereader_image_rendering.py` intermittently hangs at +interpreter exit as a result — every test reports PASSED, then the process never +returns. Observed roughly one run in four. A reader that hangs on shutdown once +in four launches would be a shipped bug; the test suite is just where it shows +up first. This raises S12 from "wasted work" to "actively harmful". + Four further defects in the same file, which matter only if the decision is to keep it: @@ -1211,6 +1226,45 @@ the demo to write straight to `docs/images/` so it cannot drift again. --- +## S15 — Form field label geometry + +### Problem + +`FormFieldText` treats its origin as the control's top-left: `size` and +`in_object` both measure down from it. But it drew the label by calling +`Text.render` at that origin, and Text anchors on the **baseline**, so the +label's glyphs landed *above* the origin — outside the box the control claims, +on top of whatever was there. In a stacked form that is the previous field's +input box, which is what +`docs/images/example_10_forms.png` showed: every label but the first crowding +and touching the box above it. + +The height was also computed as `font_size + 5 + field_height`, understating the +label by the difference between nominal size and ink height, which left the gap +between label and box smaller than the intended 5px. + +### Design + +- The origin is documented as the top-left of the whole control. +- Rendering offsets the label down by its ascent, so the glyphs occupy + `[origin.y, origin.y + ascent + descent]`. +- `LABEL_GAP` names the 5px gap, and `field_area_offset` gives the distance from + the origin to the top of the input box. `render`, `handle_click` and the height + calculation all derive from it, instead of each recomputing `font_size + 5`. + +### Acceptance criteria + +- No label ink is drawn above the control's origin. +- All ink lies within `[origin.y, origin.y + size[1]]`. +- Consecutive fields laid out by `form_layouter` do not overlap. +- A click in the input area focuses the field; a click on the label does not. + +### Files + +`pyWebLayout/concrete/functional.py` + +--- + ## Test plan Findings were reproduced with four probe scripts; each becomes a regression test diff --git a/docs/images/example_10_forms.png b/docs/images/example_10_forms.png index f3fae75171bfb21148d02aa6f11c9bc5a0e9b369..62bd3a502797ac76ba6c2b9349c806bb068f41cf 100644 GIT binary patch literal 31342 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 V$D+?*5Hf|bPfls~gPp&e`CrFxNWTC8 literal 31587 zcmeIb2|U&7+CIKyx9w82E2&JoT_VCRnKIRGOQRuE5h|gOsmv@ImBOwJ2@w^MIa7wE zgd(IekC7o`=5blS>#5G!L+$rH=Y7xb{k{Ls|D4bHe2yb)t?&2wKF@t$_jO(Oy)LP# zC@-A9az2GZS-5k@FZ(EzZ`M#KbC-TM2fw*Esi{n%xCrh1WvixRNH5*RRkQN8)U4H! z1>b$YZjt5j%Ua*Jwhdlf_pO?1#_~;bepG6?J7;Wv-kG1xZ=Z>KsQX4&?jq+RzgI?c z<+QFweYg0WQ250{N&i>#ZNDG+;g@-ZKS-V3Fqzvu@Jg-BN|_HpvY1wHweLRsV`&PAbYS@iMebBRp2;#C&-x2@m(BfqYE z^&&`I*4?-v!)8_5)LVA<<$5RIJ~^-4xZkI$t0Gd?p?{`I&nh&}s%**74qnDKo0;+b zIZYvzQ;n>Znn#SKC_bJ#22Sm_T>53YwjDoy{JMZ@{64X5smD$XH03DLM_vzAJGEXC zD3j?7iJp?SR5k0XOWo$l<{c5O9jD8>Y`t{aK#+Z3U8BoD&Y&lCBA+i_FJ06tqTVtq zPT7}NnU-*UtGar{$#VJe?$`wC#9PmTf`aGIpPPo*6sG5n#*K`O+>&?Ew(s$wPj^lT ziiqeZ9VrN#9f@*ic1y=%ty*<^>)CHjL+!M)?E5x~i!WImGoBt+IihsBZA%SB`gZnU zA(uO2x|z|}JR6tHwNJmVK7F7_adxmS)x3@Wyy0ZaS$2iVch_SUNybE~;(q$G1`JZo zv}&KQ(}r+G2DZ=naXJ3gn}7J>@Y(BSPtLPmzjiIctf_9+@RCsZaJeF%;jPVf-6`b? zTuK!|rne@un(3l3X(~4aMxQQGm7D5{^l$X)HhX+#Zqgl_CZ|sCl@dY{5~KM0`bTy> zJqfhg3S~YeExo(r3s|%?H8(krbS>nP{6$?eN+opFwhO1aUcP05joiUb9lQ>a#s_x-&8e>=QZN`Kz!?EkOC$22?!a}Yl>->e}ai^WA zU%U6_NHuMClze)>15FlJf(zy?y)9OMEy<{;aQ=8-{WwFI-(AzQ%xo8IUgj$|^o)-&Q6%U!HS&H* zuVZHw1&8oCa$yHDhQyKv=G;hDN9 zZB5OQk=LqXhPkdiRdMP|C62$kV_XstD(hhR`rh7|lP}r>4Gs3~v-Q)sU2OFn&)Son z&*0#}gDEDZ0=FDn^_r)&mnzYw8wX6vLRL(FnEH^<-fUlg0#~GXB-L$pvJ>y5(2-I` zmW4xD`=M3yY`CvVh_nq>Reh=c$x?~c+pk>YXmV`x#=ZMQbTzpQoT!f1U#e8}PK3v> z@0mfB?QEOSkQZOjMC|KS^J?*OIdQLuZw+NW9Id!YFVZ?1+uGVXIov6BAo(4Ayje_0 zXxHAoeQ)l+9?u=m!S(ts-w+WpUcS}kLVkDc1$I6K+#r?%*ME~SJHViph@|_uJ@!`md%uckOXXVVD87iU8w2O^TH>4RA`S8)Zl#|L!@7}#@%qO&deRf}3 z{So#PwaLaa6N6#yvy<+Oare&7PG$9nrscN+4V;fpkM*(3eaK6aA3;3fmN0uWHQLk0 zua{=w)SuZIAfh*h^_8-0m{8-gY80>-yLr6XotBX?act%5Se=B-lbrzp z0iwDOk9}x)eCZOS*Nm)x&EZLG+R%UiZF_pT!nDQHi{IC$ow!V&94Zk@FvxN4ese!6 zjOsi$tS-Tw-lJpaCXxGu#qhwxV-@J)&0B-P z3G2e{||SuI8RfsI#Bd9i_|53YOtCYLtCL%Qx%(o+e)6uE(1nKYGK0O~9ku zO@0~oY2k$I-x;o0*x(htON` zaBK_0yN|ai=RxboS5}C3lm=JhrfPIb+KPO|OgS~8RJ`T0Qpz^>jMEtO5Sf#?^gb)A zhKvoH=QmGPrHel5il{Mt+FlY^G(S*8Z<$_3tC#SYo}!dg`tCr{lbvOu8;y5GZugE5 zEG{W=>AoK&H1Wpk@?|5J<8=?;BTE@NzrP!G%elK#k>TX*Je8Ew{qRSYOhX#={rmUo zOXCitbUYUbn;A`T=aCKFX#4J24a;R^UZf|$WQW( z8dP=k-1qqG$){HDBV7-aO!~4;c`?Em!|q2|acXmi%Vb?fx;7wW;%(lM*Hkdb$wd4q zx>kraRIu-@;WTi3iyvnPecidIFU(puD|%L@w0Cr1dyM(wF<>Y0P5T@o5x44r%+0n* zJdbR~>~vB)t7t}ehy;7=edU?5^^6lvN5u}vh!uZ7{=)jnBFUC81;)(q-Q9tOJ_V&X z*d0NpRdIUbo{BSvbDT$yH2>;PXOr-XuySqVN^hpR;K8%Wx$X^??5j>NM1~d18F}US zw&n3zqt`#(lsvIg_);ldTAREjqBnnLaA(xyrhuYEbNRKw`IjDV5>naBlJm|PuNb_vZBRAh(xpqLx12hV%Zzj+{Pbx!xt@=T zUv=II0S}XD;h9*P-VEMuk@{MMNW1#p$L#LM%_f$uqL+J6^zUi;ZbT zqmPBrdrgyutf<4m%I+)n6!5QEvtZ?>V+eT&rNPLWljhoYo~)+s=A)N5M>8@2GS0aa zsLHha&0DfId*sz_JjO5*jFUCyGe)8mkigQ`7LHs@$?P*pS z!=WcQeMvxJS-->SBAb)Z78+^tMPnC!nm2DAg1VgrC(z_YcN@nR?ijkgTZX6L*c&Rs zAUl_2FQT!p+s}0=rsal=sp`SDqVgc|&gih>x2bE^+W)-zUW;&1+zRAvfVrg1El8r| zPj72qE*gS`idnmU1FOkw`jU^3O@VEG^oro$ z9q`m-2VeQwY-HQxukRJ)u4W)tj`!9Qm<{B$dXW9Wps(!PC!N2p+D09T6`JE0i=qaFtbM<`-P z(seG9c@naRikYkLPfSO7jacAddFO1l${DSa9mrln6S}wWrfyk%@{G1W<7d7RC7G#l znGi}yt%rMzR;+fB506pN5hOuA*AI`&LS^~fX9jBqcH&%Qp_Hh+ODE?XkOGKPM@{WR zne4y}5Gj|8?YrBONMt74w{N$YOsCQNkgyzjYX*_o0~D)iXUe48t{@f^D3la-sM!Go zhp(PO+@9^B&0_!R<75X*m}@`y$^Pm#pobIbjgl^7y+Gf+b*Vjoy_XG7E}lNN{3L>4 zQGAMT`e*tLaVvc6K%d?z*OV?jX1Q^2!hMA4i6sAagW(iUIp}2^CJ4yt5>r9 z8Ah`+ljqN$PwF`lAgYgZJ9`Q*DT=#~qsOi}$T<}*R$M_VfBOyDr6maej}GTw01k6U z5Hc31MgqRCI9*%rGi18K=d7Mpj$NA$Ir+g>xtYD&+MD$UJA$od>zilOu!e|X1Ouxt zO+{jO0Ax_iwXa~LyE3*{{wmvMPa#Puw~7d*B)-Ut=}s+qr#alsf+bH(;l=icN_F(@ zv`+PE-r;ALA5rT)Q-{29s=s0K46EqGV|HpH@B#_g&Gdc;97vw&TyguYyL%^;)iu*C z8*9X_b8kEQ?Lz0_QYrSCHFftjqTaub8o+bAuBwpv{$B8rB1KeLf(guQgUJD_8-#Q5 z*zN*--^QAX%`Ta*Ts7-uAprzOqA=bcTM!`1!S(#(vn)l+*m~$19L{N2cM`d2Qd-raJ8?p2KBMF&}zUfiI4{pw~)C3rD~NdbbWuNJJ!OAa|O;#ak%c zfZr!76Q~l##eTTw3&9WRp#!c0;^M=3TrEhZ4o}X_L+sb5QQeLlIbvec!A|{fc7mnW zsH@Ac$`)w@hp0C3a6ZAPNjDt^n%wG+N4ep>b9<9ZK4Of8hkY~X9JN9K-LLNK9PTK+ z%!qC#i{Qf}i`R;$J^8e&wP{rPr6mHnvr|1{w3#71j`^&tOBOFi=CZCjV2X*bmNI7w=gg>iM1Y^)*w|R6iekNQgZKRJj(p}d7`0KV zVKsfm6p!nkiA>*U0vq}H`2nGCw0|#yzqKhPl+8JFNeac|hjR-llpnr3%uJzt_j3&> zn=_04*Zg{i+)eLt#7Cp*#gv$HJhJxOgK33xJ(iUFL@wtHsl~;{GzEFzSf^p@qqLh6 zeRP|r3HNp9d++whtR1DOWnM5@5I2)(v!FBm?Z4iE@TSd?75IqQ;r?Tm>s792Qw;l4DGBb zG09J(>zro>i%^W}A3PWm6oka^44Y-y(xo#UQZ%QYxOB7XcsI}z&7e2bml~Uxur2@T z5TYbuD^MqaH)3Jqo_NA4<~mZLOs)j^Ce+VwsggQlmR|kfz;!>rH#j~7M3J+2q8?R| zBK^Iplamu+OYExk#}UQN@-HkT;ff@~>iZf3UJ+;emwH$mxDFPK_0^NY8CyLIht%l& zK+bp*K7&3xJz(hg_WWpf>(0{Pz<_|Uv3e`~ zj}Ny@GLS5(rR1E3dTUbx#SD4%oIt^_E6#ktE?hi)a&o90J z%l=n)cPHddb#E{##EyMhtP_7w21$|B&Uk&KvhVC$-@at0;f{$kq-F~cFv!~k;G%}cUP#AU^i(JKMns2%&RO&XTWENA zSS+hI`OTX*_zPTMQSNEf7uDfg&jM+_26eStG;>W_^2e1PJ+28@56W>+@$%)%OO`Nd zJC=#)HnSr5o$0?Ao=t?TUD7iIS4ir-l9bhLn|oCmRp8CXbRj zmKXK8d;UVHcb%P5W^V*ny)|qOFQ6TIwVPQpK89BkRo)b;b?SIlvp0PbKS`Qbtxs#9 z3*!?rd*ZY1?G3KNU1IrqdwT)jdv%_l=AS0?CicQ6r=fSauQmMqqu_iko~%={JblEasyK!Wu zxE6!4{>I>6fBiKi1f-lC)*25|+d4n8b{)mj9J>*a(<*n;q@Pjz?3p80^7X4%czDAE zH~`C(1c^WbXl>PE=b}%K)4(*~aa4jq^5k{y0z%C0intshNcLWTV;0MTxBqt}|!s*7{6?HUp-(?cg@qY5;$?7I)`-S;_>twch z&JIV+rNksSn)1?a^gSy&AF^(nxX>`me$Rzm0YO2v0B^f2n77z*4!rO+IT+n-DoF|d zk-Ve7uugw%iYfNIuulAy=3nP~?9!|hTg2kwl>zK9klA^wGFB(*-aW!h(xw;9QCo+M zqw)ML z$x}NkLw)DwZRm_NOEo#@aozj4=HGOSABbR4>G|IgGX6Q)1Al#Wxz-688JW{&gy>;m z=WWP#8qRODE(?)CMOl6D zq5W)IF527%cEoF%aPrEbk|Z}ypVkAi*n8urRby(X-sXaB!#)d439J!CfK-Q&G2q%M zd*HwUT*KZS((ev>B#$zyop`eS1>$4;qwW|Zt@@eC;S$5ch=fGkAefqH71YW0A~PC= zyWZw-`7L=I@kh8o#s^PD%{wlNXY?(abKNRqv;enX)`JrWZl38_Ud3*GEjqs1;;4xU z0`_Q~c|y*+Yr9d3qyvGTW@bsVYNmp4MZWWih<0bpf{FtW;*z#*$r{MzY9{q6cCTnu z2N?P!TrW-tiEG)`C?;@@vH|(FUs*l~1}(twR#4F78x7S_NH7T6>PtD6F1^Al{|HzT zq%f((EJBO;+~vf@Q}8R4e1_`k>N4jk+?x2rEW4gGqzUG%@nrf3LTG|ZVPgLFPE?fQ z@C)&zB^97*-@m+_kD4sWsyWw|%?inwh+BEg>~2^x3(o|$}0lL zi`+CW&LH{db37k{p2ZGkSY!3@cwLt&A&I^yEhUUCu!g+JiyPOjU7N2h>pna4;>8P; zr@4@q5J(Ww{rs-1l!!suTU%R;1p1<^%qPw%dPvYKVEut1(BCv%1PJxj%03n^!yh+f zo)l2MMY09h(gb7-?8?^wmRLGn-8wuS9;J${tW45=c&qjM6I)OeNq0pKQXR4XP#4_X zV6@%R(h~4ELQpr?brLbQ<3&&k9vLBo`o6cUO>QT6dVjiWOG~mLouzLq=VIQ@*p=tJ zk^k;75>RbyGEi{*(1KtO=_ngKw$4EtB?MM%sd_}w(Vps}>id7lzh1rO?Zy6+laqn^ z_6N+&%<#uXlZ0E8weQUUtJs6=3Q7u4r$}2t)9JwplsMiI#}H((KZ$CEr(7;K`~sR6 zb+EMnc^6S00A#0%NbajkYPxyIdhj$c}JxMl)ani6nS))~Y z0Hr(wc5Q6yWvnN_*mMBg2qhO`_f{h1{dl%yYZiUAa{xu)jM*YdpF9>m*Po#DfPqdw zR`DZH6w(pUiD8x&xCP)PR8yh1Zt;p8Ot;j>0)Wf_7CVx81WzqVHjFz)&MN{d{dC#Q zO~yKU30FVmQa@kGX;pNX1HlcOv)}f*!32eGAoxmwNuSk7Qe>F2- zJYWRbKoOTkH@|7wmo~f2Zki$xW%iSI_jkRGSyHQ3t&)++%*b#oys`q7bKEoS-2v-& z?%G9a@skXl?b{1MA>V+I6A~ha)oRRg5W@i~@Zv}sW!Ey$zPwBXSGUoo^(n9<(j+z; zc+C`&)4^l{yrI%7L8it5#NC`-Hpf~Jgp>x9OUoUt()ikp{zpAR$vb1U<52r;ux`l%Xd{$$d$X#% z<)8ApLE@pU$B(~1H*bjnl*|VY9>|>RIBINMz`J+H4(Ve~2ar5a#UI+bbt{e^f=&uR z1GW$z1%W2a-!ABC%5lLPfSivM+sM-E|4&*af!H-S(79y=?J> z7UKo&-mEejD8}UP#WA-yUUukE78lh7t%}5Jx8@&r3H%eu^_L@>=iA@C)3jeAt&2R# z_M`QK<(w*%!nxA-HQy)R-W}8Lx}M@A!>1K1(KaW<;$Mh9{wu}v;>Z{g0Re%S7{tfx zzy4~CW!ALcMp5}u%jqB`eDitlD_5@I1e1D0eJP?)0!}7MAwmghN<7$~^dpLy)vF_u zx#n0WUv$Xac45H*&?~3~ak_jC)3Lev6sa?y#~h+L00YluaAc^!<(5)?H3cq%u@zE$GC>MlFL$}dNBfVjy z_}Dtr@jG|!G%)N6SD4CySxRb;jeis~KW$m~)Oqh4!2I_84Ni!w>PtazD9X#{;3JWC zU6DPFgyo_cE`)hi%oY3HSP>VZlAbjFyIxD%0&SQ02JiJ17y}D9_HD6 zs;e<;9HFKytL()KS13bMi0C+^Aj;Udr9UK>h;nT>@)T90?h55OEl0-@R{;`o5vT}} z!--On_$dI8`;iY&=1zlS@zbD2v(Y|b1)mcV!21erQ^eZfBimmC>dA%5@9ouf!$C$T+nNq8WLJWbT;KIYvKg1b~z-4Psj z@7jeMgnM9y@UmVJB6?{^DxJM4<+%{XAutDphRTC+03dP!QW?x4nm!7&Sun4p;)N1o zY-$SjU0hZ+Yajc(yq#tUIe0L5R&H(HQuF7}Z*50ZbbfbL8S?%pp|=qU6g8cY$Ygk^ z4MXiE0;u6k%R-Q>fd+o?ih#s{BJmg^Ft!r5%;kt<$jyLFZV>4L*B{u3WeH=|V+GHFXro|Ckva!x3@T6x zauZ?l0S;mQ;797fPq_P}+x#Yw`2iC2k)e?mEpsP3@V>Aw+{It`pV~oDF+kb#qP(02 z6p5=bI=yQ4CCYgG2(&19^x3Zjo2#{hD_Iwn?@QPspiYf_`!?J6F3)oQu;nZZyCwEC z_9c$98_HBtqynjYWs9nOnI zSGc75V`Afl^fvyzxGyNXfGv-Lnf?V3K;Aw=yQZ6JrlfwVLWz^!F6LgF?Q{})Helwh z;NUEz3z(W_W@b>Y@~@rk@wjvE9zw*1+#>u!N!>3jtQj!>+&Opthu94~&8wNVZ2CG} zxyTcU2vEp5G(6nKVon)ne6VLH1@6^QR-@uTAl$e+mh@+&-b-QV= z@XGucTC2}Jeu{uyO-=b8yNM^SJPlyEQDax?OB zN=k}OoNguj1vo(16C9kJMHj+wRIpi|I_LsN33~0@w+}~Ld*8mrC0uwQ@*t;$Mh{U! z4k9!y6Enz0e89>$z6C{8CO_U#qul-Q;lrArYZY8+L;Foh1aVDz8-C{R*hc?x99!&A zzHNpGlk}x~>V2kQF_4|XIsq~Poz*NbSDEApFBk^tj%w6|lA$Kgl}#*GOl?C^9K1h{TaJWSklO4^p-^*YAnYpaQCnV@43WNq-XcXQS z*e)u)rJ9x-n$m{6PEB~)f@X>umG1nY{}~0~#|<%cwzjrD7WrUzufRKolCtQL z5=FDb=IwH45NNR9eKZ9a3^TLKS%W+SYs5QzlSxR)pN$K<3MnuBqpTDLW~7v8smboB zkVe$pAoBd3qM%&{ndZx)mX5yGV||JmR(QLNTkq+yo|=-eNoDMB z1um48#OFcGaNIJs$O-^C{f*P65GS`*E56x`XuJZZve<-N5?k3JU7zQb0 zf|73Q*2l!0i|QDs7|+tbaRx+q4?;1LY(G$ySndRr{x9f~UcGo6aAoyV_-rdw)Oj9E zU~Euz?~$q0K8Wnd7=WG$8eMvO5wpj(u_SP(s;a71)$xsarFDTy)I5Uxg%W-b@?)(}4y4*?o+NNFs>m+jVz5H{Q3ozn0;31Y zhB#o5;fUi-TAM-0L5-n~QRnhNWgnyd(|&TF}>y#69#Xon8oPBK}j_5SFd;&O|XUEb3D?gMN&JN+%WdG}?Tjqd{`Xrjb z&HRx$c}iTT$6Z$`DiZS)TyMkB`bbVeg)u(ROaoEM(s$vQU+vt9O$B{tmzh~Na4t|za!Lv@&$Xn1IYN?8 zIuBK*#-b6i6RX)7pVbdBr|!es{E~RS1WRCAspSU%SK6Y+W_SL{Z z2Pow{8M-(>3Q#?unDNEjGNh*CMI_!pXvx0URl@y^$fmn+Q;AkiJi2V`iw4wnNeGotvSh9v?i@&+LN zUFL!O>6`&KF^EYBd#u(L6czhH1p>SNgztK&JT6LRz&^1WQDs){)AfXHLVY$`ZK&8~ zhVtOJ#m@s3JNE1$956QV6da`QaQ<~4MgwiCFC{7+F@0lc9sEX_HNVFbermE0%=A5eEMB~rU?&h(pnOK(zcvLTL-poug*E{so*4J9nukk}InDAXbZyPI;RCkETf5rNyPZh!`Wzqy3~ zHA|Oo!b5CUnShKaa$SFseFJtM(2(?lDMJWSFm#*Y%%CU(lHo;Kl{pXr?A3|WKFD1{ z@ks$5Izp^(@H_TF>x3Z)pV~=4grHYPd6;2s-<gdkhc(L>0>R zLg@WruoDo3wO?vccwMw)wFPMXEYz1!9lYqZ$jv}RMFHqgk;Aqmn6|&s1^PqM6Wpg2 zOlgoaY9aqo2QM3vcAcalY+YP+3@Uu2uYSn?&d$yV`KZ4ph_!#*A37GG9wh86B2)nt zLHzn&7;?B$n1UM!G;3_|-uiU0G4DE@tTrxUA|nCYDVuE`g>mYT=`3CzK)=Yy(_xb^ z=#|>^)bj@JzztCw{4s!&JE({VK_NlG5>BXvgujkK(ATUZ)ug!sm$4DoL>?Bp0o)!~f8je( zQU_Jq1TO_xenBsi<6rF-Lrm?<;AY49%`2Pn5{};w`3ehZl>Zc*Be9U4_2Fb;TR3>_ z&lHz1LaB`HQ9zas=sqwsV6&kAC%e}?5&H>G3Ot(~jq~0NJ%IM)bljY1gP5%~p8%5c zZ*mfym#|H^7OTwm0Kh5yWNKm}3b2P9hldPnnHuOsFi4Ki`2+nLkA9-4ycqyYr4UEK zXBC-%H3yA!eTUwcD26nwu0LiE-=RdoI<$) zg%Shb1j#o z%s+TsT)~_YvpRBKBM8%wDvI=MwFUP>@p9xDc!C}|4BQG04fXf8iyr*ZU}x>s_Hfup zKvV}^z52b9`ggmU z9`TUP&Y&>`$@qC`DRziitv^gDD$t|=W&3n?PEL!spFf*6KYZnV$-|^vB92Ai%qe&$ zqb!`j9bxB^{0rQW1JxLK40(CiSCLEN6GW}GYt{gMb8>Q)10I1xHRcNiLk;{kJTydz z|CVnWlF?w~2jE5@3Zz*8LE)MDHj01S(!&!YPU6B~;{bIWAxmZNUhjq6EIeYx3E2QJ zC~nY5i4IL%Ic_gV+U5wYpSi%K z$vLmm*s|X^P+t-jQA4O&SaHi*WjHgx$}oK$m_$XjA;nZ8zo4WX>#pp9$)aI^Wz(1J z(tau{@cktCQy@Tkjr?(n(4CO789b&JNN%B3(r+BqsqMchYA^(pKa~^{6zoEgg@}x@ z!mdR}9cxOQAYnirgz@XEcR(L3&ggUf7um*e-+UT9!RYK>w27W2LP90rfoYo zcO=|&ARD^n4!Z@Y+vGr>!bN!Z4ydcjUQ12ojxUhS7O9b%6jN1%F}SfFpkg>5%?E>K z!V*afSG4gE^P{gt{n^>hH7bG-9%TF&V8Jw}P|fs1f<(MSt#B_8|SsntWyd zSER|`f7qJsT6!ufD#lVw9zstuC~DpiCxLI&Yxh#Z=Wh_DXo}ra$EEyZP1JwQ68;mN z*#CZ$f4FJ=zr9rdFZ^cz-U|OdO#js>4gc*8|3#qv_mzB^eEz*A|8r}yhWEPey*xEW&oN2NI%}^;UJJPZlM(fu+pbK-j`~PqGI!dmBh0Hdp0Nb~Q4K0*v_z9)OgvMoAYeTW@4^K!(e@!IplH4F(EGA+5 z&;=>5HOx*Xm8S;ddgR_*ZyX3`;&%63`F(EH?MDr;env$_rNcjkJBQ24G8_Koljv2z z<@WIh9^43P2V6S~xTO*0dLWp2`hOH#&&>l z*ITNDo>Xu#s;Fq$T&vov?GK$TnXM(d=&jz{Y%8~LIJ^{<@{0JH@-}n(Ke1rX{C%mQ zM96?B!GxH17-h2+)Hbn6PQ0tzwgQw5?8x9}pmGg?Vk#)RhUUFWbYQ`3q?2F(q5#c` zbmDM(?f`T$ZtDTLP zf&YjMhd^JnPayXxLr8W|gWwL-hv$hfNfKrwXz%KT9m=wC^Qu*E^Gx-rC))WchoofQ zY-RrLD4Oq+SS4{%UHr6oYT64c9~>Blf-Qm(2QUm}TZ4JR&iQ)76U{awM>ubu7-au4 zSgLV|q$FA1gEgNdjhly`oU^U+b?+u>8~B^0N|?n!9NI-!Rt#e>4zcUO%8!Oqb{wpQ=iyEg}lN|PnHzm*&A zcjuAKf&D|;;l;$nB(3aW_r+oLmDj{{mztmV?R&MtG~|0jQ6ZttXPP)#&goR1))8`bhd1ISZ$j40>OcS zi6V9v(q;=%73xnnB$TaTqeR$C=*x@ZW2DRo=#_m>k!E9)_)+sERlol)OjaECqke3g zllS}{yb8F=*PsuZarTK~j*}b$25%;QHEHDqL4)57Lq?D6t(=2~fo52oKyIzq{EG%G z5*a`@pwpEOB?||29Ygc^?U)l^Hyzp2$UTtWF{^! zCm$ta0q{f{@y`=$VZfgx156t2s=+07<0`Lw)faUT6euQHAdsa%+Qtjad;J_H*&ayZ zTs~&uTmO5mh=X;NG2GdEHSX z3&YjVKR-d{$nLP!HAs7G%y7jlq};LRb2J0Cd4I>DlU4%v)tA1m_(S|0D>x}Ck`nNv z`8u}MoT?02r?K$@8ywzqK03S&U4(rScsTF9d$%AiZ(N$9X*^gqbFA>ZkQ>2E(YgN; zuIGz4`7gJA{lBP8;89QgK^+y$nTIYirDmP^Y^E5w{-E;q%f)ZYrRkWJ_Q?{#-I-X5P_c7Ap~(-_D6qr zLqm-%G8*wB=)Asd2?GkDUZEXf>Y5t1F0!NoSWRM*-f-U)?qN?YHcH;}@RbKs2LI&0 z2F*`w&9J-x#kit5k?l*H^kAvDxHvG`bDTs6jKzS0MaSD%0yZJ?EnNQ74~W?i8rIQv zvcyTEO#~jmZsa~(qLDj5+ke<5c47#!D>M}{bS`(gUsy<}&PwbHo6&>;QgH=3o*>9I zKoCaEzwA$%Z+;Wx6u^tpV}yi+h%uT9tTO^d3-mQ{kijRsRCOinTSQR=E$TeppW`^# zy1GUkR!F3LC@xgb4F=QJx>*9B+wiG_95TcTsFKFAARMFTmY6cDJvivghV2o~eNe@*6?R zvcmoF;9w@{;f2kAH&g>R%}*U%WH1Y$II=?+w01~_XP1%j7GKCxMKm?v2n^Kvqk&0t zaIL>a-zTuO((vSe1Z=7k7@Z5!zTz=&)c=F0sc6?9EKQ_;BT2RMi%zrwot@A%;OZgc zA(?xKWemyx587X$!XRI*4_kZU*uKZBnXN7S&olW@}=+n5vR<*b$Hsf^{++wr&V?v5XuGC%m{BLx+6`pI#$OD zxC9l9%uiOI`^KT~4V**oJ4&$r>RGRWE;TlDF+-Q59@h^*lrl2h^YkD@I$|}d(CpG) zMiDq(gX~RY45+a1rL~<$7B@!=8d?M~_AocOqBZXcTFBw!K5A-O9j)G$R}2LTF7G3( z=pj>I%5y4}8AiLg<`;W@-)P3A;OaPYn{-M+(Z^Id5@Z3$;A4hOkX=jlx9+tKhwzC& zH!8Ipc-PO08joXeT}>KM{MIBwwXUgsHmWik<(R=bX#&DXq4*}ai45uq(?hl+}*sOT-2EFjO7+{b_b5sWqg zl0%JafbtctrZK(SIFOhD=nf+n@WS|e9W;h+q|Qv#q;cMG|`;>?D;iJK%tbFd$XBPnUcUd(Z+Nx+2EFA$E_*!=gf*O0-lIk4A2 zhO(X3T`+kq`dtF+Vs~0Wgn20!E0{kj2RGOn3o%hk`dDd1A1G zJbb}OVJvj+X1o@DkzrJ7c*c6zfQWj_vpi(SP7&LVzyBxqkK#b-ne#M zd{d3;;y#DcLNc1LW={!B9Mc0dMae#U)UwdRxzQc6R0$Nt2z+liMmlNN;2fU9@Kta9 z?>K2D`2}1Q;{w^?$Q7J1BNAK`wLx>q-!1~PcLj3uAN71oDJtH%QnGlq1;5Bnc4%~j zQ=^#nx8DPO6;d&N4@hrz+?_FAZMm4%vmJUq`~+P$R1>!eCEU9bH8`38voIwN70mr;lW8yE~o}Q3xS##_*)15Cm0su2cOlJ z5H8OUO2aU-iV*Ir@{(^%<;{%mzO=Ca>&;4^y74-1RLh`vICt)xImQ9qs9u27<4eRj z^k*S&0vIHr1MJ_p35#2I!5cF!$ap0I#mSwhTa5VQXp8kk1qCNcF%J)C0FvqS z6uKYK^K_&g+#6}2gOeTJ??^af$V6bcJL^#Q!tgNqhlwrgqOM_|p%48PrjmX~Kfh*N`c0f7Ri$w=w2~yUxC=W=s^1 zZs*eN_$c(L&0^9CC_x9@0J?fmjn`r2KQbR*i}<`Tw_q5D{|6ObA0j5Y3xu^FT=eJD z<|pfW(gYR&{F)3IBK=08*jXvx5Ih`bWf zEHbGV2AJc<-19tQK3)cy`}(;R6PE#l4vKhU)j`h+9x|bE{$S`Q^-kV3L9bV@U*o4f zFjW{G0Y?m@&&nMgk0d7p`XGZ_W%Z?f8uo)$ks%ydgDm?Kt^=@CIibs&46Fo$6$ADM z&=_SAhBL&!*)~V59TU_w#sARpSYqH!3}U}Iy}mN0fE`9HK?#XeR6@vVC8ecg@-wP5 zV)Mg2A<>b}Uu?wm2HV}}%E3>lx6nJXsJZ$R2z~@CfQL@z41qJm<|F4$`2DXGO?A;l zEa5jvUkhoCNjv(L`2{fiqti?dVG=1k85j}8l~qMV0hJ#p(klY}ETU6zGm()=c+js^ z73HxfKsBHdRMfdfwB=k%>bQTOfvc40Zj1p7cb@(< zLg*CgqijqWLBA;uoUo`Uddq-*5Nd*9Lx(|;c#>XTtJxESB_IzDmjR-T(W67g0^>LX zNfg9*mB#Jbx%1M%!;glof+{@qOB~q5sTLaP}^e)329p;Q=h5) zN#H51s2FdT3X0b&0#zxRIjL=2%@<3rID^qA1wFd$?%uic8RSg&fe8jmng@1$92CDO zGzaF_-{tKr6T8U#L6y3d+rwy%>rsO>zm*x6fcUqy2!yaZz%NotQ`4^DOLo>dk zSZ@^f##bEtqZz#A&WySu<>Ii$Z2=E7#ACjX^$a;10tzwO=B+>ei@NwfH>n9_At2_> zJJ`5u#VP!?1J`AJI@LOS771xepyiUJ#DbI;4$ z+fX`vY`D4eg+e9hg^_TeCjUfGM0DXTnh|JWu<5RivrznJ9$qdiIl9G21> z8&+eEHZo39QV|tWefTgk#%mN7Zeq2Q-UiF z?w_@c6(#KL%YWZEJpnR^$1D|dvdF+TuL#H`L`jpC#iSGzVi=kElZZ&)a(NK|&2>-? z)j`tZSdcMQw4vX+n!CqPgW~@t#0jOTA)o+)ZHub{Pckt?Ch4GI3-0dH;LeAbmKdUT zcs1S=p~?`xVru*6e2)-A&%{R}b}*;>VzF3LhD=9Nd44z0;AU7D1Bk8j-8)R?;2A~J zr^of(AoZ}y+zPG}+t}qv%d{$u7@0xF;oAfxj=gM2D&nojLU37SCmpMZa9l2pq7z=? zX*ly}P%8;|n;b>T|JKUio&)VM~>sKna(!G1qQ)9cEW1!LqK%ttI~gLuGLh;Gz?gs+rX3xWv!z4 zDFbQTti@6g(GvTHhIHZIxQtTVHphk%6ZCmP6mk9oNVO_H9l=&>gxHHY0kh{h4fbOv z;aLM=Va6-Q{F3=p;P#QW$ADqUXgp2(VJ4Ly;T=>f1>jc1peURtv}kXKeYH36>Q`nG zmI7N|U9Bv6?B${UhD^{9xYIGrQxMLYqzHU}nAxNI=$~gAq0cW;P#0Sk4+&GxN!uNU z6Oiu#N%H@yOo$kAu(llW_Xr%1mCrxXMrz=j1idg;ZZlX%F3Swk^9GV`-aN+Cw{ZZS zN#{SaM^uOG5n%{z_uq6dha3d$9z)8N|8ykZ13|Cf`rv^*cAb#U+gXg!n=#%P|BOL@ zWX^jg15?^aGdgDCeAyu78`lgr6V%S05b31w@bJV$YpfCZh6Q8Ac^=m$vE2G;uPu38{6mN?J(Pj zzUvWMMI*q`IG|%B=?TCjzP)2j+CfOPu;zAVq%ZYww>4q*Sn?QSD`+sv62yi)iw8Bx zwU0^dQp`w@H|*OVr_k9iS?=*tGhU+2T#~RU5SBJ^m-whiE$yhMJ-*FcL z#h~abf@>*W7XNW<&0t=$%Vy2Vw=jgpMLHM&R{u~)Fgc{=|k%GsOo(w^%B3VtPy2wN$Gr==K574vRxn~a<#0OFU z0~IhmX2r+m5R>v-^q1S%3uf1w(mb;?z#wc#`${yV3=2XW;u8g@)u81Aq%T=KYe!v6 zWsg1^0Hyn5!>x%wLOfW!gkeX~(9l56TCRsLjF}w(QL+D(p!@IMU69OB z1De6wd>SD}B<9=2!1Kr!m>ouHn$`9CwF`~11d)u}y@6QBI1gSjP6*#5&^%L3zOrGa z$o&`fQzO%J0d=CGNU8gLrL-1|%KnRPgZw&uU^KK0s_E*ktXKM4mxNb>B+4YxB#zRy z=qL@lmPczK@#?R?Vq^b~aUArWW)T7H;Rxsrf)^>M8VVD*MLQ_iiS`b^Wm?h^{~+zp zvoN$=OEC|R2wWg6r^%v4Hj4pNDu3o6KeYz@Nsd)MIq!hQM+R~v+Ff}xoQz?Z<8kohG4)3}zVFNGAVlX^TVTNF?e)80lidyyKDsio(LN7S}GZ1d{>m$k$XAl`=ry z{){#s0wS#S_N8^~dcK>|ZY_m`6NCr>D~A@o=SmPU2_3d_$q&U5Uuu4aH35Y!Htb|$ zGx>6!NQ-^hx*kI0zEr*e(}p1}U0|vp;F0%6DXXOZ2Ge%`z)y%|&3<7QE1;(Sd3a>i z)^SHbgcoQh!U*`<1lKQczEZ^(b0Y^e7Lnon;KIq2u8B)GCxW~IS`lh-uJ1xhBTe+T zZRF)e-wxiPYS!|8uh&NltoEKxKpH@${2N-+cQWT2A`s=V}|MnEQ}e{F34Q9Pi$|MoGw0Icv|`XUyVBOtT*xK z=9EOdKt;?QS95Hk^~~oMwwrQJw%8$v!kFWwe()f-`zBbUAS2_uKqU&B(dCa?2H!nkgpy(KGXD{1YM+NZ zQ!$Gc;dg(U#Q}_NMUrEG6bPw>eDMeZH=Y>gVEnxl%R)!|Easev;q}nHF@5h-7V0XW~TYk@|RpoXs#n zVQwaAEyd>^>DlvX-ceGPx_<&Hbx^=Hpq_EXKm(#5v8qy%Iv#AYY7ZtIgqEDrIUKf=n@)j;7R2 zZD;;(4pjc9kgfE(D0P`;g5We!8?Wt{L)kKF{IxXoAAgbMf2nWhje`W0D7xt}xzsbih&+EcGX+|y4MRRCC&aKMCw0Bw&CI-ixrUh@Wdjv-<#bI(_05`Zz|MN16r;3^i*mUP zWbVZli_9QzJ}(Kn$(pCAK7U int: + """Height of the rendered label, ascender to descender.""" + try: + ascent, descent = self._style.font.getmetrics() + return int(ascent + descent) + except (AttributeError, TypeError, ValueError): + # Mock or unusual font object; the nominal size is the best guess. + return int(getattr(self._style, 'font_size', 0) or 0) + + @property + def field_area_offset(self) -> int: + """Distance from this control's origin to the top of its input box.""" + return self._label_height + self.LABEL_GAP + @property def field(self) -> FormField: """Get the associated FormField object""" @@ -352,12 +378,21 @@ class FormFieldText(Text, Interactable, Queriable): """ Render the form field with label and input area. """ - # Render the label - super().render() + # Render the label. Text draws from the baseline, so shift down by the + # ascent to make the origin the top of the label rather than its baseline. + try: + label_ascent = self._style.font.getmetrics()[0] + except (AttributeError, TypeError, ValueError): + label_ascent = self._label_height - # Calculate field position (below label with 5px gap) + label_origin = self._origin + self._origin = np.array([label_origin[0], label_origin[1] + label_ascent]) + super().render() + self._origin = label_origin + + # Calculate field position (below the label, with the standard gap) field_x = self._origin[0] - field_y = self._origin[1] + self._style.font_size + 5 + field_y = self._origin[1] + self.field_area_offset # Draw field background and border bg_color = (255, 255, 255) @@ -404,7 +439,7 @@ class FormFieldText(Text, Interactable, Queriable): True if the field was clicked and focused """ # Calculate field area - field_y = self._style.font_size + 5 + field_y = self.field_area_offset # Check if click is within the input field area (not just the label) if (0 <= point[0] <= self._field_width and diff --git a/tests/concrete/test_concrete_functional.py b/tests/concrete/test_concrete_functional.py index 64a97b3..e33fdc0 100644 --- a/tests/concrete/test_concrete_functional.py +++ b/tests/concrete/test_concrete_functional.py @@ -334,8 +334,11 @@ class TestFormFieldText(unittest.TestCase): """Test size property includes field area""" renderable = FormFieldText(self.text_field, self.font, self.mock_draw) - # Size should include label height + gap + field height - expected_height = renderable._style.font_size + 5 + renderable._field_height + # Size should include label height + gap + field height. The label's + # height is its ink height (ascent + descent), not the nominal font size. + ascent, descent = renderable._style.font.getmetrics() + expected_height = (ascent + descent) + FormFieldText.LABEL_GAP \ + + renderable._field_height expected_width = renderable._field_width # Use the calculated field width np.testing.assert_array_equal( diff --git a/tests/concrete/test_form_field_geometry.py b/tests/concrete/test_form_field_geometry.py new file mode 100644 index 0000000..9a7c6f9 --- /dev/null +++ b/tests/concrete/test_form_field_geometry.py @@ -0,0 +1,140 @@ +""" +Regression tests for form field label geometry (spec S15). + +Text renders with a baseline anchor, so drawing the label at the field's origin +put its glyphs above that origin - outside the box the field claims through size +and in_object. Stacked fields therefore had each label overprinting the input box +of the field before it. +""" + +import numpy as np +import pytest +from PIL import Image, ImageDraw + +from pyWebLayout.abstract.functional import Form, FormField, FormFieldType +from pyWebLayout.concrete.functional import FormFieldText +from pyWebLayout.concrete.page import Page +from pyWebLayout.layout.document_layouter import form_layouter +from pyWebLayout.style import Font +from pyWebLayout.style.page_style import PageStyle + + +ORIGIN = (10, 40) +FIELD_HEIGHT = 24 + + +@pytest.fixture +def font(): + return Font(font_size=12, colour=(0, 0, 0)) + + +@pytest.fixture +def canvas(): + image = Image.new("RGB", (300, 200), (255, 255, 255)) + return image, ImageDraw.Draw(image) + + +def make_field(font, draw, label="Email Address"): + field = FormField(name="email", field_type=FormFieldType.TEXT, label=label) + renderable = FormFieldText(field, font, draw, field_height=FIELD_HEIGHT) + renderable.set_origin(np.array(list(ORIGIN))) + return renderable + + +def ink_rows(image, x_range, y_range): + pixels = image.convert("RGB").load() + return [y for y in y_range + if any(sum(pixels[x, y]) < 400 for x in x_range)] + + +class TestLabelStaysInsideTheFieldBox: + + def test_label_ink_is_below_the_origin(self, font, canvas): + image, draw = canvas + renderable = make_field(font, draw) + renderable.render() + + rows = ink_rows(image, range(ORIGIN[0], ORIGIN[0] + 140), + range(0, ORIGIN[1])) + assert not rows, \ + f"label drew above its own origin, at rows {rows}" + + def test_label_and_box_do_not_overlap(self, font, canvas): + image, draw = canvas + renderable = make_field(font, draw) + renderable.render() + + ascent, descent = font.font.getmetrics() + label_bottom = ORIGIN[1] + ascent + descent + box_top = renderable.field_area_offset + ORIGIN[1] + + assert box_top >= label_bottom, \ + "the input box must start below the label's descenders" + + def test_reported_height_covers_everything_drawn(self, font, canvas): + image, draw = canvas + renderable = make_field(font, draw) + renderable.render() + + top, bottom = ORIGIN[1], ORIGIN[1] + int(renderable.size[1]) + rows = ink_rows(image, range(ORIGIN[0], ORIGIN[0] + 200), range(0, 200)) + assert min(rows) >= top, "ink above the field's declared box" + assert max(rows) < bottom, "ink below the field's declared box" + + +class TestStackedFieldsDoNotCollide: + + def test_form_layout_leaves_labels_clear(self, font): + page = Page(size=(300, 400), style=PageStyle()) + form = Form("signup") + for name in ["Username", "Email Address", "Password"]: + form.add_field(FormField(name=name.lower().replace(" ", "_"), + field_type=FormFieldType.TEXT, label=name)) + + ok, ids = form_layouter(form, page, font) + assert ok and len(ids) == 3 + + fields = [c for c in page.children if isinstance(c, FormFieldText)] + assert len(fields) == 3 + + for earlier, later in zip(fields, fields[1:]): + earlier_bottom = earlier.origin[1] + earlier.size[1] + assert later.origin[1] >= earlier_bottom, \ + "fields overlap: a label would print over the preceding input box" + + def test_rendered_form_has_no_ink_collisions(self, font): + """Every field's ink stays within its own declared bounds.""" + page = Page(size=(300, 400), style=PageStyle()) + form = Form("signup") + for name in ["Username", "Email Address"]: + form.add_field(FormField(name=name.lower(), field_type=FormFieldType.TEXT, + label=name)) + form_layouter(form, page, font) + image = page.render() + + fields = [c for c in page.children if isinstance(c, FormFieldText)] + for field in fields: + top = int(field.origin[1]) + bottom = top + int(field.size[1]) + rows = ink_rows(image, range(int(field.origin[0]), + int(field.origin[0] + field.size[0])), + range(max(0, top - 6), top)) + assert not rows, f"ink found just above a field at y={top}" + + +class TestClickTargetsFollowTheLayout: + + def test_click_in_the_input_area_focuses(self, font, canvas): + _, draw = canvas + renderable = make_field(font, draw) + + inside = (5, renderable.field_area_offset + FIELD_HEIGHT // 2) + assert renderable.handle_click(inside) is True + assert renderable._focused is True + + def test_click_on_the_label_does_not_focus(self, font, canvas): + _, draw = canvas + renderable = make_field(font, draw) + + on_label = (5, 2) + assert renderable.handle_click(on_label) is False