From f0f5bc755125355b7f857db60111de6e7fb142f2 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 3 Aug 2026 11:50:49 -0400 Subject: [PATCH] big ui moment --- assets/launcher/find.png | Bin 0 -> 2475 bytes assets/launcher/gear.png | Bin 0 -> 2767 bytes assets/launcher/mods.png | Bin 0 -> 1701 bytes assets/logo/minilogo.png | Bin 0 -> 18933 bytes build-rg34xxsp.sh | 2 +- libs/flexlove/FlexLove.lua | 1853 ++++++++ libs/flexlove/LICENSE | 21 + libs/flexlove/modules/Animation.lua | 1579 +++++++ libs/flexlove/modules/Behavior.lua | 188 + libs/flexlove/modules/Blur.lua | 686 +++ libs/flexlove/modules/Calc.lua | 385 ++ libs/flexlove/modules/Color.lua | 346 ++ libs/flexlove/modules/Context.lua | 596 +++ libs/flexlove/modules/Element.lua | 3904 +++++++++++++++++ libs/flexlove/modules/Enums.lua | 171 + libs/flexlove/modules/ErrorHandler.lua | 1042 +++++ libs/flexlove/modules/EventHandler.lua | 843 ++++ libs/flexlove/modules/FocusIndicator.lua | 232 + libs/flexlove/modules/FontCache.lua | 269 ++ libs/flexlove/modules/GestureRecognizer.lua | 583 +++ libs/flexlove/modules/Grid.lua | 336 ++ libs/flexlove/modules/ImageCache.lua | 160 + libs/flexlove/modules/ImageRenderer.lua | 380 ++ libs/flexlove/modules/ImageScaler.lua | 174 + libs/flexlove/modules/InputEvent.lua | 88 + libs/flexlove/modules/KeyboardNavigation.lua | 748 ++++ libs/flexlove/modules/LayoutEngine.lua | 1714 ++++++++ libs/flexlove/modules/MemoryScanner.lua | 697 +++ libs/flexlove/modules/ModuleLoader.lua | 202 + libs/flexlove/modules/NinePatch.lua | 217 + libs/flexlove/modules/NumberValidation.lua | 351 ++ libs/flexlove/modules/PathValidator.lua | 198 + libs/flexlove/modules/Performance.lua | 560 +++ libs/flexlove/modules/PropertySchema.lua | 505 +++ libs/flexlove/modules/Renderer.lua | 1230 ++++++ libs/flexlove/modules/RoundedRect.lua | 124 + libs/flexlove/modules/ScrollManager.lua | 1446 ++++++ libs/flexlove/modules/Select.lua | 719 +++ libs/flexlove/modules/StateManager.lua | 790 ++++ libs/flexlove/modules/TextEditor.lua | 1783 ++++++++ libs/flexlove/modules/TextSanitizer.lua | 183 + libs/flexlove/modules/Theme.lua | 1655 +++++++ libs/flexlove/modules/UTF8.lua | 44 + libs/flexlove/modules/Units.lua | 335 ++ libs/flexlove/modules/ZIndex.lua | 35 + libs/flexlove/modules/behaviors/Animated.lua | 245 ++ libs/flexlove/modules/behaviors/Clickable.lua | 344 ++ libs/flexlove/modules/behaviors/Imageable.lua | 282 ++ .../modules/behaviors/Persistable.lua | 132 + .../flexlove/modules/behaviors/Scrollable.lua | 264 ++ .../flexlove/modules/behaviors/Selectable.lua | 206 + .../modules/behaviors/TextEditable.lua | 576 +++ libs/flexlove/modules/behaviors/Themed.lua | 178 + libs/flexlove/modules/types.lua | 662 +++ libs/flexlove/modules/utils.lua | 319 ++ mobile/ANDROID.md | 3 +- scripts/build.sh | 4 +- src/import/LauncherSettings.lua | 387 ++ src/import/LauncherView.lua | 1826 ++++++++ src/import/RomImporter.lua | 3128 +------------ tests/engine/launcher_delete_confirm.lua | 134 +- tests/engine/launcher_text_input_bug578.lua | 35 +- tests/rom_importer_double_pick_test.lua | 10 +- tests/run_save_editor_tests.lua | 229 +- tests/save_editor_wheel_bug595_test.lua | 43 + tools/save-editor/App.lua | 108 +- tools/save-editor/Kit.lua | 213 +- tools/save-editor/Ops.lua | 42 + tools/save-editor/State.lua | 12 +- tools/save-editor/Theme.lua | 10 +- tools/save-editor/panels/Boxes.lua | 196 +- tools/save-editor/panels/Dex.lua | 89 +- tools/save-editor/panels/Events.lua | 74 +- tools/save-editor/panels/Items.lua | 306 +- tools/save-editor/panels/MapBrowser.lua | 149 +- tools/save-editor/panels/MonEditor.lua | 300 +- tools/save-editor/panels/Party.lua | 47 +- tools/save-editor/panels/SpeciesPicker.lua | 27 +- 78 files changed, 34435 insertions(+), 3519 deletions(-) create mode 100644 assets/launcher/find.png create mode 100644 assets/launcher/gear.png create mode 100644 assets/launcher/mods.png create mode 100644 assets/logo/minilogo.png create mode 100644 libs/flexlove/FlexLove.lua create mode 100644 libs/flexlove/LICENSE create mode 100644 libs/flexlove/modules/Animation.lua create mode 100644 libs/flexlove/modules/Behavior.lua create mode 100644 libs/flexlove/modules/Blur.lua create mode 100644 libs/flexlove/modules/Calc.lua create mode 100644 libs/flexlove/modules/Color.lua create mode 100644 libs/flexlove/modules/Context.lua create mode 100644 libs/flexlove/modules/Element.lua create mode 100644 libs/flexlove/modules/Enums.lua create mode 100644 libs/flexlove/modules/ErrorHandler.lua create mode 100644 libs/flexlove/modules/EventHandler.lua create mode 100644 libs/flexlove/modules/FocusIndicator.lua create mode 100644 libs/flexlove/modules/FontCache.lua create mode 100644 libs/flexlove/modules/GestureRecognizer.lua create mode 100644 libs/flexlove/modules/Grid.lua create mode 100644 libs/flexlove/modules/ImageCache.lua create mode 100644 libs/flexlove/modules/ImageRenderer.lua create mode 100644 libs/flexlove/modules/ImageScaler.lua create mode 100644 libs/flexlove/modules/InputEvent.lua create mode 100644 libs/flexlove/modules/KeyboardNavigation.lua create mode 100644 libs/flexlove/modules/LayoutEngine.lua create mode 100644 libs/flexlove/modules/MemoryScanner.lua create mode 100644 libs/flexlove/modules/ModuleLoader.lua create mode 100644 libs/flexlove/modules/NinePatch.lua create mode 100644 libs/flexlove/modules/NumberValidation.lua create mode 100644 libs/flexlove/modules/PathValidator.lua create mode 100644 libs/flexlove/modules/Performance.lua create mode 100644 libs/flexlove/modules/PropertySchema.lua create mode 100644 libs/flexlove/modules/Renderer.lua create mode 100644 libs/flexlove/modules/RoundedRect.lua create mode 100644 libs/flexlove/modules/ScrollManager.lua create mode 100644 libs/flexlove/modules/Select.lua create mode 100644 libs/flexlove/modules/StateManager.lua create mode 100644 libs/flexlove/modules/TextEditor.lua create mode 100644 libs/flexlove/modules/TextSanitizer.lua create mode 100644 libs/flexlove/modules/Theme.lua create mode 100644 libs/flexlove/modules/UTF8.lua create mode 100644 libs/flexlove/modules/Units.lua create mode 100644 libs/flexlove/modules/ZIndex.lua create mode 100644 libs/flexlove/modules/behaviors/Animated.lua create mode 100644 libs/flexlove/modules/behaviors/Clickable.lua create mode 100644 libs/flexlove/modules/behaviors/Imageable.lua create mode 100644 libs/flexlove/modules/behaviors/Persistable.lua create mode 100644 libs/flexlove/modules/behaviors/Scrollable.lua create mode 100644 libs/flexlove/modules/behaviors/Selectable.lua create mode 100644 libs/flexlove/modules/behaviors/TextEditable.lua create mode 100644 libs/flexlove/modules/behaviors/Themed.lua create mode 100644 libs/flexlove/modules/types.lua create mode 100644 libs/flexlove/modules/utils.lua create mode 100644 src/import/LauncherSettings.lua create mode 100644 src/import/LauncherView.lua diff --git a/assets/launcher/find.png b/assets/launcher/find.png new file mode 100644 index 0000000000000000000000000000000000000000..028da2ba8d06e27cbaed862912f47bd9216bf8e9 GIT binary patch literal 2475 zcmV;c2~_rpP)e@=d?_{a*dob&{9Y|NU3>tM0jV>sHmRYNCjU zh=_=Yh=_=Y(&4CL0N^s%EA>?(YC7lmUK>Nm~EA#c92d_HUDNKh=GE_8fZbdjTT59Fdg-|p^~syos@KP z?E0}FR}#^UM8s~O`uvH>cr3u_Xf%EkU!UE|U*$c{V2cDN~3jI+U$ES?*h1uJ52!7a(-(DfTd#Fzwuf{R*SITFSq5ci|}tM zAbhp&jNJep1~5M*Uv?jR%=N#P1YjQ_6F{G*-9A(+w*k1qU*=UNP1C*t zaHyo|9mjGx%^$#70QxgX(9tu`M;+fWrpVageF1FBFg^P0TO<5Zybg>eVOPeqhbqYP z9n%Vo*K=Kl_A4nn0L-!JN-Sru+%L~*rP^Vis{H}gK>Jm*F0Inv#s~=ydF8TDVxEE$_%YU-mS>ar01k4iq&)Q=Pr;8@P6VaDB8den` zww+^%lIlFHWB{g?9cfpTfWuORtA+j1Irmp69dJq+*Avlz>3Dtcg3tl-cQ7qLad66W zt;lB0FThw$$o&s#scG+Yg-pP-oXYVUE+fPiFbL<|P9oYi^l4NJaOU$=Dj7uy$a5=$ z6?}{3Y=lZc-h08go(1+H1XYisKd2_A)lNDjOqI}gO7P_LbDwz@Bmpjq02%m%W{#ZP z{e1`iJM1C-NhKqX0M&#|Der6LjskE9g$XD3*;0O;I`Ppb!C^tQfTr=G`#q0=&TAxqlUg}hO zCIO-eTA=q#TxU`q%Va6A)VY*`YZ-H-h3bIILd&X?b)}*ebHRJEl3cmLjdw)E%X*7- z9-hKLmz3v9nTq!9zbe@70<)#nzyv)V2w;24ikL3VAF`M5vS>{sWkqZ;fW>AFp-ObKTTDdPiwm0M{pc9rQTZH7%T0}lTavQ2YPIPz z2^Xv^?h+%mf0l$QCI#(ki}(}3;3QlqeOKnrM*ttEL`GOsbk}%?BNp7Tv&Jh|QXs*7 z@@~~Fws0$0mQksusn|tTkbse(th+ry`{is%4C%}e!e|P?gl>G0LxNWBtg=YJ=m6at zGlXxSujmNvPiO@TO+}J|U$u|0B=n4zjm7AV;VS&Jz+CMQeg?3=Diw7K3<>w-Xql4m zH-IZm`nC~)(e}Q}n4PT6;9A}btLmrr3T9^s@6-CkR5%G%2oLh2cRPr{kg$it2zjK- zW00J(SmCeo^P^6JY3|308<(|B$$rP@MO@9~*}O2aPi#0m(7U?K=DlIk02~b94he_1 z0?raX_g|AF&-($K5^P*(K~r>2!R?|}Grs_EEr27&_}eD|xYO*&&(nPU?k50)YZm}k z%d?+#<6tL}p!WBY1Z~ed&jB*E@e1$Pl3guF1DNb-{xsQ2biQoFTj%AGw>Mcef2-N6 z_JlmsV{^6um}~3QsU+<7DqWNa7=dh+?M-}rCp$5dNG)>2ftF*V5&o0%Oe4Mb)QN;e zQjwwpYI^4eWkKI7a@>t2c%7QM1mUBOB(NEIBcXCd&@^UDucTSbtn{|^J)j+ahVW}e zf<3J1Rv81RVA^?5Q=Z`0>*-n2kF3rSeyvCt9$B(?0pMw`lC2(7mIfu&@6`D)kLji? ztK_?+bL#3zLPBd;DCb1@LKyG}$vW)A_7}cc;{_Mqoioe)X0Lcs3uI@f74C!~VR(*4 zKA*rF;sAVJVBprs>6m$*>1_bl*pL|S=mH%u&Na?KP$ZNx38jLN6)Ii$m?xnkr_0~$ zsj%rtnwi`!k|3g$2)A6IOo1@ z_kmBYiX)4YDuAJRqJt!QZHfsOVZm?bz$xckf6|FvEU2tlp3q4s9cGl<)5=`DIbSI8 zAg6+Ea=yRIodAAJ1@2GMT~#k6K`UltsSmBi<)0{+><F_zxDcpR&5s8j=72002ovPDHLkV1i!7xF7%k literal 0 HcmV?d00001 diff --git a/assets/launcher/gear.png b/assets/launcher/gear.png new file mode 100644 index 0000000000000000000000000000000000000000..8c3e9bf092b8975c566851e370952465d241578e GIT binary patch literal 2767 zcmV;=3NZDFP)Nkl3qg7qP-c}k(g3ds;eD_9Cbqz|=ykv{lfu}>C6 z)Z$nPG&Sl|`xM(EiDC9zU;xRu-?YNeK!h%5rQ7QhoBgjb@TAsfC|s32Ha`+vKL+$AC> zMdU5Hg3BVZgJ}m9(Ht;O&09$8Bx!GVX@^zN>?C}_L}Wokek3AiWi6I@|0UB6E1)~h zL-UMi7|xQHg*09_1ZN!cl?g#5#>1vi5s4t^}N@KCTiGdJg#K|V7mA$Xxe)@6co`;625yc^O^-sJJyEC zt71b`fEE4^u8^}Zq|iSD;1E^F%)LO*uDp9mqmR;4Lq8XD1aDSR!Ew$x`>;3P*DmVPz5M!e$ZNzT@9 z>7Qv*_=+YM8b)iWK+6w&e=fR)yyEABf;5&QUN=+)nC0^%fMut1w+&mHER#1yE&hmB zDnAZMvXSU_3mkypc!QP!&L(d>BGdbMQU@H|JO!)eT$p-K@NkM>R`# zML{SBZ`2YIRVNLJbO)Z7`@JC#8$kHB5?*pE&MYh8rs4Toiutb#A&(2u9NKi>O&up4 zj5jS625F@nNm)zpix%YWv&3Xg8eRl&(f6eCjc*o5SAxZ2Cb25gZVjt zudXxU8;obwj=tu~lqz%)*#h8G0Mw(lAHbhO2;V8Zs54DKL~cwOFJBUoM?~Zf5!n{K zw~6=rnARNO>k*fEzt5M=m313s@CaoR<3`f}3snH`$C=sk1k2(D5qVrh_FH9$$X@5_ z>yoyU7rqR8ozVBQ%v$ystIo4_+{-fa5X2X>+k6_p3vOv|=@VYDgXJ86Ujz7}8nu{a zz57)YmO+h(zXfmyfDgIfbr{ld-Xi;8lQ7@?9Du*+Z)x+y#0t>FBJvXe-=JnN$#iXm z-X0s>^E$O1iTM zwh2hzsnVvCw`_s3yN0q<472nATS)vK!0wi9HX}#-zw{;OID8tw>wKnoKY)(_Sm6A# zm(Sun+TdU1`9}bsXFTj0l{8Gy`?WCt1hAsMI)HBi z*yaS$Mmdz`WdI)wA-q0yUEqS5?#U5ERW%YvQ$q!JL~D~d3!*h!jRpPMZ!Y}~<`L)H zWLZoly~oRV(j1@(wZ`5AaDl$tqJdFGAa30LRtRB{F2Zq`iE0tL8s&Yw>gJXB-zSWBe&ND3R&#`S6m2c5ydSdGQbBB}_3A?rjk@6F zv_cuq=ZZ6mLdkcD$f*egs%BhqByHJW2F^TkWK5wx{(ZhGvt4O2&C2u|8-|D+7Lngg zAy}4qzb~M2!=2^n_6bzUyU$ByblA}?=Ut0yPDBoh$kS3C5?<$NXQ?GD+)disNUQ2e zS!Hr?n(WFJU`>KQ9(ggcz=NT))uYj=7R?!+C^aHRv8?aG;-8Yh_SlIzjuds3`!(6CSF2KO?ZDQgzyF}hPA1@ z%zHpuPenZBg}t}a1Ycx-bK%xTVq^8H1cdh!4F<%Tqy0q0Ls86^*;rn-yT83kEr=oS zr)ec@Vv%G41*an(T9HB73K&#mCpqJu)eWO@hO}p|PHU9mpyc?cMmv-oI{pXXVha^; zk+h2tEowt_pPK|6cWi`&_)j^qcR2-Vw2OR znyi5Niu@g=)aJ4kpgUg5V8r`E3u@>>MIrK~WKhP`J9OGhQ3T%iwV;OXi+CuCGi6K< zxho)A^*vuSpXj|?d2GyhFBxhA^G-&l3$$50)H51EUvo zGQ68Dvrf%yKpJWp!{Il3itbnlR9(IL~jBwKVz_D=a0o$_u^ zz+t-W3{CCnO5CwxM;u?dj+eBs6!-s;DDNiVWp4H{L#=WoHvwl{1NY-7uD3V*B|#EB+qH&pu`W>>Zu z-C&0&8Id8=Xr=v_8`|oQst(ar7p!5u%(9(hS*@b&z$jl<(;W5r!-YZ<`)#VtY*U?D z0sTg8sePk%e^f!sM-}vkb#(c#4wq4XTurFwxSCqgAK26Z^hcI;0A3T&gYiVr$~E*_F)FIQ_358)WCyAY+#M;9CwF+d>Ft# z0Bq^V`O(IMo&ff7o*g<x3r~|@>Gbd~zZ)nnj+GGWoN_J-fL!H!o zc(>8GGvc*&G|~e5puvgW9Q>mk zWB*?O=$DtqfdZKcUNIZ%`^T6^)rd$gN14}fLND;AGTPUjG}OEci^OWs zf6lf8=s)P(0ra0VzZ$KKbLQd zNvIt~6o&tE-+LNSi31TQG;u&(1x0a)^MC{)D(c2*BPekzL|nwR5;3|G7p@#}BM2%u zgy4`(P;nuK#E8ZrDn?@@lDs?oQuqB8%jX+z-|pLWt5Y8|eY@}N>6}y5r%s)!3uH2x zOeT}bWHOmdCNmq7hK4eh4@PoA_eL}8YMn{N1wa;+}7gRPV%y>gyAiJ!c`$8UzI>RS{9>6a$2AFle{J?p|}Dj zwB9H|9%OO6vi6c(A*DJ`DKnt(Z*Rnq3HfWfW5C2Rduf*+*X8AJ(dQmcwu zk=#w^HeLbXBDV`bS%LK%Z2%tjmq8-5BDOu&NKjSy@V)*u^QNx=7r_kb=HDxR zVfyp#srbB&D*@i)*&!iSAt>)jQRnsh4le}+;e~k@Cn&8ysj!~*!rleo8(6&v1+E=70$&DjzqWfxlm;L6CHc0j=|BZ_dEIn_F!;I{ z?*uE;e@k)|fR9!GmVTc^!ne%#0yxXe{;czz&?>38TT=122av%D04|@kZ{(D{&}pf} zTm~ow&X7*kwxyx~9{_6tR#9lYRs}S{`lFIW%)H5D1w<&cn-kHi78O6aO2d<)=0)dno$s+JOl)3z1{i`(u>_)})K z6Ts~N4(PDd`w!+y23QVYy{x~GOh;;s$~bUJ_yWKax?a;pG*0N59wd2^3~%6?rH<;r z-H^M^vq~HE8Ej4KM>qz+twr7Mkt;yO(s@mL<+?SkBgf;6>S`3Et{0-ux%nK;#I&4S z)WB-6ara{JWQh;|o-6R}H zI5xCcN~CkQ3(2)r*RWJ&(Oq0YCiFPsV@GdlyPlTtV)&T3HaGa)Xck>Pqf52lqUtBU z7vyKXQrP-QrTzKXNzMcrkGA?io-wochY3lS zjfT#9K{pWxfXDUXO(hE28?XZ@?D2aIz&*o`Ehw9wzCmH;EgJIUdcrPnQwAtJi$trP zj@GlYA}B-h#RQpZ|`TedG=*+aX_t0xSqGH{k_)teLrx z`SV)lr3&GrAwsJXKh4dND%FQxs20d356R5k%Ld0sV#eN==5}bbfZVM16W$o z47f39E3JN$ncYzLA3~IwFUW_a?*;Ipc6Smh5j_e2F94Uwm`V>jiZKiNLnQYxO}+~L z1fq7Yu(Y-MB1_VxXx|OsQcWQ|&Lx2Wep9aMD`vJo>HngMJ@a5$V*craRs0Zs9=Q}; vQ_KHJmdRu?nM@{=$z(E_OeT}b;PBxe5-ZZN?^fBz00000NkvXXu0mjfCwC}E literal 0 HcmV?d00001 diff --git a/assets/logo/minilogo.png b/assets/logo/minilogo.png new file mode 100644 index 0000000000000000000000000000000000000000..3224b97d4999a4f3982da2aae1bb8687622f7ef7 GIT binary patch literal 18933 zcmV)QK(xP!P)S>>*Q@sKg{d7$JrP z+yfC6tSBn76h*267Oa*bbs~y`f~erOAVoy2#VRfweWK6P+CKfhuV3Fk?tP#0JLlft zdEavYXaON2aTclqkSUf)BmKSEaq$UkVh^A|1_Iat@c7x1&?vuX0DwSWE;~EY-y8mf zmji$(-dC*x!r5&2|Dej3NM!&>H~=^ZgxP!mBp(1Wa%B=ld>jCn3(^%F#VNQnEC(3TzO%XPAIadNVF2 zsQ-HS525h(GkYHK$uor2{YUKY^0I0GI1>Qa=09Q!$^lx}0%+X&BWAQ4Ksg_vrT2?| zSc-W`mB}QoHa5Arxz-{f-&!H)@A#hy{{Ub7&sy=h-{oQZ2$Om83>jOY8$T-}OD<(+ zOL%-C+v=}F{I3iD{vOjJg~>vxP|O#yV?@GSkvN6T%@PYlGEtV8EfW7H6aQt|U+{UZ z0f15a0hsfxL8X@o*w5X7V^9H0+@)~+ci%$DF`(GISiRBDbN>mSw)xLMixrP-QOc(} zZe%o@FPG+gK2xkE;DG|lzywXu1w$|cE3gM=a05^9g&+unXo!b-z=u@G1S#agGFS}- zPz0M{J5)e5)IuFJ!eMBIV{jVI!6mp1{csxwVFVt-IJ|^O1VM-h9bq7vh#q2$SR(ca z2bqcZA|Xg55|1PyDTo-6Bg>FAND)$kR3LkhI^?$^hJ;C1K2sj3gjkChJ;R0~8aeUlD+zQ-ATm|j` zt_9bLyNVmajpIJx>3D6tCEgVuh>ydk;B)Z>_)`2nd^7$mz8^n=e@P$^SOimoGa-Nw zPe>yyC2S<@Bs39D5&8(jgb5;%s7bUWx)Z~QJfe(PKrAO7B%UN*A&wAVlc*#;l0C_f zlt2=b@<^qmdeRBf71AhalB_~DCcBcu$O7_WaxuA<+)nN#kB}!RsuWX-J0+TuMp;cM zqcl>^QtnWmQ>jz~stYxODx$8UmQfE;&r^q}Z)j>X3mTV}NR!hx(e~3$(QeY7)9G{* zx+gt>E~9Uv*U`_=@6lf?F_f&80+j?xE0rphT9mFRJyj+v8!LM$&r@EaT&jFTxmWp# z3R%TOg{#6-S)o#?a#ZEI%7iLI)lM}`HB)t?YQ5?O)rV?CH4`;oHGx{b+FrFYYQqeS zVaVVz_>47-TEM`nz)GO7Gs}Hg;mI*76mBA`z z9cB%%-cHe(;x$D$Wy6%lDOaYv(wM5@sUg%T)M(P^*LbVR*7Vg(*W9Ans(Dunr)8lP zp_Qw(OY5xGlc~(9Zd3VF3#T5QdP^JCw$P5$UaGxUyIXrgM_0#RN1{`%b4KThE=$)_ zH%)h&?g`y7J%-*)J(1ovy_0&6*y?OAb_TnQ-N_!;*VYfvm+SA*@6n$$Fg2KMkY~_j zaMzGx$T3Vd+-BHeIBuk86l%20sKMx#G1-`7oNBzoxXbvJiK$7f$vTr(lZU1nrh%qQ zOdCw^n9Z$EmAEiEqX0cOD9W_I|j@M+H{| z-w&|}k%qK~qM<>dTSM>6GM*)#)fxt2fni(2?uMI&FAV=af)EiNQ5o?d(k^mYWLFd; zYJOCG)XQk^=;G+xv(0A9XLrOX$0Wwo#k`93i7km8jI)Vb9(O5TD_#`emOxC1N!XV# zF~@h#_BkVoj)?_{19MI1E}DB`p4PmKdB^7~&*#lQl7vr+O{!0N#|z`t@Fo@nE~r{C z&gb%X@E;321=|E;LU&<_a5ULHxg_~vihIh|l(AHg)Y8-^BCe=H^einPtvc;hdRY4Y z^p6>_8BLj_%%setVl{EPxHC&PYf)C8#8Ofq8C>YPaQnh>X|Qx(Hj+IzyG_QFNo3t} zGx=KiP|nPp%AD7^F}X(;sV)*P>RxQIc*Ek+C4Ni3UW!}FU)r%uf7$9~L(9FE?^%JY zSg@jFrQyo_l_RVCR~=YQS)IQ6hdkT7lDrpdV%N0i>*TM>A71OfwxK|&Kw5Bpo!h$V z^|f@&5O+Cd<#k)2WHj6h8Z1LD~ zphUG~Ny+fm(5)@o*xL%Xz1Ti~`-Rf!rPVtqJLEeC%Rt>~}xu57B( zuG&!bYNuf5m0g~@8mo1xi>lx57VW;V$A3>tjY&=EUc%m-y$@<*YP-JXd|kIsYhTg6 z_xr{B2MO{}J(LvTob=+U=H-wqrO zKHPDHbENS*dIe*ZE3W0l9%j~Aby zoX9`%@#K<|6Q?Ano}5lOJ=`(BbYxy*Dm$X>hBwv zJ#gcC;`PBBf*TKSX54&sE9cha?NxX1cM9*S+%3JQeXr(6^B=P%vk zapNP;q|ZM7y79T@^ZhTTzv!3L>3SRcc62iL9r0b+d-M10 zAA&#J`I!0f<8Swb^ZUi9i9!GX010qNS#tmY3ljhU3ljkVnw%H_06$?#L_t(|ob7!F zyrorj@7kx|e(&_g^u|yI83$162qIT7hjBz59Z!`&iT$)_TFo)|N5`;0ermuF>R@) zrlw)z$Bp&)!u@w&&Bxn+d>c7r$bp%L!ov=r6~LuZ08lPh-9T}GK6l=E5B}rH{R7=_ zUUFkbkLhwLmq|I7_TEFiR)@ZxUYbsP?HuqF`nq>6v$gr=`?D0TTdTvxQ!e+^t0Ja{t+G`m+{U+ zwOoZ_e?N{q$p$Gy+(fium8ueyN;#;@i+FNyBdJQKpRhJX>!v5E$zFEY2V_UuK~C;-h8d@p|7V$hEydA z)R%~4E^Mqc>*!X>J39E09(E4q%$Wl>-*O9NvKe|^$mhjz!Iv~=beyRZClgp=)on5v z>25x19Vf6>w^<8PAGCR)kVo)LxAMm}xo&V<+nnaMwx{a#dd)dUkg-;-K!1M`Kj%@> zo%K(eQ4t@rh%(5YRIX2{q|h%?zdSoTZRJuKUVr;dNVzn$b+pmZOx#iR%1U9m)am2j z(jthq`iLM6cop=AxO4F-!-aD@gNY2Nw>VMcabm-6{ni?7(t@~Qd0i-=R zU#V1Fv44POucxaA2ak{`ZX}buyj>kXAoWI8QU`~f4PNXvu{!9eDpM{6UA^6K$6a^2 zdB+|{Dz)zGFl*1fLB3FcAw!2i zXXgg*Qs8_YEwWsVN@$o^HQjoDyfj&0X7;;KeOXE*GE@Q_&V6ndhm2%nF z$`#jL?#A8HvE#0)m88V0gpl?!`-4J&>P-O*OB4?^f{~uCZus?YUVvdEN5J$+lc2G& zkzAIGF258myy!xaKHNKNmJyGA|4;srY~F~WLwV#=4yrhz-owTX8|b}itp?Rf1uB&? zOr1WRwE`W>L8VlNmtT7g#*Q9MAWP?8b*rrj-q;1^PiSue%VJt+A^88U$)!slIdbF; z`E2$&RH5Kp8bKcVf0a_%)$3K?vx6p(s1{kg;?QLPx-2z)*OGe zRp(!LfxF;>3tU5EBiTS5xm9b|!0t14qc7qb>)O!;%}vb#AUS|)wFZBB^`FAZl`CP- zJ@=%suGXsHt32dAJ-u-7!3Xj_ck?)R&f>HI_6hVIqW+VDo5s1_P)S1Of#X`qN|)R%x%X-dGm+z3h) z+K_NlGMM746HKHVLH-6+Ccb^x;fJ|f@AwX>IVbZcpOFMkn=}bkC7Ca$|9f!xd>#e{ z2B4v#fk(9Np;oOyDxJYIxUXDwHS9HWFYsgwgUKVM;s7euD$G4*9#m^}Lc^(Or+q#- zi&i5}9_sxQ=bs3pM~!w{w{9V|t~=U<%8FE|Z1+y)I-!JAP!(A5*qHuu?!RPmhYcHc zUSmVU-Sv9ih?8=$45eZTYEXgh?h1Jc5f(&a6Wbre1LACW&@M zO8aGUSyyN%$oFt()Z}4RRO?lEcf~TuWU?@M>^PBrK)R0+D5}PCg?&UBzcC|*LvwQr zluBioJb4ln@&!}!c%MIi_+MyQB6z~ZfHIavWGt#SlS{+mN-u3~YuO~Au{3{Z_4HR3C|O#T+Macr_uFwoZr)oKMQ z#j@`zmW^29>kF%rPAU#ONu#O%V6rnNpvlrJL(9SGv|;9bdmJXfpB7VC{w8v6dD>}!-h^s zXEM;C>QEEkkolE1e5*6rL9sX*Qcn0$fv`c+50x*t(hCo;-dc zhjVzagj!|+h}5*IW`%qSNoIf%5WG$V<;J~zw;IiApXN$8?t%Wb=owVbe3sBy{QyD z^5|nQWXKTXP;pvom8wVsN`A!S(1KzRgvUl^2@!27ouVkqt}|x9E@Q?dqyeu^lqnV6Uq1S%JM43xg9T?VpzouKMgaBXFQ4#-9DImF7YJyb1wC0Vp(Y5>RwYWuss=SW zem0+l<*Qcm1T)R0?4z-kDy2HNia7X%%q6J*4-bT&}<`9(xRqIQ+9rA3Bt9 z4w2zF&`M_$q2#+>V*)gN$hD2Pz+a=2KsJE)!dM~n76*_H^9?z4fC--o zLu1R3UbE*V_>JRGESPdFN{y)xcIlU|fD1186S9puaE(n3=1!OzAW=gQ!>SY~0l$Fz zzqx23OrJc3cD(uKn_=e6nNVliPHr_ts=TLX2h7-Y1~fG`new~h%U8mYM;-zFeZA1w zs3;fVTZ)bo)}RMM%4F~P7Tep~=qAwjeEjiWLa|hYA%llPUC?x%X+ByScieV6tXZ|% zpZ}%v-TQ0S(EZuOh~(-dqcl&NGD$~D#~XYNSGajr2~D14$HE_RJR+u}bTMW4XrAJ| z{lx)jEHnt#@{VtW;fKke$apq}$16>qW zXfPvmk$BSp?yLn1V8iB3goN=+X}l_8ZakDLWtcr{HZ=0Yh9ULd74i*$+M=ncSuwI{ zKDs2*Ju<4Q(dr&1jvr6QIpfST$^RWbd^kz`mMxp%!2J(2Ri@Ye@Iw!|haP(9Lu5an zhtF~0r5D4obB}>_AG{Bv$BdPQ#S=qFT)eQvdiA=qnCD zQ=x$lk`gaV>1np6GM55@><&rVS?cg$d(PYw&OY-@v4=b%s?Tu{w{G1^VKKCWh#FCi z;>Y7Bpxp_E<@GmSr@sdc8Uzc@K8I9sF3nsmo)2yOC-#V>PzMzlb)pJIX^fMK@bz_H z|2iCa;6Y^X?YjaEa4gV7=>kJEy?)(#IOxEGSY;`dPa?phf%nnf-3@o%c^4dW+&t*+ z?txG5w;yXLA^0=tG~EZ@gR&0zeH`aUAA5ACNs4$2RhE24^Xh}{H1(O3Tyq~IXQ{7P zgrVWEA)sLOnot>h;#7)0H&>0QI0rknZ-)a8*dO{a+TsCn*_^-nYgfBt z=FW@M3NN{8y#}YBb~>>)>JO#JB8tHmC5ij#%*N&}l}Z~~_2Uwm(TE(hQI^BZ9PiSubc@{#TO9U<%Ay!axF89N5Lw|5a+ z)!5WX=f;STU6KdVJeOa3Sx~XcTIy}8yCZthq?hF=ftg7ZWtJmXD2OvnaB09b6&h*a z%w~igX_bXwRZtan&El_;qaKmpNX1plRiDq~91i}NU3P(CLx(|2YYR(wWVBbUSV7;RT7emCJN1UL_*mN~@6*FapqAy1C5Ah{8O=V6rMs8G-f zyx!GR?&#V9z1`g~bi@dOv6>OVGc#h;2%6M}rbgk$P$EJA0Qc|f>w{T)?=7Dz183A? z;(Cl`HYAiFGMKqyW{$=$;pgi6%5#(}6_2Y-v_hCa7RTr^_$(A^LMx4qy`bMhp)m?? z0Zg1S85X|#E;*nW!O#IDWSd1fWRUdiGf%^xoc|>{V7Xj|>C>j0n?>-AfB?YUqmG7b z7I|KywiL@M2lBA;fGc-#$MxWYu-)M0LB})n)rz=QY(4s+oFq&s4fW963 zxhduq#FZhBLS~oQXPs2Crl!0YIY(YwkZnS_=mXYjd~+EN&L|Qs6bevi%tJ#%!AM81 zE{I4Lf{Vir|150UwuRo{g;FQU)!}9mAVsK^fj3K)nvi+(=D~t<7m%GZ3@G4c!q~BV zry|XKD?+dUz+s>HOt3ZrEh0WyJy*En_U}NsT!Q}oURbqim9D$s9M(OB04vop+4c?V z*Td#bn_$z14Y1EAXTyH`?#ufHSi=)YZ}Lx?v@l;5ETF}MKZ(>;#h?<$VaW^!rXx34 zC(zclk|aFo%n|8G>5ht6c)RLr*TMmx{`Pg zDZo@Hmr5{Vk!uv)ba-8*(bPj@%CwD8AByjLn@ZzoNjoS@r)iF!J6y;_BA zV`JnKFuA{M)e1Lb>NF^o%kbu#Z!)-PY=Fs=Co6_CK!?}0=bnVD-Bs6I1N-m)XQv*4OQjNw8NG|dl{l`rW5;%A@90pCZFnW= z2B=6&Y%MGR3L<0llF~I|Y59l+f|JLZ!@`)+y1__S;1tL$O*u!bu;{gR7TBbP(fd{1{5>^5UJ-_qPdZo48^ zoQNIUyI@o22DtCOAHnW3c83|$cNNWR9);LI6%Qp_4bMh!ga231RP!|`S1VAeRD7jY z!NrXC;mg$u8OxBpD!MUBfDZ{=N(@ujHjFO0YL&>=KqKn|}AKVK& zcI=?sT0BQCo1=qZER=%=L66OtwyUpUDii=*a`|O2_o$fnN)+3L`8qcusFz&Qt8p1M(P@uh^y9WVd_Qx@cc1yHC~2)D770t zl?X#1?8P)FCP<}-8`x)`ecD9@LI8*yB7eJz2dD8N5JD0~EJWfU;$AnE8v< zOgv7B!2xjRe&GwyP$&dZSCx=*@V)PUpGf`Y=4Lwp9k1>=6`v%Ji4P2(OX?@>c-MEn zL(!YvX6y!Aw{9bFHD;Hw6jkc!?jfHO|3-6wI_;?^pMvYJzh0D#u1rN@rcrgoSTdvtZVW|BG+|dG7150(l6L{% zvMaBE1!tX2Y7yqj8xFp<%k z|FFcWy2AyCi+jnkrL=&_bjgBFsE8=;OHPk1Nue_(2~Ni!!?Y5m<-a8p!zd69%Q9Rx zYof=D)2%=sxSljTpspE7ple}#~7LryXWNX zdL7<;<1PQzn{Uxxxm>|lMSaJ^>mtR6Oc^Gq?X+To2oqy!9*AP5_y*P8BT=A3WNU=Tkrb91bat+1-!EFncoEySZ-cpW=R#9M zBTBMl(~ERQ5oATJVWKRlGk9Ne^XT$bYt@g!0^ZDXbT4S5VkwIykU8e0RGMQifN$E= zsqV#>UV^UfZlXEOq$>f0<|dss;ZR!|MnYswll;g_(Ys`xMV&GQ10;iDnl+}R28P8} z$Bju0Q;iWO2)`quA-XI+SIEq_3BV7#y1MAJ%a<*K(@s5w(n&alGk_TUqak15;DTlp z#LQZ>pG<}zQ18%gTjqxhLegc_tbe0kEH)xuA=l~btoHQ)JOH%&q=^&Z@h6`EblFm= zl&Nc-^q$^cdY>gumZW8<;7_LE#8VRa5CzOSlzDesG%<4`7W$*Kh*q?)dS*@n2_4H# z11B~YOH`7e01uT(r?Jd|a?LasHeye7nFSOrxlSK){>3wn(s7fMr{@h(5Ofy=z?jGw z()*|_!fsv}MW1j~BMCGTJrF@FSFdK2ErnF9@fevnmkKD8W=CyBv|MaeP<=vl$qa%E zg$DQBubzdo&pZoS+gn+(Xu&vm{>7I_@8CjUKTTDa5*U&VCIm;dQj0y~5Nx0jr{&^% zo?Oh%w|ftwimMgTXT)3u>5By{fiyzA(GuZy`ku1VWt2d^ylT}cI>rY5RmEfb4Z^V=6+a%Y}# z2DG)e3$cQ^%~c|BI6u`mBk^1LTHaprF4!} zI^!a_X{~+=9uBUov`-=Aj`Y_@N}hrFI9H4LN}fQ~1qt=Xr&O(Dj5Q{?#kpQOotE*b zW}9lN^rZL#T`)`}M}II)Hc%FUvILYuYe-n|O%h<)s|b}*Rf8;F#1AjN@B$n>XO1rx z`(3$I@t>GE(;+8N#CjxAnBiVkzJswtU>B=d{0P&4SFT-aD_F6fj;APQj#}1A`FMc7j+c`bRDY_`PAG+O<%Q3(gCLQ zQU@(9ZLoOh5?3sid?uX{RT)Z3F#3{CrA3|bq|iMXCeb{KohmS84VmjC_cwmrI2hb9 zh@yv_i;Xq8oOZrs#WEN(dNc|M9SJdniX^&a5vLz1wXl){fq-?j8a*?+%PA4!;wp=T zZzA^=eBYqmj6Rl|kjdqv>HP(2jepm@ckJHq1juR(DIl~|RoBR8QP5Z>YUiXtr8*M< zfBfRekt5;N*Iy%dfz@;TMJ}qM!3b}o1`v?M7void(5-Q%K#bb*`GWuZAO6sN^Bdp5 z!Y#VA-@f{)kq=r9Tdh{zmaSU=lLuM#av+&PiGUdw1#u_17Kradl%*_Cq#iA*gE8(X zmzk2$J-~u<&$Zhj?MuM{%g!}@ifQe*KN%X1AO(kMCwJQl#4RJl2lt-E&^bT@k%kXNRUD8 zulc{xgb~ZxNJlImO%BF&K*L!Q%!sKaFD5Pgc0i7)aou+2`2KtG4%S#4FwO*&cE2VP8 zOd;RRoH5=MCFS7@DWv%Ry?5I@V+EU%H9A4NPMZop`^94xjVTrf1RH6(>^MWYsDiqycPQ*_uhH!h+3n7B5`_xojR; zCey-E+(UY=Z3Nzps@(GrEH;o9sHCr+5?2Di790ArA1pnt%4TTEitsz0wF z+S(0pvNj@EoD3{-RyC@NM~@?E-LP>(rwxdWkeHkO_WQKMpoAbZ$YpYd3-MfAwr+z@ z&7MsMM6iTWn@05OYaW|2&>1kz=xf(q2Pd6;G9RA-M__Iun8_^_Xj%i;WV$nAurz~H zOE?3sX*Vhn66QkDKE1Q>9hbriHYDbQMe1ef2dY$XUg*UZP`S=x@^&w1HUk73yqL8C)TJFSt)CPT*y@ z5joTn>x_^lMp^JaG=PF1&7M7*ilPnf$pGeaSdNm0>C-WR zP@9yglco?c&oyRZPsNo|XFtSrc*|z8FmuKp@cP?tkwha1(mS?zf|ycj^bz}8(Z+M- z;q0@|HojfVFC*t{mfP+{RI`QX+b{qZlRf-8C%+z|ite zWNTEA8%P>eI0>I2d@W*(6n%^1ic+n6ZQ8I=mpkJooGG?nl(%8`h)P`nkC0#?Y!@vp z@s5TK9zu4KV7yEsC3#7<1Z7N^!BBUduew~RK&e`R*|YYhD__2JX<|%5cmrV3lBM!C z%eNOVSzQI(o_p>&vbTx=>^PS96l@s=GgQqY2-17QqhiWV7+#)Ca|vW5?!WJTSi5#D ztXj1iR<2q_ztMy+R~9BK1igY2ike2nGAhc8|HX+gGHnr1k;^c&j2~^@w2|!b@Dan5 zxrnb>@m54ch|Y~9k#7#m*Hf;kxrx$hPy)kJ3yEWhQYmFsQeyC3lp?100%V~0uLn5v zvxhtG){=CQaPYf&y5v%+WDnFGFhTW%ez{CE5*BqNm0M7|-O-e09$m1r=zLmWSZ8g#U`Lrx~v(p-bl zhmD&yK~E0?4+neAoJrXf=mVnK3JWE4^g`cI2az*4`20+{g=H)<3k<}SQjlbeD3w;_ z3|+Q_59FM~M0RYa5bJnydobAE+Ujrm)?bLrmRS2#5I%nFE?7a~D9;)RAZ-4X%Sqmr zB;L!0@mgVo*(>>uR}yyRVID|Cl%|1FohTEg)wMgopyGj=}tQ7WYa>bkF*kC=fo|}@8o}K zC=<5W>hi_U4fG&u0?pq=M2H}R`%G{JxW#p2uoU6knCwQn5*>fs4?%~naU9sU7A?~I z(JOS{yx|7;_HDOO?F_GOg(Dn|snr=w;p=qDG8^c(7Idq}6ud)FSJyxP+;bu5XdWA% zexLpJh2Gv?`_j}Y>@`S4EZ9hg_7MwJYhzc78M^dE)upf<+9COvNyNPys!8r@y znrp8Ww^O)dsSc4b6p=nK?QO6WlwEDS9tE)&WODvNhpiI?9Tva9@TB%#q zzMuHSOvwiI1q*Gb-gL&HPy6Gg(-?j8$m3x`>M<9HEt7fo{jv zMLPDH#kjt+Q>D?q&gond;15)Tjx-a|>T+vJGhP6lSVlRb6P)6?%Fx^4QVU1sPSH(O@E&ShRd`!ZwnFZs(PW74np7iy9g{k{Rvo ztqwCf$c82smxq?NHj}Pez3$rDTj9jxk0<5~-$RCo7LEarFd1Cq>tXBWt)}`=V%Tky zAr@tD-8cS>r7i+Bgrpq@Goqu?Pqfddi5`0RVc2({eM6KeT4jn(m9}7^;PF=-#M6x3 ze)+g4Ae%OAqRdF$ORW|r$4!_pk={qOEM8lPD#U4@mcwFc2ZqVwwmLiGt)M4@r6+rZ z%#N;7rPc6!d(4;y?QI=aYl|%u`q@^ww-zm=#DSR8R_SOcgwM(Ay=G2q8BJ zCGn+~ehd3iF?3x@$HRqU^hxhE!R;yd^L4oHx>vdO1Y8L<`;SC%{)Y+6R$2~aYvA&|pd zF1j|v8YH=P)`$)XK*az9M=B^qkTZ7G%`nrMXqCei2kNfy0-{WWC~l=h7Awu|lsy3O1ES<{^;z8t$fv zG72@6``8I1FEq{|S@Yg%7`w~3klLy$lKZO#h9-8niLcN$Mmp(?FeyAmEfOdPH+(Ow zR$VL+`{hz@U|@jyE#xyN`q3XgRoHBV5s%eM*E5|L_erdcim&=js zg+8OsL1>~AvK)BGLV2K0Yz6&S0BG-MCm#hx;)UP7Ot;b6+Ug$q=LczV9CFAU-`3HN zImS`-j>NxlJUl92?Wx6qbi+Q*`TjnZKuTkc(#CWsBI3fjA88k0OAZK{D10bc5JU<| z$ShyT!vp{HPwv~deA_i*TClc^;Gv+kkZ;ISv7JaR_nKgsBv@4g)*%;=+eHb66eJz@ zD5eQXXVMN`2rC8A)+nBX)O+o|hgFy(!GVgv6nW@HPVwKj7QRCl@cA=NH?6DwAo+So zVRDNfLkgJ1KWpz<@XW7%MZgY~sYDkouM5wFKImr-JKTn)TB(E_Z3cBgR9(t%?Civv zAZC^&nHJ*x(10X(QEC^ zF8~%6y}KADOc>9^z7h3d<|rdl4=bC1i!;JQ0VH*cEtO#7 zrj26j$kbR;8(jysUZ7+0>J14w>QF}fgaOUa_bD>$^vANL%iI^vImb0NHhJvIDm${B z1m~!QkvP*D(F*{~cwjm?22xh6UIpDfJVp=7GpOCZ4P@C!ty;C3(r2E2>M3{DS!eQNS$rKXx%d*e z@mt^W-CbRdY!$1OaJH~S%}iJpf_EPKT5RmxK&_5M)0kwTeMQ&F8Qm6DjmTf<1Bw6y z(Ub#Onygd`;O(wjxyqe=)>(i6hP&39+NS*)uqj);uD+d|DgmpgivYTYfi~dld3)hP zD3vky3+R~$xaiSkQ`(>5Dw~&en>NtFr%#&9nUC`LngmG^@c{PdC=pVr8B3;^F}urP zgI7GOJse2g%jQE?w+<4H(P4)l4(r!(Xb_l#{)_r#igLL zbDae7qdmC0ina`}7^?&|WFb&i2bC`B^57LN4XYs(BU8kvUL(mn@q`m#%a$#awi~VX zxMho71W&9kh6FVh*aMWTbc%uqZ@m2$rPydrHR=^?%|vY+YIO>lOrJaz{`ujD2=sIe z9%O8pcAv%XEnf64tXjPaTkyl!2@?`2zU5*GviTgfNTP;oW({S$(o$%rS{yU&>N98(hXFxdwPsZ!NX&`bq3D|cujxk?klDH*4 zHx*;;EI^VnM)~n;Pdxrh7&T^;Tet25^sO2F*OZg?)X=4WU;vIh>PYhOAAa~@Si5F5 zfksMBR5Zl7&n}nGCu+FD9P*n;KQhwNJJiK2i$G7DP-7O_GX?JOfefZvV)!o@Mbc># zMfw5O$E7E-S;&^Lh^5$i?{b+89DUT$t~fA|DA};iv5I6JABc%~No2FjP@EASXn}6p z3G+{YTfcLM>+b0$@&Q%x=8YTSbBBEv&OY~C*uHfew6?XHz_SE!h*Z^R4E0s$@z>w| z-QU43W5>Y(2OdQJJypYq&yTL-zWeS=-@$&Ec*#c`afD=_=E>zlK-wrhCR@2HPk?xK zZ-F?^Xd6GJDnuxZepH7Z)nDiO^+f37N-`2dO|{&uf(u>#(B;|;j< zvdg$%bjD%-GiiE^jX?{re8o!2}R7A~cgs4npjmc5K7U$ifJA^%<{^G_P;a?tp zn23)=*hwG)RvH>|;-=3!u>nQ|e^8Bs$Hn;iTW?at2fL6{>!frBe*LRw;o=KIY?p3F z0h(wGJP@Vciv4PBZKZlC^{5)K1091)`u)NC@7n_s(G;K?^X|Kgs5b~cg8>5=NWL6< z?6H8T8`}>z<~du_U>p11n3OC>UF54+Oq z`jFX5qoe#l`_Q&NZ-wQ_S0MZxym9A-bo2+K`b&;nN|0+d%? zd(ELQh*1yR?-P6Mq3z7fc%l@cT`%_aK|a@R=gUKNH(CoNNQ#tGNZ~&afT~WM!+Du_x`dkU+K;{ z=Nu#lEXb6llvZ|`+S=M30t^Kb;<}k~qG$X-`&wS0sjQv7=_k0m$5VWH|odh4n^kNxM;=9K zuUNO+kk7;DU3T$fMvrkzmM$SXd;9ITQ&z{?)vIB`#EDSHVnc~pQ<0E1&x%U4>=A=3 zAT%18Y&MsgHER}DpwaapgTjp=&8vt)S0dGs%YuJjf8(FS(MQiEyM#GK7;_9z*0T9r zYfCGP9zI-sW6{nwu}XNr<(FN~3=M@12|ja|*CZE%F9!TQb(_BX0g;>Wo?1-n0GZj!CGMcZ^Euf1v0Mi?lT zVEXiF7CKeyi8aZM*+$pX+v7i2`@Vbsz4yrzL5W55e&tub45yxY8uaw^5pt&q6%90U z^a$V5#4&DT{d7f1D31!k^n-qCdyfyUh!cz?<$--rQIUbzdG5s*Ad^bNpdo`f5FmuU zrlXp{rq1RLdi#1|$;uTlW6D&E_iC^i4|&-Ym&2g;cFs8>Qb61^1T&gxA-dR7ImJJo zz0Yh|aMl7srK*|g9$?Mt)zI47BK$M6GAOnCe|mp90QR0l6Lt69cf%Q{orVeLT1`&c zh#p48Rqk{>SoLRs5zBBkZlL5iZP0>~j;(iZxc+*0=zl+CEYc5t_ygEyzfXnyWQRe8 zf{ZRd5p1ATguVCP8-8@(ePoj{ij|8%75=N%E2v?T>12t7^$?c8CS#_``icX-Azv`T zMD92=jMw>gaNP07!DW|U0YirjZwLY;uo=XEO21|ZC7V>dCG|MO237cRGicZGjqiTH0TzcGCS(wMMB-?iZSds9>P46kn-EH^X$tfQ@euAWH)-CXkAPQ~UgFk%$4mHb#yX z_LAwjWZ5#nNxlCkKcPv(HuvcMPyrleL^w)9$Ls3vAApt1S6C)GqF(j+E5$NtBP`EB zQ-I0w2nMmO@YPpc1+zann{G)olO~#~GFDeORIMO*3@S{elzz}Di>0E^X0p<@5a6`a zPlG-7+AC3|i@yg3`r(+P=fVdcd|-$A_AR$~44=(E;dtm=zm9AyK9OVI+<6`W0-{g7 z*sHF+8ZNxx!qCP*)cW3g??n+D9Hebqx6uQav_jjOpV33ZxLPo|d5Zk#Mn0+(Vqw#6 zloq^!h8#aVg`RI~U*+?ke){RYkjKW~s%c_c9zA$IwVXIO6J#oT# z;(~wq#1nASU;ZVmUcHhZ4}UVBPDSgXX42YH~`=whX@qDYbkr<9CDqszj`{ zD$*Ps@eM--fz0fsS6l&o^q?nw_MRe32Yc+chjc#+dKQuTU*CBreD|)qh_bxvum2kM z-~Ry2G;sK67qYn?VAzP^VdGZ0O303y6`&=ACi30_J-Ia)% zOF_9*gpC_FLMyiR_W%pueA~}G>S%|I*O$I@0nD2>m+G8ux#h3mh2OkLKup0Cp?#@U zvI&epvAS305LpTZ?}anZfQo8;IDJ*8TA$pfD+TQfH|D^B;)$dJty)b|SNNppQ!ZV( z!qQxF`FtQn}q{3{|Ug|E@uw|0CVV2AynOfke z5E~;!{8SRYKU_SUHg1A>^NxYm*0wmOntrBJ8GrA+_qcoSy~hsM*wA2Yk3_r@NeXHz z)XriBWOyWDa9Ocpqq(7x4oYfS;GZ&QJ8LRvVA%6uY6myL2%$78)s* z*|~0=^4&asW^b=3GV~#!=h7UvXL^P9B-eGSjRM6)xIENM;P8Kp) z`1kIZUk%>>1V=n?OnwvS+* z*;s}^)FvvfqpHBjKv+_boUOa34_ca=6i`|IjFpENauzLzD_NNxW_h@d$AnH+A#U$1 zd!l*h&N~rYVB#LE1xtJc?d~tg^ zwvbDj42ow>7fZ#qVrif+erS|cD{5RWs?OEK&Mo-Dn{@fIDyd4&)m0^swOv z9{ec`A2CvzjD*@3yhn6@P-?@Vs9^BTN>U&B%4GLaDL}g#k0$Pa?s;G2CtwP{i>e

+@&8%dfmb$HIx#>6<-kmTKcTjmWynM)PQb67(8xB!QGl z6dgg+le940td!JXgr+>2k;1`D$_~-Jo$E~joRRAe-JAz zNGN!@_*xxWT9M<_8{m)-wGg5*3*L9!#PRSCfBz4FsthFs;k^POStUd>4jw!x)VIp{ zP#vOO!^ML>;bDh<2Hh5a*%g<&6HlB^$peEr2Em36Ncpi%eE6Y<;Mr%N#a8t2v!DHp ze8Pst23hoc6SsZ)R=D-sx6=OBH!7L(W+`$>w<5|h_THB()r2-P(m?1e4MMUHEMahM z?rmL1Ir-bt!nGq@$PuFt>h;=X*RT!E#l zRzR(c{W8!ksB^f=$zx41swP!kDETPq8EjhO9LziJICC+Atu4Tzb3S7ihtiM!@NKu; z;^&_*-&Lw5_|cF47hH4IHJGeMkAuN4?==&BZOalkwwaNK)mIY$5&BXg+CsOxY-xmCH1N`XrnG(v2R}DA$*? z9mWN*@kK%&pZ+|0%;$_s-2sN!7CXHz$+ z1e9A3yq}F~BOJaF4K`okg)Wlh- zzB1q)GWcaP+J*No*Z5Iu@dO2%FZr&NTN+yQ%VGH3BFPdbu=l{`%+lZSJHIFvi%0)E zD)f(H6Z%p5EhvS;pGB5N_SUK}HP5nJEItAw#J+{^@Bz#?MJx*_{~Idwk9uNpW71SR)fn)n&6MFzDU^x4s>sVAgp7TV zG|?b{l}L$NiMD-WiI#Cugx1;7-*zBN$;Fnc=}e|Lysry`J{NZo^3R6dg UMPx# literal 0 HcmV?d00001 diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh index 44dbac4c..ea587822 100755 --- a/build-rg34xxsp.sh +++ b/build-rg34xxsp.sh @@ -89,7 +89,7 @@ mkdir -p "$GAME_SRC" # tools/save-editor is part of that payload: the launcher's Edit button on a # save row opens it in-process (main.lua). (cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \ - main.lua conf.lua src data assets tools/save-editor \ + main.lua conf.lua src libs data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') if unzip -Z1 "$WORK/game-payload.zip" \ diff --git a/libs/flexlove/FlexLove.lua b/libs/flexlove/FlexLove.lua new file mode 100644 index 00000000..ef17edbf --- /dev/null +++ b/libs/flexlove/FlexLove.lua @@ -0,0 +1,1853 @@ +local packageName = ... or "FlexLove" +local modulePath = packageName:match("(.-)[^%.]+$") -- Get the module path prefix (e.g., "libs." or "") +-- If modulePath is empty (e.g., require("FlexLove")), use the package name +if modulePath == "" then + modulePath = packageName .. "." +end + +local function req(name) + return require(modulePath .. "modules." .. name) +end + +---@type ErrorHandler +local ErrorHandler = req("ErrorHandler") +local ModuleLoader = req("ModuleLoader") +ModuleLoader.init({ ErrorHandler = ErrorHandler }) + +local function safeReq(name, isOptional) + local module = ModuleLoader.safeRequire(modulePath .. "modules." .. name, isOptional) + if isOptional and module and module._isStub then + return nil + end + return module +end + +-- Required core modules +local utils = req("utils") +local Calc = req("Calc") +local Units = req("Units") +local Context = req("Context") +---@type StateManager +local StateManager = req("StateManager") +local RoundedRect = req("RoundedRect") +local Grid = req("Grid") +local InputEvent = req("InputEvent") +local TextEditor = req("TextEditor") +---@type LayoutEngine +local LayoutEngine = req("LayoutEngine") +local Renderer = req("Renderer") +---@type EventHandler +local EventHandler = req("EventHandler") +local ScrollManager = req("ScrollManager") +---@type ZIndex +local ZIndex = req("ZIndex") +---@type Element +local Element = req("Element") +---@type Color +local Color = req("Color") + +-- Lua 5.2+ compatibility for unpack (bare global `unpack` is nil under +-- Lua 5.4, which the stock test runner uses). mirrors the shim in +-- modules/Blur.lua:2 and modules/Element.lua:162. +local unpack = table.unpack or unpack + +---@type Select +local Select = req("Select") + +-- Behavior: mouse/touch event handling, pressed-state, hit-testing (task 02). +-- Auto-attaches to interactive elements via shouldAttach(props). +local Clickable = req("behaviors.Clickable") + +-- Behavior: Renderer ownership + theme-state rendering (task 07). Owns the +-- single Renderer:draw call and creates the per-element Renderer. Attaches to +-- every renderable element (see behaviors/Themed.lua for the always-attach +-- rationale). Must precede Clickable in the registry so its core Renderer:draw +-- runs before Clickable's pressed-state overlay (onDraw layering). +local Themed = req("behaviors.Themed") + +-- Behavior: image loading + image rendering config (task 07). Enriches the +-- shared element._renderer with image config, runs the deferred image-load +-- pipeline, and persists _loadedImage across immediate-mode frames. Attaches to +-- elements with imagePath/image. +local Imageable = req("behaviors.Imageable") + +-- Behavior: animation update, interpolation, chaining, transition wiring +-- (task 06). Auto-attaches to elements that pre-declare `transitions`, and +-- late-attaches on demand via Animated.ensureAttached when an animation is +-- created post-construction (animateTo/fadeIn/direct assignment/transition fire). +local Animated = req("behaviors.Animated") + +-- Behavior: Select state-machine lifecycle (task 05). Owns select subsystem +-- init, managed-frame layout sync each frame, and select save/restore. Auto- +-- attaches to elements with selectParent or selectOption props. +local Selectable = req("behaviors.Selectable") + +-- Behavior: TextEditor subsystem ownership — text editing, cursor management, +-- text selection, text-related input handling, and text-editor save/restore +-- (task 04). Auto-attaches to editable elements and text-bearing elements via +-- shouldAttach(props); onAttach allocates the TextEditor (editable only). +-- Element retains 1-line forwarders routed through this module for the 27 +-- text-editor delegate methods, eliminating the `if self._textEditor` guards. +local TextEditable = req("behaviors.TextEditable") + +-- Behavior: ScrollManager lifecycle (task 03, landed via the task 08 capstone). +-- Owns ScrollManager creation + immediate-mode scrollbar interaction-state +-- restore (formerly Element:_initScrollManager). Auto-attaches to elements that +-- declare overflow / overflowX / overflowY. Placed late in the registry: its +-- onAttach creates the ScrollManager, which no other behavior's onAttach +-- depends on. The ScrollManager update / scrollbar draw / state save-restore +-- stay inline in Element:update / Element:draw / Element:saveState as +-- unconditional 1-line delegates (task 09 folds them into hooks). +local Scrollable = req("behaviors.Scrollable") + +-- Behavior: generic public-property persistence across the immediate-mode +-- recreation cycle (task 12). Owns the `_props` snapshot (event-driven mutations +-- to `text` / `display` / `opacity` / ... that must survive per-frame Element +-- recreation). Auto-attaches to every element; placed LAST in the registry so +-- its restoreState overrides subsystem-hydrated state, preserving the legacy +-- restore ordering (behaviors first, `_props` tail). With this behavior in +-- place, Element:saveState / Element:restoreState collapse to a pure +-- behavior-dispatch loop and Element owns zero property-extraction logic. +local Persistable = req("behaviors.Persistable") + +-- Optional modules (can be excluded in minimal builds) +local Blur = safeReq("Blur", true) +---@type Performance +local Performance = safeReq("Performance", true) +---@type KeyboardNavigation +local KeyboardNavigation = safeReq("KeyboardNavigation", true) +---@type FocusIndicator +local FocusIndicator = safeReq("FocusIndicator", true) +local ImageRenderer = safeReq("ImageRenderer", true) +local ImageScaler = safeReq("ImageScaler", true) +local NinePatch = safeReq("NinePatch", true) +local ImageCache = safeReq("ImageCache", true) +local GestureRecognizer = safeReq("GestureRecognizer", true) +---@type PropertySchema +local PropertySchema = req("PropertySchema") +---@type Animation +local Animation = safeReq("Animation", true) +---@type Theme +local Theme = safeReq("Theme", true) + +-- Handle Animation.Transform safely +local Transform = Animation and Animation.Transform or nil + +local enums = utils.enums + +local flexlove = Context +flexlove._VERSION = "0.15.0" +flexlove._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox" +flexlove._URL = "https://github.com/mikefreno/FlexLove" +flexlove._LICENSE = [[ + MIT License + + Copyright (c) 2025 Mike Freno + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +]] + +-- GC (Garbage Collection) configuration +---@type GCConfig +flexlove._gcConfig = { + strategy = "auto", -- "auto", "periodic", "manual", "disabled" + memoryThreshold = 100, -- MB before forcing GC + interval = 60, -- Frames between GC steps (for periodic mode) + stepSize = 200, -- Work units per GC step (higher = more aggressive) +} +---@type GCState +flexlove._gcState = { + framesSinceLastGC = 0, + lastMemory = 0, + gcCount = 0, +} + +-- Deferred callback queue for operations that cannot run while Canvas is active +---@type function[] +flexlove._deferredCallbacks = {} + +-- Track accumulated delta time for immediate mode updates +flexlove._accumulatedDt = 0 + +-- Touch ownership tracking: maps touch ID (string) to the element that owns it +---@type table +flexlove._touchOwners = {} + +---@type table +flexlove._mouseButtonStates = {} + +-- Shared GestureRecognizer instance for touch routing (initialized in init()) +---@type GestureRecognizer|nil +flexlove._gestureRecognizer = nil + +--- Check if FlexLove initialization is complete and ready to create elements +--- Use this before creating elements to avoid automatic queueing +---@return boolean ready True if FlexLove is initialized and ready to use +function flexlove.isReady() + return flexlove._initState == "ready" +end + +--- Set up FlexLove for your application's specific needs - configure responsive scaling, theming, rendering mode, and debugging tools +--- Use this to establish a consistent UI foundation that adapts to different screen sizes and provides performance insights +--- After initialization, any queued element creation calls will be automatically processed +---@param config FlexLoveConfig? +function flexlove.init(config) + flexlove._initState = "initializing" + config = config or {} + + flexlove._ErrorHandler = ErrorHandler.init({ + includeStackTrace = config.includeStackTrace, + logLevel = config.reportingLogLevel, + logTarget = config.errorLogTarget, + logFile = config.errorLogFile, + maxLogSize = config.errorLogMaxSize, + maxLogFiles = config.maxErrorLogFiles, + enableRotation = config.errorLogRotateEnabled, + }) + + -- Initialize Performance if available + if Performance then + flexlove._Performance = Performance.init({ + enabled = config.performanceMonitoring or true, + hudEnabled = false, -- Start with HUD disabled + hudToggleKey = config.performanceHudKey or "f3", + hudPosition = config.performanceHudPosition or { x = 10, y = 10 }, + warningThresholdMs = config.performanceWarningThreshold or 13.0, + criticalThresholdMs = config.performanceCriticalThreshold or 16.67, + logToConsole = config.performanceLogToConsole or false, + logWarnings = config.performanceWarnings or false, + warningsEnabled = config.performanceWarnings or false, + memoryProfiling = config.memoryProfiling or config.immediateMode and true or false, + }, { ErrorHandler = flexlove._ErrorHandler }) + + if config.immediateMode then + flexlove._Performance:registerTableForMonitoring( + "StateManager.stateStore", + StateManager._getInternalState().stateStore + ) + flexlove._Performance:registerTableForMonitoring( + "StateManager.stateMetadata", + StateManager._getInternalState().stateMetadata + ) + end + else + flexlove._Performance = Performance + end + + -- Initialize optional modules if available + if ModuleLoader.isModuleLoaded(modulePath .. "modules.ImageRenderer") then + ImageRenderer.init({ ErrorHandler = flexlove._ErrorHandler, utils = utils }) + end + + if ModuleLoader.isModuleLoaded(modulePath .. "modules.ImageScaler") then + ImageScaler.init({ ErrorHandler = flexlove._ErrorHandler }) + end + + if ModuleLoader.isModuleLoaded(modulePath .. "modules.NinePatch") then + NinePatch.init({ ErrorHandler = flexlove._ErrorHandler }) + end + + -- Initialize Blur module with immediate mode optimization config + if ModuleLoader.isModuleLoaded(modulePath .. "modules.Blur") then + local blurOptimizations = config.immediateModeBlurOptimizations + if blurOptimizations == nil then + blurOptimizations = true -- Default to enabled + end + Blur.init({ + ErrorHandler = flexlove._ErrorHandler, + immediateModeOptimizations = blurOptimizations and config.immediateMode or false, + }) + end + + -- Initialize required modules + StateManager.init({ ErrorHandler = flexlove._ErrorHandler }) + Calc.init({ ErrorHandler = flexlove._ErrorHandler }) + Units.init({ Context = Context, ErrorHandler = flexlove._ErrorHandler, Calc = Calc }) + Color.init({ ErrorHandler = flexlove._ErrorHandler }) + utils.init({ ErrorHandler = flexlove._ErrorHandler }) + + -- Initialize optional ImageCache module + if ModuleLoader.isModuleLoaded(modulePath .. "modules.ImageCache") then + ImageCache.init({ ErrorHandler = flexlove._ErrorHandler }) + end + + -- Initialize optional Animation module + if ModuleLoader.isModuleLoaded(modulePath .. "modules.Animation") then + Animation.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color }) + end + + -- Initialize optional Theme module + if ModuleLoader.isModuleLoaded(modulePath .. "modules.Theme") then + Theme.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color, utils = utils }) + end + + LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, utils = utils }) + EventHandler.init({ + ErrorHandler = flexlove._ErrorHandler, + Performance = flexlove._Performance, + InputEvent = InputEvent, + utils = utils, + Context = Context, + }) + + -- Initialize shared GestureRecognizer for touch routing + if GestureRecognizer then + flexlove._gestureRecognizer = GestureRecognizer.new({}, { InputEvent = InputEvent, utils = utils }) + end + + -- Initialize KeyboardNavigation and FocusIndicator if enabled + local keyboardConfig = config.keyboardNavigation + if + KeyboardNavigation + and (keyboardConfig == true or (type(keyboardConfig) == "table" and keyboardConfig.enabled ~= false)) + then + KeyboardNavigation.init({ + Context = Context, + Element = Element, + ErrorHandler = flexlove._ErrorHandler, + utils = utils, + InputEvent = InputEvent, + }) + + if FocusIndicator then + FocusIndicator.init({ Context = Context, Color = Color }) + KeyboardNavigation.FocusIndicator = FocusIndicator + -- Also set FocusIndicator reference in EventHandler for clearing on mouse click + EventHandler._FocusIndicator = FocusIndicator + -- Note: FocusIndicator is only updated from keyboard navigation (_focusElement) + -- Mouse clicks and activation clear the indicator + end + + -- Apply configuration if provided + flexlove._applyKeyboardNavConfig(keyboardConfig) + end + + flexlove._defaultDependencies = { + Context = Context, + Theme = Theme, + Color = Color, + Calc = Calc, + Units = Units, + Blur = Blur, + ImageRenderer = ImageRenderer, + ImageScaler = ImageScaler, + NinePatch = NinePatch, + RoundedRect = RoundedRect, + ImageCache = ImageCache, + utils = utils, + Grid = Grid, + InputEvent = InputEvent, + GestureRecognizer = GestureRecognizer, + StateManager = StateManager, + TextEditor = TextEditor, + LayoutEngine = LayoutEngine, + Renderer = Renderer, + EventHandler = EventHandler, + ScrollManager = ScrollManager, + ErrorHandler = flexlove._ErrorHandler, + Performance = flexlove._Performance, + Transform = Transform, + Animation = Animation, + ZIndex = ZIndex, + Select = Select, + PropertySchema = PropertySchema, + -- Behavior registry (behavior-mode-unification task 09). Two ordering + -- invariants: + -- * Update: Animated (geometry) → Scrollable (scroll interaction) → + -- Clickable (hit-testing) — animated geometry must be current for + -- hit-testing, and scrollbar press state must be set before Clickable's + -- EventHandler processes mouse events. + -- * Draw: Themed (core Renderer:draw) runs before Clickable (pressed-state + -- overlay); Scrollable (drawLayer="overlay") is dispatched AFTER children + -- for scrollbar-on-top. Imageable/Animated/Selectable/TextEditable onDraw + -- are no-ops, so their position is unconstrained for layering. + -- 7 entries. (task 09 reordered Animated+Scrollable ahead of Clickable.) + clickableBehaviors = { Themed, Clickable, Imageable }, + -- Persistable is the registry tail (task 12): its restoreState applies the + -- `_props` override AFTER every subsystem behavior has hydrated, preserving + -- the legacy restore ordering (behaviors first, `_props` last). + behaviors = { Themed, Animated, Scrollable, Clickable, Imageable, Selectable, TextEditable, Persistable }, + TextEditable = TextEditable, + } + + -- Initialize Element module with dependencies + Element.init(flexlove._defaultDependencies) + + if config.baseScale then + flexlove.baseScale = { + width = config.baseScale.width or 1920, + height = config.baseScale.height or 1080, + } + + local currentWidth, currentHeight = Units.getViewport() + flexlove.scaleFactors.x = currentWidth / flexlove.baseScale.width + flexlove.scaleFactors.y = currentHeight / flexlove.baseScale.height + end + + if config.theme and ModuleLoader.isModuleLoaded(modulePath .. "modules.Theme") then + local success, err = pcall(function() + if type(config.theme) == "string" then + Theme.load(config.theme) + Theme.setActive(config.theme) + flexlove.defaultTheme = config.theme + elseif type(config.theme) == "table" then + local theme = Theme.new(config.theme) + Theme.setActive(theme) + flexlove.defaultTheme = theme.name + end + end) + + if not success then + flexlove._ErrorHandler:warn("FlexLove", "THM_005", { + error = tostring(err), + }) + end + end + + local immediateMode = config.immediateMode or false + flexlove.setMode(immediateMode and "immediate" or "retained") + + flexlove._autoFrameManagement = config.autoFrameManagement or false + + -- Configure GC strategy + if config.gcStrategy then + flexlove._gcConfig.strategy = config.gcStrategy + end + if config.gcMemoryThreshold then + flexlove._gcConfig.memoryThreshold = config.gcMemoryThreshold + end + if config.gcInterval then + flexlove._gcConfig.interval = config.gcInterval + end + if config.gcStepSize then + flexlove._gcConfig.stepSize = config.gcStepSize + end + + if config.stateRetentionFrames or config.maxStateEntries then + StateManager.configure({ + stateRetentionFrames = config.stateRetentionFrames, + maxStateEntries = config.maxStateEntries, + }) + end + flexlove.initialized = true + flexlove._initState = "ready" + + -- Configure debug draw overlay + flexlove._debugDraw = config.debugDraw or false + flexlove._debugDrawKey = config.debugDrawKey or nil + + -- Process all queued element creations + local queue = flexlove._initQueue + flexlove._initQueue = {} -- Clear queue before processing to prevent re-entry issues + + for _, item in ipairs(queue) do + local element = Element.new(item.props) + if item.callback and type(item.callback) == "function" then + local success, err = pcall(item.callback, element) + if not success then + flexlove._ErrorHandler:warn( + "FlexLove", + string.format("Failed to execute queued element callback: %s", tostring(err)) + ) + end + end + end +end + +--- Enable keyboard navigation after initialization (for deferred or conditional setup) +--- Useful when you need to conditionally enable keyboard navigation based on runtime conditions +--- Automatically initializes KeyboardNavigation and FocusIndicator modules if not already initialized +---@param config KeyboardNavigationConfig? Optional configuration table +--- @usage +--- -- Enable with defaults +--- FlexLove.enableKeyboardNavigation() +--- +--- -- Enable with custom configuration +--- FlexLove.enableKeyboardNavigation({ +--- directionalNavigation = true, +--- wrapAround = false, +--- focusIndicator = { +--- enabled = true, +--- color = {1, 0.8, 0, 0.8}, +--- lineWidth = 3, +--- }, +--- }) +--- Enable debug mode for keyboard navigation +--- Use this to troubleshoot keyboard navigation issues +---@param enabled boolean +function flexlove.setKeyboardNavigationDebug(enabled) + if KeyboardNavigation and KeyboardNavigation.config then + KeyboardNavigation.config.debugMode = enabled + print(string.format("[FlexLove] Keyboard navigation debug mode: %s", tostring(enabled))) + end +end + +--- Apply keyboard navigation configuration (internal helper) +---@param config table +function flexlove._applyKeyboardNavConfig(config) + if type(config) ~= "table" then + return + end + + if config.enabled ~= nil then + KeyboardNavigation.config.enabled = config.enabled + end + if config.directionalNavigation ~= nil then + KeyboardNavigation.config.directionalNavigation = config.directionalNavigation + end + if config.wrapAround ~= nil then + KeyboardNavigation.config.wrapAround = config.wrapAround + end + if config.dropFocusOnSelection ~= nil then + KeyboardNavigation.config.dropFocusOnSelection = config.dropFocusOnSelection + end + + if config.focusIndicator and FocusIndicator then + local fiConfig = config.focusIndicator + if fiConfig.enabled ~= nil then + FocusIndicator.config.enabled = fiConfig.enabled + end + if fiConfig.draw ~= nil then + FocusIndicator.config.draw = fiConfig.draw + end + if fiConfig.color then + FocusIndicator.setColor( + fiConfig.color[1] or 0.2, + fiConfig.color[2] or 0.6, + fiConfig.color[3] or 1.0, + fiConfig.color[4] or 0.8 + ) + end + if fiConfig.lineWidth ~= nil then + FocusIndicator.config.lineWidth = fiConfig.lineWidth + end + if fiConfig.pulseEnabled ~= nil then + FocusIndicator.config.pulseEnabled = fiConfig.pulseEnabled + end + end +end + +--- Enable keyboard navigation after initialization (for deferred or conditional setup) +--- Useful when you need to conditionally enable keyboard navigation based on runtime conditions +--- Automatically initializes KeyboardNavigation and FocusIndicator modules if not already initialized +---@usage +--- -- Enable with defaults +--- FlexLove.enableKeyboardNavigation() +--- +--- -- Enable with custom configuration +--- FlexLove.enableKeyboardNavigation({ +--- directionalNavigation = true, +--- wrapAround = false, +--- dropFocusOnSelection = false, +--- focusIndicator = { +--- enabled = true, +--- color = {1, 0.8, 0, 0.8}, +--- lineWidth = 3, +--- draw = function(element, bounds, style) end, +--- }, +--- }) +---@param config KeyboardNavigationConfig +function flexlove.enableKeyboardNavigation(config) + if not KeyboardNavigation then + return + end + config = config or {} + + -- Check if already initialized + if KeyboardNavigation.config and KeyboardNavigation._deps then + -- Already initialized, just apply config if provided + flexlove._applyKeyboardNavConfig(config) + return + end + + -- Initialize KeyboardNavigation + KeyboardNavigation.init({ + Context = Context, + Element = Element, + ErrorHandler = flexlove._ErrorHandler, + utils = utils, + InputEvent = InputEvent, + }) + + -- Initialize FocusIndicator if available + if FocusIndicator then + FocusIndicator.init({ Context = Context, Color = Color }) + KeyboardNavigation.FocusIndicator = FocusIndicator + -- Also set FocusIndicator reference in EventHandler for clearing on mouse click + EventHandler._FocusIndicator = FocusIndicator + -- Note: FocusIndicator is only updated from keyboard navigation (_focusElement) + -- Mouse clicks and activation clear the indicator + end + + flexlove._applyKeyboardNavConfig(config) +end + +--- Safely schedule operations that modify LÖVE's rendering state (like window mode changes) to execute after all canvas operations complete +--- Prevents crashes from attempting canvas-incompatible operations during rendering +---@param callback function The callback to execute +function flexlove.deferCallback(callback) + if type(callback) ~= "function" then + flexlove._ErrorHandler:warn("FlexLove", "CORE_001") + return + end + table.insert(flexlove._deferredCallbacks, callback) +end + +--- Execute deferred operations at the safest point in the render cycle - after all canvas operations are complete +--- Call this at the end of love.draw() to enable window resizing and other state-modifying operations without crashes +--- @usage +--- function love.draw() +--- love.graphics.setCanvas(myCanvas) +--- FlexLove.draw() +--- love.graphics.setCanvas() -- Release ALL canvases +--- FlexLove.executeDeferredCallbacks() -- Now safe to execute +--- end +function flexlove.executeDeferredCallbacks() + if #flexlove._deferredCallbacks == 0 then + return + end + + -- Copy callbacks and clear queue before execution + -- This prevents infinite loops if callbacks defer more callbacks + local callbacks = flexlove._deferredCallbacks + flexlove._deferredCallbacks = {} + + for _, callback in ipairs(callbacks) do + local success, err = xpcall(callback, debug.traceback) + if not success then + flexlove._ErrorHandler:warn("FlexLove", "CORE_002", { + error = tostring(err), + }) + end + end +end + +--- Recalculate all UI layouts when the window size changes - ensures your interface adapts seamlessly to new dimensions +--- Hook this to love.resize() to maintain proper scaling and positioning across window size changes +function flexlove.resize() + local newWidth, newHeight = love.window.getMode() + + if flexlove.baseScale then + flexlove.scaleFactors.x = newWidth / flexlove.baseScale.width + flexlove.scaleFactors.y = newHeight / flexlove.baseScale.height + end + + if ModuleLoader.isModuleLoaded(modulePath .. "modules.Blur") then + Blur.clearCache() + end + + -- Release old canvases explicitly + if flexlove._gameCanvas then + flexlove._gameCanvas:release() + end + if flexlove._backdropCanvas then + flexlove._backdropCanvas:release() + end + + flexlove._gameCanvas = nil + flexlove._backdropCanvas = nil + flexlove._canvasDimensions = { width = 0, height = 0 } + + for _, win in ipairs(flexlove.topElements) do + win:resize(newWidth, newHeight) + end +end + +--- Switch between immediate mode (React-like, recreates UI each frame) and retained mode (persistent elements) to match your architectural needs +--- Use immediate for simpler state management and declarative UIs, retained for performance-critical applications with complex state +---@param mode "immediate"|"retained" +function flexlove.setMode(mode) + if mode == "immediate" then + flexlove._immediateMode = true + flexlove._immediateModeState = StateManager + flexlove._frameStarted = false + flexlove._autoBeganFrame = false + -- Notify StateManager of mode change + StateManager.setImmediateMode(true) + elseif mode == "retained" then + flexlove._immediateMode = false + flexlove._immediateModeState = nil + flexlove._frameStarted = false + flexlove._autoBeganFrame = false + flexlove._currentFrameElements = {} + flexlove._frameNumber = 0 + -- Notify StateManager of mode change + StateManager.setImmediateMode(false) + else + error("[FlexLove] Invalid mode: " .. tostring(mode) .. ". Expected 'immediate' or 'retained'") + end +end + +--- Check which rendering mode is active to conditionally handle state management logic +--- Useful for libraries and reusable components that need to adapt to different rendering strategies +---@return "immediate"|"retained" +function flexlove.getMode() + return flexlove._immediateMode and "immediate" or "retained" +end + +--- Manually start a new frame in immediate mode for precise control over the UI lifecycle +--- Only needed when you want explicit frame boundaries; otherwise FlexLove auto-manages frames +function flexlove.beginFrame() + if not flexlove._immediateMode then + return + end + + -- Reset accumulated delta time for new frame + flexlove._accumulatedDt = 0 + + -- Start performance frame timing + if flexlove._Performance then + flexlove._Performance:startFrame() + end + + -- Cleanup elements from PREVIOUS frame (after they've been drawn) + -- This breaks circular references and allows GC to collect memory + if flexlove._currentFrameElements then + local function cleanupChildren(elem) + for _, child in ipairs(elem.children) do + cleanupChildren(child) + end + elem:_cleanup() + end + + for _, element in ipairs(flexlove._currentFrameElements) do + if not element.parent then + cleanupChildren(element) + end + end + end + + flexlove._frameNumber = flexlove._frameNumber + 1 + StateManager.incrementFrame() + flexlove._currentFrameElements = {} + flexlove._frameStarted = true + flexlove.topElements = {} + + Context.clearFrameElements() +end + +--- Finalize the frame in immediate mode, triggering layout calculations and state persistence +--- Only needed when manually controlling frames with beginFrame(); otherwise handled automatically +function flexlove.endFrame() + if not flexlove._immediateMode then + return + end + + Context.sortElementsByZIndex() + + -- Layout all top-level elements now that all children have been added + for _, element in ipairs(flexlove._currentFrameElements) do + if not element.parent then + element:layoutChildren() + end + end + + flexlove._handleSelectPointerDismissal() + + -- Update all top-level elements created this frame + for _, element in ipairs(flexlove._currentFrameElements) do + if not element.parent then + element:update(flexlove._accumulatedDt) + end + end + + -- Save state for all elements created this frame + for _, element in ipairs(flexlove._currentFrameElements) do + if element.id and element.id ~= "" then + local stateUpdate = element:saveState() + local stateChanged = StateManager.updateStateIfChanged(element.id, stateUpdate) + if stateChanged and (element.backdropBlur or element.contentBlur) and Blur then + Blur.clearElementCache(element.id) + end + end + end + + StateManager.cleanup() + StateManager.forceCleanupIfNeeded() + -- Flush dirty state from this frame (no-op in retained mode) + StateManager.flushFrame() + flexlove._frameStarted = false + + -- End performance frame timing + if flexlove._Performance then + flexlove._Performance:endFrame() + flexlove._Performance:resetFrameCounters() + end +end + +---@type love.Canvas? +flexlove._gameCanvas = nil +---@type love.Canvas? +flexlove._backdropCanvas = nil +---@type {width: number, height: number} +flexlove._canvasDimensions = { width = 0, height = 0 } + +--- Recursively draw debug boundaries for an element and all its children +--- Draws regardless of visibility/opacity to reveal hidden or transparent elements +---@param element Element +local function drawDebugElement(element) + local color = element._debugColor + if color then + local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) + + -- Fill with 0.5 opacity + love.graphics.setColor(color[1], color[2], color[3], 0.5) + love.graphics.rectangle("fill", element.x, element.y, bw, bh) + + -- Border with full opacity, 1px line + love.graphics.setColor(color[1], color[2], color[3], 1) + love.graphics.setLineWidth(1) + love.graphics.rectangle("line", element.x, element.y, bw, bh) + end + + for _, child in ipairs(element.children) do + drawDebugElement(child) + end +end + +--- Render the debug draw overlay for all elements in the tree +--- Traverses every element regardless of visibility or opacity +function flexlove._renderDebugOverlay() + -- Save current graphics state + local prevR, prevG, prevB, prevA = love.graphics.getColor() + local prevLineWidth = love.graphics.getLineWidth() + + -- Clear any active scissor so debug draws are always visible + love.graphics.setScissor() + + for _, win in ipairs(flexlove.topElements) do + drawDebugElement(win) + end + + -- Restore graphics state + love.graphics.setColor(prevR, prevG, prevB, prevA) + love.graphics.setLineWidth(prevLineWidth) +end + +--- Render all UI elements with optional backdrop blur support for glassmorphic effects +--- Place your game scene in gameDrawFunc to enable backdrop blur on UI elements; use postDrawFunc for overlays +---@param gameDrawFunc function|nil pass component draws that should be affected by a backdrop blur +---@param postDrawFunc function|nil pass component draws that should NOT be affected by a backdrop blur +function flexlove.draw(gameDrawFunc, postDrawFunc) + if flexlove._immediateMode and flexlove._autoBeganFrame then + flexlove.endFrame() + flexlove._autoBeganFrame = false + end + + local outerCanvas = love.graphics.getCanvas() + local gameCanvas = nil + + if type(gameDrawFunc) == "function" then + local width, height = love.graphics.getDimensions() + + if + not flexlove._gameCanvas + or flexlove._canvasDimensions.width ~= width + or flexlove._canvasDimensions.height ~= height + then + -- Release old canvases before creating new ones + if flexlove._gameCanvas then + flexlove._gameCanvas:release() + end + if flexlove._backdropCanvas then + flexlove._backdropCanvas:release() + end + + flexlove._gameCanvas = love.graphics.newCanvas(width, height) + flexlove._backdropCanvas = love.graphics.newCanvas(width, height) + flexlove._canvasDimensions.width = width + flexlove._canvasDimensions.height = height + end + + gameCanvas = flexlove._gameCanvas + + love.graphics.setCanvas(gameCanvas) + love.graphics.clear() + gameDrawFunc() + love.graphics.setCanvas(outerCanvas) + + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(gameCanvas, 0, 0) + end + + table.sort(flexlove.topElements, function(a, b) + return a.z < b.z + end) + + local function hasBackdropBlur(element) + if element.backdropBlur and element.backdropBlur.radius > 0 then + return true + end + for _, child in ipairs(element.children) do + if hasBackdropBlur(child) then + return true + end + end + return false + end + + local needsBackdropCanvas = false + for _, win in ipairs(flexlove.topElements) do + if hasBackdropBlur(win) then + needsBackdropCanvas = true + break + end + end + + if needsBackdropCanvas and gameCanvas then + local backdropCanvas = flexlove._backdropCanvas + local prevColor = { love.graphics.getColor() } + + love.graphics.setCanvas(backdropCanvas) + love.graphics.clear() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(gameCanvas, 0, 0) + + love.graphics.setCanvas(outerCanvas) + love.graphics.setColor(unpack(prevColor)) + + for _, win in ipairs(flexlove.topElements) do + -- Check if this element tree has backdrop blur + local needsBackdrop = hasBackdropBlur(win) + + -- Draw element with backdrop blur applied if needed + if needsBackdrop then + win:draw(backdropCanvas) + else + win:draw(nil) + end + + -- IMPORTANT: Update backdrop canvas for EVERY element (respecting z-index order) + -- This ensures that lower z-index elements are visible in the backdrop blur + -- of higher z-index elements + love.graphics.setCanvas(backdropCanvas) + love.graphics.setColor(1, 1, 1, 1) + win:draw(nil) + love.graphics.setCanvas(outerCanvas) + end + else + for _, win in ipairs(flexlove.topElements) do + win:draw(nil) + end + end + + if type(postDrawFunc) == "function" then + postDrawFunc() + end + + -- Render performance HUD if enabled + if flexlove._Performance then + flexlove._Performance:renderHUD() + end + + -- Render focus indicator if keyboard navigation is enabled + if KeyboardNavigation and KeyboardNavigation.config and KeyboardNavigation.config.enabled and FocusIndicator then + FocusIndicator:draw() + end + + -- Render debug draw overlay if enabled + if flexlove._debugDraw then + flexlove._renderDebugOverlay() + end + + love.graphics.setCanvas(outerCanvas) + + -- NOTE: Deferred callbacks are NOT executed here because the calling code + -- (e.g., main.lua) might still have a canvas active. Callbacks must be + -- executed by calling FlexLove.executeDeferredCallbacks() at the very end + -- of love.draw() after ALL canvases have been released. +end + +--- Check if element is an ancestor of target +---@param element Element The potential ancestor element +---@param target Element The target element to check +---@return boolean isAncestor True if element is an ancestor of target +local function isAncestor(element, target) + local current = target.parent + while current do + if current == element then + return true + end + current = current.parent + end + return false +end + +---@param element Element +---@param results Element[] +local function collectOpenSelects(element, results) + if element._selectState and element._selectState.open then + table.insert(results, element) + end + + for _, child in ipairs(element.children) do + collectOpenSelects(child, results) + end +end + +function flexlove._handleSelectPointerDismissal() + local isLeftDown = love.mouse.isDown(1) + local wasLeftDown = flexlove._mouseButtonStates[1] or false + + if isLeftDown and not wasLeftDown then + local mx, my = love.mouse.getPosition() + local target = flexlove.getElementAtPosition(mx, my) + local openSelects = {} + + for _, element in ipairs(flexlove.topElements) do + collectOpenSelects(element, openSelects) + end + + for _, selectParent in ipairs(openSelects) do + local containsTarget = target and (target == selectParent or isAncestor(selectParent, target)) + if not containsTarget then + selectParent:closeSelect() + end + end + end + + flexlove._mouseButtonStates[1] = isLeftDown +end + +--- Determine which UI element the user is interacting with at a specific screen position +--- Essential for custom input handling, tooltips, or debugging click targets in complex layouts +---@param x number +---@param y number +---@return Element? +function flexlove.getElementAtPosition(x, y) + local candidates = {} + local blockingElements = {} + + local function collectHits(element, scrollOffsetX, scrollOffsetY) + scrollOffsetX = scrollOffsetX or 0 + scrollOffsetY = scrollOffsetY or 0 + + -- pointHitsElement is the single canonical bounds + display:none guard. + if Context.pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then + -- Skip invisible/transparent elements and their entire subtree + if element.visibility == "hidden" or element.opacity <= 0 then + return + end + + -- Collect interactive elements (those with onEvent handlers) + if + (element.onEvent or element.editable or element._selectState or element.selectOption) and not element.disabled + then + table.insert(candidates, element) + end + + -- Collect all visible elements for input blocking + -- Elements with opacity > 0 block input to elements below them + if element.opacity > 0 then + table.insert(blockingElements, element) + end + + -- Check if this element has scrollable overflow + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + local hasScrollableOverflow = ( + overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" + or overflowX == "hidden" + or overflowY == "hidden" + ) + + -- Accumulate scroll offset for children if this element has overflow clipping + local childScrollOffsetX = scrollOffsetX + local childScrollOffsetY = scrollOffsetY + if hasScrollableOverflow then + childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0) + childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0) + end + + for _, child in ipairs(element.children) do + collectHits(child, childScrollOffsetX, childScrollOffsetY) + end + end + end + + for _, element in ipairs(flexlove.topElements) do + collectHits(element) + end + + -- Sort both lists by composite z-index (highest first). Uses the same + -- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ key as + -- Context.sortElementsByZIndex / findInteractiveAtPosition so the hit-test + -- topmost matches the visual draw order across overlapping windows. + -- Skip the precompute + sort when there is 0 or 1 element (the common case + -- for getElementAtPosition which is called on every love.mousemoved event). + if #candidates > 1 then + local candidateZ = {} + for i = 1, #candidates do + candidateZ[candidates[i]] = Context.getEffectiveZIndex(candidates[i]) + end + table.sort(candidates, function(a, b) + return candidateZ[a] > candidateZ[b] + end) + end + + if #blockingElements > 1 then + local blockerZ = {} + for i = 1, #blockingElements do + blockerZ[blockingElements[i]] = Context.getEffectiveZIndex(blockingElements[i]) + end + table.sort(blockingElements, function(a, b) + return blockerZ[a] > blockerZ[b] + end) + end + + -- If we have interactive elements, return the topmost one + -- But only if there's no blocking element with higher z-index (that isn't an ancestor) + if #candidates > 0 then + local topCandidate = candidates[1] + + -- Check if any blocking element would prevent this interaction + if #blockingElements > 0 then + local topBlocker = blockingElements[1] + -- If the top blocker has higher z-index than the top candidate, + -- and the blocker is NOT an ancestor of the candidate, + -- return the blocker (even though it has no onEvent, it blocks input) + if topBlocker.z > topCandidate.z and not isAncestor(topBlocker, topCandidate) then + return topBlocker + end + end + + return topCandidate + end + + -- No interactive elements, but return topmost blocking element if any + -- This prevents clicks from passing through non-interactive overlays + return blockingElements[1] +end + +--- Update all UI animations, interactions, and state changes each frame +--- Hook this to love.update() to enable hover effects, animations, text cursors, and scrolling +---@param dt number +function flexlove.update(dt) + -- Update Performance module with actual delta time for accurate FPS + if flexlove._Performance then + flexlove._Performance:updateDeltaTime(dt) + end + + -- Update keyboard navigation (animations, etc.) + if KeyboardNavigation then + KeyboardNavigation:update(dt) + end + + -- Garbage collection management + flexlove._manageGC() + + -- Invalidate the per-frame findInteractiveAtPosition cache so Clickable's + -- per-element occlusion lookup (one per interactive element per frame) is + -- recomputed fresh for this frame's tree. Within the frame every + -- Clickable.onUpdate then shares one cached result instead of re-walking + -- + re-sorting the tree per element (unified-event-routing task 05 fix). + flexlove.clearInteractiveCache() + + -- Select-pointer dismissal: if the left mouse button was just pressed, + -- check whether any open Select dropdowns should be closed (click-outside). + -- This calls getElementAtPosition ONLY on the click frame, not every frame. + flexlove._handleSelectPointerDismissal() + + -- In immediate mode, accumulate dt and skip updating here - elements will be updated in endFrame after layout + if flexlove._immediateMode then + flexlove._accumulatedDt = flexlove._accumulatedDt + dt + else + for _, win in ipairs(flexlove.topElements) do + win:update(dt) + end + end + + -- Note: State saving happens in endFrame() after element:update() is called + -- This ensures all state changes (including cursor blink) are captured once per frame +end + +--- Internal GC management function (called from update) +function flexlove._manageGC() + local strategy = flexlove._gcConfig.strategy + + if strategy == "disabled" then + return + end + + local currentMemory = collectgarbage("count") / 1024 -- Convert to MB + flexlove._gcState.lastMemory = currentMemory + flexlove._gcState.framesSinceLastGC = flexlove._gcState.framesSinceLastGC + 1 + + -- Check memory threshold (applies to all strategies except disabled) + if currentMemory > flexlove._gcConfig.memoryThreshold then + -- Force full GC when exceeding threshold + collectgarbage("collect") + flexlove._gcState.gcCount = flexlove._gcState.gcCount + 1 + flexlove._gcState.framesSinceLastGC = 0 + return + end + + -- Strategy-specific GC + if strategy == "periodic" then + -- Run incremental GC step every N frames + if flexlove._gcState.framesSinceLastGC >= flexlove._gcConfig.interval then + collectgarbage("step", flexlove._gcConfig.stepSize) + flexlove._gcState.gcCount = flexlove._gcState.gcCount + 1 + flexlove._gcState.framesSinceLastGC = 0 + end + elseif strategy == "auto" then + -- Let Lua's automatic GC handle it, but help with incremental steps + -- Run a small step every frame to keep memory under control + if flexlove._gcState.framesSinceLastGC >= 5 then + collectgarbage("step", 50) -- Small steps to avoid frame drops + flexlove._gcState.framesSinceLastGC = 0 + end + end + -- "manual" strategy: no automatic GC, user must call flexlove.collectGarbage() +end + +--- Manually trigger garbage collection to prevent frame drops during critical gameplay moments +--- Use this to control when memory cleanup happens rather than letting it occur unpredictably +---@param mode? string "collect" for full GC, "step" for incremental (default: "collect") +---@param stepSize? number Work units for step mode (default: 200) +function flexlove.collectGarbage(mode, stepSize) + mode = mode or "collect" + stepSize = stepSize or 200 + + if mode == "collect" then + collectgarbage("collect") + flexlove._gcState.gcCount = flexlove._gcState.gcCount + 1 + flexlove._gcState.framesSinceLastGC = 0 + elseif mode == "step" then + collectgarbage("step", stepSize) + elseif mode == "count" then + return collectgarbage("count") / 1024 -- Return memory in MB + end +end + +--- Choose how FlexLove manages memory cleanup to balance performance and memory usage for your app's needs +--- Use "manual" for tight control in performance-critical sections, "auto" for hands-off operation +---@param strategy string "auto", "periodic", "manual", or "disabled" +function flexlove.setGCStrategy(strategy) + if strategy == "auto" or strategy == "periodic" or strategy == "manual" or strategy == "disabled" then + flexlove._gcConfig.strategy = strategy + else + flexlove._ErrorHandler:warn("FlexLove", "CORE_003", { + strategy = tostring(strategy), + }) + end +end + +--- Monitor memory management behavior to diagnose performance issues and tune GC settings +--- Use this to identify memory leaks or optimize garbage collection timing +---@return GCStats stats GC statistics +function flexlove.getGCStats() + return { + gcCount = flexlove._gcState.gcCount, + framesSinceLastGC = flexlove._gcState.framesSinceLastGC, + currentMemoryMB = flexlove._gcState.lastMemory, + strategy = flexlove._gcConfig.strategy, + threshold = flexlove._gcConfig.memoryThreshold, + } +end + +--- Forward text input to focused editable elements like text fields and text areas +--- Hook this to love.textinput() to enable text entry in your UI +---@param text string +function flexlove.textinput(text) + local focusedElement = Context.getFocused() + if focusedElement and not focusedElement.disabled then + focusedElement:textinput(text) + end +end + +--- Handle keyboard input for text editing, navigation, and performance overlay toggling +--- Hook this to love.keypressed() to enable text selection, cursor movement, and the performance HUD +---@param key string +---@param scancode string +---@param isrepeat boolean +function flexlove.keypressed(key, scancode, isrepeat) + if flexlove._Performance then + flexlove._Performance:keypressed(key) + end + if flexlove._debugDrawKey and key == flexlove._debugDrawKey then + flexlove._debugDraw = not flexlove._debugDraw + end + + -- Handle keyboard navigation (if module is available and enabled) + if KeyboardNavigation and KeyboardNavigation.config and KeyboardNavigation.config.enabled then + -- Debug logging for keyboard navigation entry point + if KeyboardNavigation.config.debugMode then + print(string.format("[FlexLove.keypressed] Keyboard nav enabled, handling key: %s", key)) + end + + -- Check if we're in text input mode (editable element focused) + local focusedElement = Context.getFocused() + local isTextInputMode = focusedElement and (focusedElement.editable or focusedElement._textEditor) + + -- Only handle navigation if not in text input mode, or if in text input mode without modifiers + local shouldHandleNav = not isTextInputMode + or ( + isTextInputMode + and not ( + love.keyboard.isDown("lctrl") + or love.keyboard.isDown("rctrl") + or love.keyboard.isDown("lalt") + or love.keyboard.isDown("ralt") + ) + ) + + if shouldHandleNav then + local handled = KeyboardNavigation:handleKeyPress(key, scancode, isrepeat) + if KeyboardNavigation.config.debugMode and not handled then + print(string.format("[FlexLove.keypressed] Key %s was NOT handled by keyboard navigation", key)) + end + if handled then + return -- Navigation handled the key, don't forward to element + end + end + end + + -- Forward to focused element for text input + local focusedElement = Context.getFocused() + if focusedElement and not focusedElement.disabled then + focusedElement:keypressed(key, scancode, isrepeat) + end +end + +--- Enable mouse wheel scrolling in scrollable containers and lists +--- Hook this to love.wheelmoved() to allow users to scroll through content naturally +---@param dx number +---@param dy number +function flexlove.wheelmoved(dx, dy) + local mx, my = love.mouse.getPosition() + local element = Context.findScrollableAtPosition(mx, my) + + if element then + element:_handleWheelScroll(dx, dy) + + -- In immediate mode, persist scroll manager state for next frame + if flexlove._immediateMode and element._stateId and element._scrollManager then + local scrollManagerState = element._scrollManager:getState() + StateManager.updateState(element._stateId, { + scrollManager = scrollManagerState, + }) + end + end +end + +--- Find the touch-interactive element at a given position using z-index ordering +--- Similar to getElementAtPosition but checks for touch-enabled elements +---@param x number Touch X position +---@param y number Touch Y position +---@return Element|nil element The topmost touch-enabled element at position +function flexlove._getTouchElementAtPosition(x, y) + local candidates = {} + + local function collectTouchHits(element, scrollOffsetX, scrollOffsetY) + scrollOffsetX = scrollOffsetX or 0 + scrollOffsetY = scrollOffsetY or 0 + + -- pointHitsElement is the single canonical bounds + display:none guard. + if Context.pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then + -- Check if element is touch-enabled and interactive + if + element.touchEnabled + and not element.disabled + and (element.onEvent or element.onTouchEvent or element.onGesture) + then + table.insert(candidates, element) + end + + -- Check if this element has scrollable overflow (for touch scrolling) + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + local hasScrollableOverflow = ( + overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" + or overflowX == "hidden" + or overflowY == "hidden" + ) + + -- Accumulate scroll offset for children + local childScrollOffsetX = scrollOffsetX + local childScrollOffsetY = scrollOffsetY + if hasScrollableOverflow then + childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0) + childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0) + end + + for _, child in ipairs(element.children) do + collectTouchHits(child, childScrollOffsetX, childScrollOffsetY) + end + end + end + + for _, element in ipairs(flexlove.topElements) do + collectTouchHits(element) + end + + -- Sort by z-index (highest first) — topmost element wins + table.sort(candidates, function(a, b) + return a.z > b.z + end) + + return candidates[1] +end + +--- Handle touch press events from LÖVE's touch input system +--- Routes touch to the topmost element at the touch position and assigns touch ownership +--- Hook this to love.touchpressed() to enable touch interaction +---@param id lightuserdata Touch identifier from LÖVE +---@param x number Touch X position in screen coordinates +---@param y number Touch Y position in screen coordinates +---@param dx number X distance moved (usually 0 on press) +---@param dy number Y distance moved (usually 0 on press) +---@param pressure number Touch pressure (0-1, if supported by device) +function flexlove.touchpressed(id, x, y, dx, dy, pressure) + local touchId = tostring(id) + pressure = pressure or 1.0 + + -- Apply base scaling if configured + local touchX, touchY = x, y + if flexlove.baseScale then + touchX = x / flexlove.scaleFactors.x + touchY = y / flexlove.scaleFactors.y + end + + -- Find the topmost touch-enabled element at this position + local element = flexlove._getTouchElementAtPosition(touchX, touchY) + + if element then + -- Assign touch ownership: this element receives all subsequent events for this touch + flexlove._touchOwners[touchId] = element + + -- Create and route touch event + local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "began", pressure) + element:handleTouchEvent(touchEvent) + + -- Feed to shared gesture recognizer + if flexlove._gestureRecognizer then + local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent) + if gestures then + for _, gesture in ipairs(gestures) do + element:handleGesture(gesture) + end + end + end + + -- Route to scroll manager for scrollable elements + if element._scrollManager then + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then + element._scrollManager:handleTouchPress(touchX, touchY) + end + end + end +end + +--- Handle touch move events from LÖVE's touch input system +--- Routes touch to the element that owns this touch ID (from the original press), regardless of current position +--- Hook this to love.touchmoved() to enable touch drag and gesture tracking +---@param id lightuserdata Touch identifier from LÖVE +---@param x number Touch X position in screen coordinates +---@param y number Touch Y position in screen coordinates +---@param dx number X distance moved since last event +---@param dy number Y distance moved since last event +---@param pressure number Touch pressure (0-1, if supported by device) +function flexlove.touchmoved(id, x, y, dx, dy, pressure) + local touchId = tostring(id) + pressure = pressure or 1.0 + + -- Apply base scaling if configured + local touchX, touchY = x, y + if flexlove.baseScale then + touchX = x / flexlove.scaleFactors.x + touchY = y / flexlove.scaleFactors.y + end + + -- Route to owning element (touch ownership persists from press to release) + local element = flexlove._touchOwners[touchId] + if element then + -- Create and route touch event + local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "moved", pressure) + element:handleTouchEvent(touchEvent) + + -- Feed to shared gesture recognizer + if flexlove._gestureRecognizer then + local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent) + if gestures then + for _, gesture in ipairs(gestures) do + element:handleGesture(gesture) + end + end + end + + -- Route to scroll manager for scrollable elements + if element._scrollManager then + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then + element._scrollManager:handleTouchMove(touchX, touchY) + end + end + end +end + +--- Handle touch release events from LÖVE's touch input system +--- Routes touch to the owning element and cleans up touch ownership tracking +--- Hook this to love.touchreleased() to properly end touch interactions +---@param id lightuserdata Touch identifier from LÖVE +---@param x number Touch X position in screen coordinates +---@param y number Touch Y position in screen coordinates +---@param dx number X distance moved since last event +---@param dy number Y distance moved since last event +---@param pressure number Touch pressure (0-1, if supported by device) +function flexlove.touchreleased(id, x, y, dx, dy, pressure) + local touchId = tostring(id) + pressure = pressure or 1.0 + + -- Apply base scaling if configured + local touchX, touchY = x, y + if flexlove.baseScale then + touchX = x / flexlove.scaleFactors.x + touchY = y / flexlove.scaleFactors.y + end + + -- Route to owning element + local element = flexlove._touchOwners[touchId] + if element then + -- Create and route touch event + local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "ended", pressure) + element:handleTouchEvent(touchEvent) + + -- Feed to shared gesture recognizer + if flexlove._gestureRecognizer then + local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent) + if gestures then + for _, gesture in ipairs(gestures) do + element:handleGesture(gesture) + end + end + end + + -- Route to scroll manager for scrollable elements + if element._scrollManager then + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then + element._scrollManager:handleTouchRelease() + end + end + end + + -- Clean up touch ownership (touch is complete) + flexlove._touchOwners[touchId] = nil +end + +--- Get the number of currently active touches being tracked +---@return number count Number of active touch points +function flexlove.getActiveTouchCount() + local count = 0 + for _ in pairs(flexlove._touchOwners) do + count = count + 1 + end + return count +end + +--- Get the element that currently owns a specific touch +---@param touchId string|lightuserdata Touch identifier +---@return Element|nil element The element owning this touch, or nil +function flexlove.getTouchOwner(touchId) + return flexlove._touchOwners[tostring(touchId)] +end + +--- Retrieve an element by its ID from the UI tree +--- Works in both immediate and retained modes; searches all known elements including top-level and nested children +---@param id string The element ID to search for +---@return Element|nil element The found element, or nil if not found +function flexlove.getById(id) + if not id or id == "" then + return nil + end + + local function findElementById(element, targetId) + if element.id == targetId then + return element + end + + for _, child in ipairs(element.children) do + local result = findElementById(child, targetId) + if result then + return result + end + end + + return nil + end + + for _, win in ipairs(flexlove.topElements) do + local result = findElementById(win, id) + if result then + return result + end + end + + if flexlove._currentFrameElements then + for _, element in ipairs(flexlove._currentFrameElements) do + local result = findElementById(element, id) + if result then + return result + end + end + end + + if Context._zIndexOrderedElements then + for _, element in ipairs(Context._zIndexOrderedElements) do + local result = findElementById(element, id) + if result then + return result + end + end + end + + return nil +end + +--- Clean up all UI elements and reset FlexLove to initial state when changing scenes or shutting down +--- Use this to prevent memory leaks when transitioning between game states or menus +function flexlove.destroy() + for _, win in ipairs(flexlove.topElements) do + win:destroy() + end + flexlove.topElements = {} + flexlove.baseScale = nil + flexlove.scaleFactors = { x = 1.0, y = 1.0 } + flexlove._cachedViewport = { width = 0, height = 0 } + + -- Release canvases explicitly before destroying + if flexlove._gameCanvas then + flexlove._gameCanvas:release() + end + if flexlove._backdropCanvas then + flexlove._backdropCanvas:release() + end + + flexlove._gameCanvas = nil + flexlove._backdropCanvas = nil + flexlove._canvasDimensions = { width = 0, height = 0 } + Context.clearFocus() + StateManager:reset() + + -- Clean up touch state + flexlove._touchOwners = {} + flexlove._mouseButtonStates = {} + if flexlove._gestureRecognizer then + flexlove._gestureRecognizer:reset() + end +end + +--- Create a new UI element with flexbox layout, styling, and interaction capabilities +--- This is your primary API for building interfaces - buttons, panels, text, images, and containers +--- If called before FlexLove.init(), the element creation will be automatically queued and executed after initialization +---@param props ElementProps +---@param callback? function Optional callback function(element) that will be called with the created element (useful when queued) +---@return Element -- Returns element if initialized, nil if queued for later creation +function flexlove.new(props, callback) + props = props or {} + + if not flexlove.initialized then + -- Queue element creation for after initialization + table.insert(flexlove._initQueue, { + props = props, + callback = callback, + }) + + if flexlove._initState == "uninitialized" then + if flexlove._ErrorHandler then + flexlove._ErrorHandler:warn( + "FlexLove", + "[FlexLove] Element creation queued - FlexLove.init() has not been called yet. Element will be created automatically after init() is called." + ) + end + end + return nil + end + + -- Use global mode to determine behavior + if not flexlove._immediateMode then + return Element.new(props) + end + + -- Immediate mode - proceed with immediate-mode logic + -- Auto-begin frame if not manually started (convenience feature) + if not flexlove._frameStarted then + flexlove.beginFrame() + flexlove._autoBeganFrame = true + end + + -- Immediate mode: generate ID if not provided + if not props.id then + props.id = StateManager.generateID(props, props.parent) + end + + -- Get or create state for this element + local state = StateManager.getState(props.id, {}) + + -- Mark state as used this frame + StateManager.markStateUsed(props.id) + + -- Inject scroll state into props BEFORE creating element + -- This ensures scroll position is set before layoutChildren/detectOverflow is called + -- ScrollManager state uses _scrollX/_scrollY with underscore prefix + if state.scrollManager then + props._scrollX = state.scrollManager._scrollX or 0 + props._scrollY = state.scrollManager._scrollY or 0 + else + -- Fallback to old state structure for backward compatibility + props._scrollX = state._scrollX or 0 + props._scrollY = state._scrollY or 0 + end + + local element = Element.new(props) + + -- Restore all state from StateManager (delegates to sub-modules) + element:restoreState(state) + + -- Bind element to StateManager for interactive states + element._stateId = props.id + + -- Set initial theme state based on StateManager state + -- This will be updated in Element:update() but we need an initial value + if element.themeComponent then + local eventState = state.eventHandler or {} + if element.disabled or eventState.disabled then + element._themeState = "disabled" + elseif element.active or eventState.active then + element._themeState = "active" + elseif eventState._pressed and next(eventState._pressed) then + element._themeState = "pressed" + elseif eventState._hovered then + element._themeState = "hover" + else + element._themeState = "normal" + end + end + + table.insert(flexlove._currentFrameElements, element) + + return element +end + +--- Check how many UI element states are being tracked in immediate mode to detect memory leaks +--- Use this during development to ensure states are properly cleaned up +---@return number +function flexlove.getStateCount() + if not flexlove._immediateMode then + return 0 + end + return StateManager.getStateCount() +end + +--- Remove stored state for a specific element when you know it won't be rendered again +--- Use this to immediately free memory for elements you've removed from your UI +---@param id string +function flexlove.clearState(id) + if not flexlove._immediateMode then + return + end + StateManager.clearState(id) +end + +--- Wipe all element state when transitioning between completely different UI screens +--- Use this for scene transitions to start with a clean slate and prevent state pollution +function flexlove.clearAllStates() + if not flexlove._immediateMode then + return + end + StateManager.clearAllStates() +end + +--- Inspect state management metrics to diagnose performance issues and optimize immediate mode usage +--- Use this to understand state lifecycle and identify unexpected state accumulation +---@return { stateCount: number, frameNumber: number, oldestState: number|nil, newestState: number|nil } +function flexlove.getStateStats() + if not flexlove._immediateMode then + return { stateCount = 0, frameNumber = 0 } + end + return StateManager.getStats() +end + +--- Create a calc() expression for dynamic CSS-like calculations +--- Use this to create responsive layouts that adapt to viewport and parent dimensions +--- @usage +--- local button = FlexLove.new({ +--- x = FlexLove.calc("50% - 10vw"), +--- y = FlexLove.calc("50% - 5vh"), +--- width = "20vw", +--- height = "10vh", +--- }) +---@param expr string The calc expression (e.g., "50% - 10vw", "100px + 20%") +---@return CalcObject calcObject A calc expression object that will be evaluated during layout +function flexlove.calc(expr) + return Calc.new(expr) +end + +--- Get the currently focused element +--- Returns the element that is currently receiving keyboard input (e.g., text input, text area) +---@return Element|nil The focused element, or nil if no element has focus +function flexlove.getFocusedElement() + return Context.getFocused() +end + +--- Set focus to a specific element +--- Automatically blurs the previously focused element if different +--- Use this to programmatically focus text inputs or other interactive elements +---@param element Element|nil The element to focus (nil to clear focus) +function flexlove.setFocusedElement(element) + Context.setFocused(element) +end + +--- Clear focus from any element +--- Removes keyboard focus from the currently focused element +function flexlove.clearFocus() + Context.setFocused(nil) +end + +--- Enable or disable the debug draw overlay that renders element boundaries with random colors +--- Each element gets a unique color: full opacity border and 0.5 opacity fill to identify collisions and overlaps +---@param enabled boolean True to enable debug draw overlay, false to disable +function flexlove.setDebugDraw(enabled) + flexlove._debugDraw = enabled +end + +--- Check if the debug draw overlay is currently active +---@return boolean enabled True if debug draw overlay is enabled +function flexlove.getDebugDraw() + return flexlove._debugDraw +end + +flexlove.Animation = Animation +flexlove.Color = Color +flexlove.Theme = Theme +flexlove.enums = enums + +return flexlove diff --git a/libs/flexlove/LICENSE b/libs/flexlove/LICENSE new file mode 100644 index 00000000..0b2a1f3e --- /dev/null +++ b/libs/flexlove/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Mike Freno + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/flexlove/modules/Animation.lua b/libs/flexlove/modules/Animation.lua new file mode 100644 index 00000000..66a3d5d1 --- /dev/null +++ b/libs/flexlove/modules/Animation.lua @@ -0,0 +1,1579 @@ +local Easing = {} + +---@type EasingFunction +function Easing.linear(t) + return t +end + +---@type EasingFunction +function Easing.easeInQuad(t) + return t * t +end + +---@type EasingFunction +function Easing.easeOutQuad(t) + return t * (2 - t) +end + +---@type EasingFunction +function Easing.easeInOutQuad(t) + return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t +end + +---@type EasingFunction +function Easing.easeInCubic(t) + return t * t * t +end + +---@type EasingFunction +function Easing.easeOutCubic(t) + local t1 = t - 1 + return t1 * t1 * t1 + 1 +end + +---@type EasingFunction +function Easing.easeInOutCubic(t) + return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 +end + +---@type EasingFunction +function Easing.easeInQuart(t) + return t * t * t * t +end + +---@type EasingFunction +function Easing.easeOutQuart(t) + local t1 = t - 1 + return 1 - t1 * t1 * t1 * t1 +end + +---@type EasingFunction +function Easing.easeInOutQuart(t) + if t < 0.5 then + return 8 * t * t * t * t + else + local t1 = t - 1 + return 1 - 8 * t1 * t1 * t1 * t1 + end +end + +---@type EasingFunction +function Easing.easeInQuint(t) + return t * t * t * t * t +end + +---@type EasingFunction +function Easing.easeOutQuint(t) + local t1 = t - 1 + return 1 + t1 * t1 * t1 * t1 * t1 +end + +---@type EasingFunction +function Easing.easeInOutQuint(t) + if t < 0.5 then + return 16 * t * t * t * t * t + else + local t1 = t - 1 + return 1 + 16 * t1 * t1 * t1 * t1 * t1 + end +end + +---@type EasingFunction +function Easing.easeInExpo(t) + return t == 0 and 0 or math.pow(2, 10 * (t - 1)) +end + +---@type EasingFunction +function Easing.easeOutExpo(t) + return t == 1 and 1 or 1 - math.pow(2, -10 * t) +end + +---@type EasingFunction +function Easing.easeInOutExpo(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + if t < 0.5 then + return 0.5 * math.pow(2, 20 * t - 10) + else + return 1 - 0.5 * math.pow(2, -20 * t + 10) + end +end + +---@type EasingFunction +function Easing.easeInSine(t) + return 1 - math.cos(t * math.pi / 2) +end + +---@type EasingFunction +function Easing.easeOutSine(t) + return math.sin(t * math.pi / 2) +end + +---@type EasingFunction +function Easing.easeInOutSine(t) + return -(math.cos(math.pi * t) - 1) / 2 +end + +---@type EasingFunction +function Easing.easeInCirc(t) + return 1 - math.sqrt(1 - t * t) +end + +---@type EasingFunction +function Easing.easeOutCirc(t) + local t1 = t - 1 + return math.sqrt(1 - t1 * t1) +end + +---@type EasingFunction +function Easing.easeInOutCirc(t) + if t < 0.5 then + return (1 - math.sqrt(1 - 4 * t * t)) / 2 + else + local t1 = -2 * t + 2 + return (math.sqrt(1 - t1 * t1) + 1) / 2 + end +end + +---@type EasingFunction +function Easing.easeInBack(t) + local c1 = 1.70158 + local c3 = c1 + 1 + return c3 * t * t * t - c1 * t * t +end + +---@type EasingFunction +function Easing.easeOutBack(t) + local c1 = 1.70158 + local c3 = c1 + 1 + local t1 = t - 1 + return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1 +end + +---@type EasingFunction +function Easing.easeInOutBack(t) + local c1 = 1.70158 + local c2 = c1 * 1.525 + + if t < 0.5 then + return (2 * t * 2 * t * ((c2 + 1) * 2 * t - c2)) / 2 + else + local t1 = 2 * t - 2 + return (t1 * t1 * ((c2 + 1) * t1 + c2) + 2) / 2 + end +end + +---@type EasingFunction +function Easing.easeInElastic(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + local c4 = (2 * math.pi) / 3 + return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c4) +end + +---@type EasingFunction +function Easing.easeOutElastic(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + local c4 = (2 * math.pi) / 3 + return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1 +end + +---@type EasingFunction +function Easing.easeInOutElastic(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + local c5 = (2 * math.pi) / 4.5 + + if t < 0.5 then + return -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * c5)) / 2 + else + return (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * c5)) / 2 + 1 + end +end + +---@type EasingFunction +function Easing.easeOutBounce(t) + local n1 = 7.5625 + local d1 = 2.75 + + if t < 1 / d1 then + return n1 * t * t + elseif t < 2 / d1 then + local t1 = t - 1.5 / d1 + return n1 * t1 * t1 + 0.75 + elseif t < 2.5 / d1 then + local t1 = t - 2.25 / d1 + return n1 * t1 * t1 + 0.9375 + else + local t1 = t - 2.625 / d1 + return n1 * t1 * t1 + 0.984375 + end +end + +---@type EasingFunction +function Easing.easeInBounce(t) + return 1 - Easing.easeOutBounce(1 - t) +end + +---@type EasingFunction +function Easing.easeInOutBounce(t) + if t < 0.5 then + return (1 - Easing.easeOutBounce(1 - 2 * t)) / 2 + else + return (1 + Easing.easeOutBounce(2 * t - 1)) / 2 + end +end + +--- Create a custom back easing function with configurable overshoot +---@param overshoot number? Overshoot amount (default: 1.70158) +---@return EasingFunction +function Easing.back(overshoot) + overshoot = overshoot or 1.70158 + local c3 = overshoot + 1 + + return function(t) + return c3 * t * t * t - overshoot * t * t + end +end + +--- Create a custom elastic easing function +---@param amplitude number? Amplitude (default: 1) +---@param period number? Period (default: 0.3) +---@return EasingFunction +function Easing.elastic(amplitude, period) + amplitude = amplitude or 1 + period = period or 0.3 + + return function(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + local s = period / 4 + local a = amplitude + + if a < 1 then + a = 1 + s = period / 4 + else + s = period / (2 * math.pi) * math.asin(1 / a) + end + + return a * math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / period) + 1 + end +end + +-- ============================================================================ +-- TRANSFORM +-- ============================================================================ + +local Transform = {} +Transform.__index = Transform + +--- Create a new transform instance +---@param props Transform? +---@return Transform transform +function Transform.new(props) + props = props or {} + + local self = setmetatable({}, Transform) + + self.rotate = props.rotate or 0 + self.scaleX = props.scaleX or 1 + self.scaleY = props.scaleY or 1 + self.translateX = props.translateX or 0 + self.translateY = props.translateY or 0 + self.skewX = props.skewX or 0 + self.skewY = props.skewY or 0 + self.originX = props.originX or 0.5 + self.originY = props.originY or 0.5 + + return self +end + +--- Apply transform to LÖVE graphics context +---@param transform Transform Transform instance +---@param x number Element x position +---@param y number Element y position +---@param width number Element width +---@param height number Element height +function Transform.apply(transform, x, y, width, height) + if not transform then + return + end + + local ox = x + width * transform.originX + local oy = y + height * transform.originY + + love.graphics.push() + love.graphics.translate(ox, oy) + + if transform.rotate ~= 0 then + love.graphics.rotate(transform.rotate) + end + + if transform.scaleX ~= 1 or transform.scaleY ~= 1 then + love.graphics.scale(transform.scaleX, transform.scaleY) + end + + if transform.skewX ~= 0 or transform.skewY ~= 0 then + love.graphics.shear(transform.skewX, transform.skewY) + end + + love.graphics.translate(-ox, -oy) + love.graphics.translate(transform.translateX, transform.translateY) +end + +--- Remove transform from LÖVE graphics context +function Transform.unapply() + love.graphics.pop() +end + +--- Interpolate between two transforms +---@param from Transform Starting transform +---@param to Transform Ending transform +---@param t number Interpolation factor (0-1) +---@return Transform interpolated +function Transform.lerp(from, to, t) + if type(from) ~= "table" then + from = Transform.new() + end + if type(to) ~= "table" then + to = Transform.new() + end + if type(t) ~= "number" or t ~= t then + t = 0 + elseif t == math.huge then + t = 1 + elseif t == -math.huge then + t = 0 + else + t = math.max(0, math.min(1, t)) + end + + return Transform.new({ + rotate = (from.rotate or 0) * (1 - t) + (to.rotate or 0) * t, + scaleX = (from.scaleX or 1) * (1 - t) + (to.scaleX or 1) * t, + scaleY = (from.scaleY or 1) * (1 - t) + (to.scaleY or 1) * t, + translateX = (from.translateX or 0) * (1 - t) + (to.translateX or 0) * t, + translateY = (from.translateY or 0) * (1 - t) + (to.translateY or 0) * t, + skewX = (from.skewX or 0) * (1 - t) + (to.skewX or 0) * t, + skewY = (from.skewY or 0) * (1 - t) + (to.skewY or 0) * t, + originX = (from.originX or 0.5) * (1 - t) + (to.originX or 0.5) * t, + originY = (from.originY or 0.5) * (1 - t) + (to.originY or 0.5) * t, + }) +end + +--- Check if transform is identity (no transformation) +---@param transform Transform +---@return boolean isIdentity +function Transform.isIdentity(transform) + if not transform then + return true + end + + return transform.rotate == 0 + and transform.scaleX == 1 + and transform.scaleY == 1 + and transform.translateX == 0 + and transform.translateY == 0 + and transform.skewX == 0 + and transform.skewY == 0 +end + +--- Clone a transform +---@param transform Transform +---@return Transform clone +function Transform.clone(transform) + if not transform then + return Transform.new() + end + + return Transform.new({ + rotate = transform.rotate, + scaleX = transform.scaleX, + scaleY = transform.scaleY, + translateX = transform.translateX, + translateY = transform.translateY, + skewX = transform.skewX, + skewY = transform.skewY, + originX = transform.originX, + originY = transform.originY, + }) +end + +-- ============================================================================ +-- INTERPOLATION HELPERS +-- ============================================================================ + +--- Helper function to interpolate numeric values +---@param startValue number Starting value +---@param finalValue number Final value +---@param easedT number Eased time (0-1) +---@return number interpolated Interpolated value +local function lerpNumber(startValue, finalValue, easedT) + return startValue * (1 - easedT) + finalValue * easedT +end + +--- Helper function to interpolate Color values +---@param startColor any Starting color (Color instance or parseable color) +---@param finalColor any Final color (Color instance or parseable color) +---@param easedT number Eased time (0-1) +---@param ColorModule table Color module reference +---@return any interpolated Interpolated Color instance +local function lerpColor(startColor, finalColor, easedT, ColorModule) + if not ColorModule or not ColorModule.parse or not ColorModule.lerp then + return startColor + end + + local colorA = ColorModule.parse(startColor) + local colorB = ColorModule.parse(finalColor) + + return ColorModule.lerp(colorA, colorB, easedT) +end + +--- Helper function to interpolate table values (padding, margin, cornerRadius) +---@param startTable table Starting table +---@param finalTable table Final table +---@param easedT number Eased time (0-1) +---@return table interpolated Interpolated table +local function lerpTable(startTable, finalTable, easedT) + local result = {} + + local keys = {} + for k in pairs(startTable) do + keys[k] = true + end + for k in pairs(finalTable) do + keys[k] = true + end + + for key in pairs(keys) do + local startVal = startTable[key] + local finalVal = finalTable[key] + + if type(startVal) == "number" and type(finalVal) == "number" then + result[key] = lerpNumber(startVal, finalVal, easedT) + elseif startVal ~= nil then + result[key] = startVal + else + result[key] = finalVal + end + end + + return result +end + +---@class Animation +local Animation = { + _Transform = Transform, +} +Animation.__index = Animation + +--- Build smooth, timed transitions between visual states +---@param props AnimationProps Animation properties +---@return Animation animation The new animation instance +function Animation.new(props) + if type(props) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_001") + end + props = { duration = 1, start = {}, final = {} } + end + + if type(props.duration) ~= "number" or props.duration <= 0 then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_002") + end + props.duration = 1 + end + + if type(props.start) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_001") + end + props.start = {} + end + + if type(props.final) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_001") + end + props.final = {} + end + + local self = setmetatable({}, Animation) + self.duration = props.duration + self.start = props.start + self.final = props.final + self.keyframes = props.keyframes + self.transform = props.transform + self.transition = props.transition + self.elapsed = 0 + + self.onStart = props.onStart + self.onUpdate = props.onUpdate + self.onComplete = props.onComplete + self.onCancel = props.onCancel + self._hasStarted = false + + self._paused = false + self._reversed = false + self._speed = 1.0 + self._state = "pending" + + local easingName = props.easing or "linear" + if type(easingName) == "string" then + self.easing = Easing[easingName] or Easing.linear + elseif type(easingName) == "function" then + self.easing = easingName + else + self.easing = Easing.linear + end + + self._cachedResult = {} + self._resultDirty = true + + return self +end + +--- Advance the animation timeline +---@param dt number Delta time in seconds +---@param element table? Optional element reference for callbacks +---@return boolean completed True if animation is complete +function Animation:update(dt, element) + if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then + dt = 0 + end + + if self._paused then + return false + end + + if self._delay and self._delayElapsed then + if self._delayElapsed < self._delay then + self._delayElapsed = self._delayElapsed + dt + return false + end + end + + if not self._hasStarted then + self._hasStarted = true + self._state = "playing" + if self.onStart and type(self.onStart) == "function" then + local success, err = pcall(self.onStart, self, element) + if not success then + Animation._ErrorHandler:warn("Animation", "EVT_002", { + callback = "onStart", + error = tostring(err), + }) + end + end + end + + dt = dt * self._speed + + if self._reversed then + self.elapsed = self.elapsed - dt + if self.elapsed <= 0 then + self.elapsed = 0 + self._state = "completed" + self._resultDirty = true + if self.onComplete and type(self.onComplete) == "function" then + local success, err = pcall(self.onComplete, self, element) + if not success then + Animation._ErrorHandler:warn("Animation", "EVT_002", { + callback = "onComplete", + error = tostring(err), + }) + end + end + return true + end + else + self.elapsed = self.elapsed + dt + if self.elapsed >= self.duration then + self.elapsed = self.duration + self._resultDirty = true + + if self._repeatCount then + self._repeatCurrent = (self._repeatCurrent or 0) + 1 + + if self._repeatCount == 0 or self._repeatCurrent < self._repeatCount then + if self._yoyo then + self._reversed = not self._reversed + if self._reversed then + self.elapsed = self.duration + else + self.elapsed = 0 + end + else + self.elapsed = 0 + end + return false + end + end + + self._state = "completed" + if self.onComplete and type(self.onComplete) == "function" then + local success, err = pcall(self.onComplete, self, element) + if not success then + Animation._ErrorHandler:warn("Animation", "EVT_002", { + callback = "onComplete", + error = tostring(err), + }) + end + end + return true + end + end + + self._resultDirty = true + + if self.onUpdate and type(self.onUpdate) == "function" then + local progress = self.elapsed / self.duration + local success, err = pcall(self.onUpdate, self, element, progress) + if not success then + Animation._ErrorHandler:warn("Animation", "EVT_002", { + callback = "onUpdate", + error = tostring(err), + }) + end + end + + return false +end + +--- Find the two keyframes surrounding the current progress +---@param progress number Current animation progress (0-1) +---@return Keyframe? prevFrame The keyframe before current progress +---@return Keyframe? nextFrame The keyframe after current progress +function Animation:findKeyframes(progress) + if not self.keyframes or #self.keyframes < 2 then + return nil, nil + end + + local prevFrame = self.keyframes[1] + local nextFrame = self.keyframes[#self.keyframes] + + for i = 1, #self.keyframes - 1 do + if progress >= self.keyframes[i].at and progress <= self.keyframes[i + 1].at then + prevFrame = self.keyframes[i] + nextFrame = self.keyframes[i + 1] + break + end + end + + return prevFrame, nextFrame +end + +--- Interpolate between two keyframes +---@param prevFrame Keyframe Starting keyframe +---@param nextFrame Keyframe Ending keyframe +---@param easedT number Eased time (0-1) for interpolation +---@return table result Interpolated values +function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) + local result = {} + + local keys = {} + for k in pairs(prevFrame.values) do + keys[k] = true + end + for k in pairs(nextFrame.values) do + keys[k] = true + end + + local numericSet = { + width = true, + height = true, + opacity = true, + x = true, + y = true, + gap = true, + imageOpacity = true, + scrollbarWidth = true, + borderWidth = true, + fontSize = true, + lineHeight = true, + } + + local colorSet = { + backgroundColor = true, + borderColor = true, + textColor = true, + scrollbarColor = true, + scrollbarBackgroundColor = true, + imageTint = true, + } + + local tableSet = { + padding = true, + margin = true, + cornerRadius = true, + } + + for key in pairs(keys) do + local startVal = prevFrame.values[key] + local finalVal = nextFrame.values[key] + + if numericSet[key] and type(startVal) == "number" and type(finalVal) == "number" then + result[key] = lerpNumber(startVal, finalVal, easedT) + elseif colorSet[key] and Animation._ColorModule then + if startVal ~= nil and finalVal ~= nil then + result[key] = lerpColor(startVal, finalVal, easedT, Animation._ColorModule) + end + elseif tableSet[key] and type(startVal) == "table" and type(finalVal) == "table" then + result[key] = lerpTable(startVal, finalVal, easedT) + elseif type(startVal) == type(finalVal) then + if type(startVal) == "number" then + result[key] = lerpNumber(startVal, finalVal, easedT) + else + result[key] = finalVal + end + end + end + + return result +end + +--- Calculate the current animated values +---@return table result Interpolated values +function Animation:interpolate() + if not self._resultDirty then + return self._cachedResult + end + + local t = math.min(self.elapsed / self.duration, 1) + + if self.keyframes and type(self.keyframes) == "table" and #self.keyframes >= 2 then + local prevFrame, nextFrame = self:findKeyframes(t) + + if prevFrame and nextFrame then + local localProgress = 0 + if nextFrame.at > prevFrame.at then + localProgress = (t - prevFrame.at) / (nextFrame.at - prevFrame.at) + end + + local easingFn = Easing.linear + if prevFrame.easing then + if type(prevFrame.easing) == "string" then + easingFn = Easing[prevFrame.easing] or Easing.linear + elseif type(prevFrame.easing) == "function" then + easingFn = prevFrame.easing + end + end + + local success, easedT = pcall(easingFn, localProgress) + if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then + easedT = localProgress + end + + local keyframeResult = self:lerpKeyframes(prevFrame, nextFrame, easedT) + + local result = self._cachedResult + for k in pairs(result) do + result[k] = nil + end + for k, v in pairs(keyframeResult) do + result[k] = v + end + + self._resultDirty = false + return result + end + end + + local success, easedT = pcall(self.easing, t) + if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then + easedT = t + end + + local result = self._cachedResult + + for k in pairs(result) do + result[k] = nil + end + + local numericProperties = { + "width", + "height", + "opacity", + "x", + "y", + "gap", + "imageOpacity", + "scrollbarWidth", + "borderWidth", + "fontSize", + "lineHeight", + } + + local colorProperties = { + "backgroundColor", + "borderColor", + "textColor", + "scrollbarColor", + "scrollbarBackgroundColor", + "imageTint", + } + + local tableProperties = { + "padding", + "margin", + "cornerRadius", + } + + for _, prop in ipairs(numericProperties) do + local startVal = self.start[prop] + local finalVal = self.final[prop] + + if type(startVal) == "number" and type(finalVal) == "number" then + result[prop] = lerpNumber(startVal, finalVal, easedT) + end + end + + if Animation._ColorModule then + for _, prop in ipairs(colorProperties) do + local startVal = self.start[prop] + local finalVal = self.final[prop] + + if startVal ~= nil and finalVal ~= nil then + result[prop] = lerpColor(startVal, finalVal, easedT, Animation._ColorModule) + end + end + end + + for _, prop in ipairs(tableProperties) do + local startVal = self.start[prop] + local finalVal = self.final[prop] + + if type(startVal) == "table" and type(finalVal) == "table" then + result[prop] = lerpTable(startVal, finalVal, easedT) + end + end + + if Animation._Transform and self.start.transform and self.final.transform then + result.transform = Animation._Transform.lerp(self.start.transform, self.final.transform, easedT) + end + + if self.transform and type(self.transform) == "table" then + for key, value in pairs(self.transform) do + result[key] = value + end + end + + self._resultDirty = false + return result +end + +--- Attach animation to an element +---@param element table The element to apply animation to +function Animation:apply(element) + if not element or type(element) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_003") + end + return + end + element.animation = self +end + +--- Apply interpolated values to an element during update. +--- Called each frame while the animation is active (not yet finished). +---@param element table Element to apply interpolated properties to +function Animation:applyInterpolation(element) + local anim = self:interpolate() + + -- Numeric properties + element.width = anim.width or element.width + element.height = anim.height or element.height + element.opacity = anim.opacity or element.opacity + element.x = anim.x or element.x + element.y = anim.y or element.y + element.gap = anim.gap or element.gap + element.imageOpacity = anim.imageOpacity or element.imageOpacity + element.scrollbarWidth = anim.scrollbarWidth or element.scrollbarWidth + element.borderWidth = anim.borderWidth or element.borderWidth + element.fontSize = anim.fontSize or element.fontSize + element.lineHeight = anim.lineHeight or element.lineHeight + + -- Color properties + if anim.backgroundColor then + element.backgroundColor = anim.backgroundColor + end + if anim.borderColor then + element.borderColor = anim.borderColor + end + if anim.textColor then + element.textColor = anim.textColor + end + if anim.scrollbarColor then + element.scrollbarColor = anim.scrollbarColor + end + if anim.scrollbarBackgroundColor then + element.scrollbarBackgroundColor = anim.scrollbarBackgroundColor + end + if anim.imageTint then + element.imageTint = anim.imageTint + end + + -- Table properties + if anim.padding then + element.padding = anim.padding + end + if anim.margin then + element.margin = anim.margin + end + if anim.cornerRadius then + element.cornerRadius = anim.cornerRadius + end + if anim.transform then + element.transform = anim.transform + end + + -- Backward compatibility: opacity-only animation updates background alpha + if anim.opacity and not anim.backgroundColor then + element.backgroundColor.a = anim.opacity + end +end + +--- Pause animation +function Animation:pause() + if self._state == "playing" or self._state == "pending" then + self._paused = true + self._state = "paused" + end +end + +--- Resume animation +function Animation:resume() + if self._state == "paused" then + self._paused = false + self._state = "playing" + end +end + +--- Check if paused +---@return boolean paused +function Animation:isPaused() + return self._paused +end + +--- Reverse animation direction +function Animation:reverse() + self._reversed = not self._reversed +end + +--- Check if reversed +---@return boolean reversed +function Animation:isReversed() + return self._reversed +end + +--- Set playback speed +---@param speed number Speed multiplier +function Animation:setSpeed(speed) + if type(speed) == "number" and speed > 0 then + self._speed = speed + end +end + +--- Get playback speed +---@return number speed +function Animation:getSpeed() + return self._speed +end + +--- Seek to specific time +---@param time number Time in seconds +function Animation:seek(time) + if type(time) == "number" then + self.elapsed = math.max(0, math.min(time, self.duration)) + self._resultDirty = true + end +end + +--- Get animation state +---@return string state +function Animation:getState() + return self._state +end + +--- Cancel animation +---@param element table? Optional element reference +function Animation:cancel(element) + if self._state ~= "cancelled" and self._state ~= "completed" then + self._state = "cancelled" + if self.onCancel and type(self.onCancel) == "function" then + local success, err = pcall(self.onCancel, self, element) + if not success then + Animation._ErrorHandler:warn("Animation", "EVT_002", { + callback = "onCancel", + error = tostring(err), + }) + end + end + end +end + +--- Reset animation +function Animation:reset() + self.elapsed = 0 + self._hasStarted = false + self._paused = false + self._state = "pending" + self._resultDirty = true +end + +--- Get animation progress +---@return number progress +function Animation:getProgress() + return math.min(self.elapsed / self.duration, 1) +end + +--- Chain animations +---@param nextAnimation Animation|function +---@return Animation nextAnimation +function Animation:chain(nextAnimation) + if type(nextAnimation) == "function" then + self._nextFactory = nextAnimation + return self + elseif type(nextAnimation) == "table" then + self._next = nextAnimation + return nextAnimation + else + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_004") + end + return self + end +end + +--- Add delay before animation starts +---@param seconds number Delay duration +---@return Animation self +function Animation:delay(seconds) + if type(seconds) ~= "number" or seconds < 0 then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_005") + end + seconds = 0 + end + self._delay = seconds + self._delayElapsed = 0 + return self +end + +--- Set repeat count +---@param count number Repeat count (0 = infinite) +---@return Animation self +function Animation:repeatCount(count) + if type(count) ~= "number" or count < 0 then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_006") + end + count = 0 + end + self._repeatCount = count + self._repeatCurrent = 0 + return self +end + +--- Enable yoyo mode +---@param enabled boolean? Enable yoyo (default: true) +---@return Animation self +function Animation:yoyo(enabled) + if enabled == nil then + enabled = true + end + self._yoyo = enabled + return self +end + +--- Create fade animation +---@param duration number Duration in seconds +---@param fromOpacity number Starting opacity +---@param toOpacity number Ending opacity +---@param easing string? Easing function name +---@return Animation animation +function Animation.fade(duration, fromOpacity, toOpacity, easing) + if type(duration) ~= "number" or duration <= 0 then + duration = 1 + end + if type(fromOpacity) ~= "number" then + fromOpacity = 1 + end + if type(toOpacity) ~= "number" then + toOpacity = 0 + end + + return Animation.new({ + duration = duration, + start = { opacity = fromOpacity }, + final = { opacity = toOpacity }, + easing = easing, + }) +end + +--- Create scale animation +---@param duration number Duration in seconds +---@param fromScale {width:number,height:number} Starting scale +---@param toScale {width:number,height:number} Ending scale +---@param easing string? Easing function name +---@return Animation animation +function Animation.scale(duration, fromScale, toScale, easing) + if type(duration) ~= "number" or duration <= 0 then + duration = 1 + end + if type(fromScale) ~= "table" then + fromScale = { width = 1, height = 1 } + end + if type(toScale) ~= "table" then + toScale = { width = 1, height = 1 } + end + + return Animation.new({ + duration = duration, + start = { width = fromScale.width or 0, height = fromScale.height or 0 }, + final = { width = toScale.width or 0, height = toScale.height or 0 }, + easing = easing, + }) +end + +--- Create keyframe animation +---@param props {duration:number, keyframes:Keyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?} +---@return Animation animation +function Animation.keyframes(props) + if type(props) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_007") + end + props = { duration = 1, keyframes = {} } + end + + if type(props.duration) ~= "number" or props.duration <= 0 then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_002") + end + props.duration = 1 + end + + if type(props.keyframes) ~= "table" or #props.keyframes < 2 then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_008") + end + props.keyframes = { + { at = 0, values = {} }, + { at = 1, values = {} }, + } + end + + local sortedKeyframes = {} + for i, kf in ipairs(props.keyframes) do + if type(kf) == "table" and type(kf.at) == "number" and type(kf.values) == "table" then + table.insert(sortedKeyframes, kf) + end + end + + table.sort(sortedKeyframes, function(a, b) + return a.at < b.at + end) + + if #sortedKeyframes > 0 then + if sortedKeyframes[1].at > 0 then + table.insert(sortedKeyframes, 1, { at = 0, values = sortedKeyframes[1].values }) + end + if sortedKeyframes[#sortedKeyframes].at < 1 then + table.insert(sortedKeyframes, { at = 1, values = sortedKeyframes[#sortedKeyframes].values }) + end + end + + return Animation.new({ + duration = props.duration, + start = {}, + final = {}, + keyframes = sortedKeyframes, + onStart = props.onStart, + onUpdate = props.onUpdate, + onComplete = props.onComplete, + onCancel = props.onCancel, + }) +end + +--- Link an array of animations into a chain (static helper) +--- Each animation's completion triggers the next in sequence +---@param animations Animation[] Array of animations to chain +---@return Animation first The first animation in the chain +function Animation.chainSequence(animations) + if type(animations) ~= "table" or #animations == 0 then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "ANIM_004") + end + return Animation.new({ duration = 0, start = {}, final = {} }) + end + + for i = 1, #animations - 1 do + animations[i]:chain(animations[i + 1]) + end + + return animations[1] +end + +-- ============================================================================ +-- ANIMATION GROUP (Utility) +-- ============================================================================ + +local AnimationGroup = {} +AnimationGroup.__index = AnimationGroup + +--- Coordinate multiple animations +---@param props AnimationGroupProps +---@return AnimationGroup group +function AnimationGroup.new(props) + if type(props) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("AnimationGroup", "ANIM_009") + end + props = { animations = {} } + end + + if type(props.animations) ~= "table" or #props.animations == 0 then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("AnimationGroup", "ANIM_010") + end + props.animations = {} + end + + local self = setmetatable({}, AnimationGroup) + + self.animations = props.animations + self.mode = props.mode or "parallel" + self.stagger = props.stagger or 0.1 + self.onComplete = props.onComplete + self.onStart = props.onStart + + if self.mode ~= "parallel" and self.mode ~= "sequence" and self.mode ~= "stagger" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("AnimationGroup", "ANIM_011", { + mode = tostring(self.mode), + }) + end + self.mode = "parallel" + end + + self._currentIndex = 1 + self._staggerElapsed = 0 + self._startedAnimations = {} + self._hasStarted = false + self._paused = false + self._state = "ready" + + return self +end + +--- Update all animations in parallel +---@param dt number Delta time +---@param element table? Optional element reference +---@return boolean finished +function AnimationGroup:_updateParallel(dt, element) + local allFinished = true + + for i, anim in ipairs(self.animations) do + local isCompleted = false + if type(anim.getState) == "function" then + isCompleted = anim:getState() == "completed" + elseif anim._state then + isCompleted = anim._state == "completed" + end + + if not isCompleted then + local finished = anim:update(dt, element) + if not finished then + allFinished = false + end + end + end + + return allFinished +end + +--- Update animations in sequence +---@param dt number Delta time +---@param element table? Optional element reference +---@return boolean finished +function AnimationGroup:_updateSequence(dt, element) + if self._currentIndex > #self.animations then + return true + end + + local currentAnim = self.animations[self._currentIndex] + local finished = currentAnim:update(dt, element) + + if finished then + self._currentIndex = self._currentIndex + 1 + if self._currentIndex > #self.animations then + return true + end + end + + return false +end + +--- Update animations with stagger +---@param dt number Delta time +---@param element table? Optional element reference +---@return boolean finished +function AnimationGroup:_updateStagger(dt, element) + self._staggerElapsed = self._staggerElapsed + dt + + for i, anim in ipairs(self.animations) do + local startTime = (i - 1) * self.stagger + + if self._staggerElapsed >= startTime and not self._startedAnimations[i] then + self._startedAnimations[i] = true + end + end + + local allFinished = true + for i, anim in ipairs(self.animations) do + if self._startedAnimations[i] then + local isCompleted = false + if type(anim.getState) == "function" then + isCompleted = anim:getState() == "completed" + elseif anim._state then + isCompleted = anim._state == "completed" + end + + if not isCompleted then + local finished = anim:update(dt, element) + if not finished then + allFinished = false + end + end + else + allFinished = false + end + end + + return allFinished +end + +--- Advance all animations in the group +---@param dt number Delta time +---@param element table? Optional element reference +---@return boolean finished +function AnimationGroup:update(dt, element) + if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then + dt = 0 + end + + if self._paused or self._state == "completed" or self._state == "cancelled" then + return self._state == "completed" + end + + if not self._hasStarted then + self._hasStarted = true + self._state = "playing" + if self.onStart and type(self.onStart) == "function" then + local success, err = pcall(self.onStart, self) + if not success then + Animation._ErrorHandler:warn("Animation", "EVT_002", { + callback = "onStart", + error = tostring(err), + }) + end + end + end + + local finished = false + + if self.mode == "parallel" then + finished = self:_updateParallel(dt, element) + elseif self.mode == "sequence" then + finished = self:_updateSequence(dt, element) + elseif self.mode == "stagger" then + finished = self:_updateStagger(dt, element) + end + + if finished then + self._state = "completed" + if self.onComplete and type(self.onComplete) == "function" then + local success, err = pcall(self.onComplete, self) + if not success then + Animation._ErrorHandler:warn("Animation", "EVT_002", { + callback = "onComplete", + error = tostring(err), + }) + end + end + end + + return finished +end + +--- Pause all animations +function AnimationGroup:pause() + self._paused = true + for _, anim in ipairs(self.animations) do + if type(anim.pause) == "function" then + anim:pause() + end + end +end + +--- Resume all animations +function AnimationGroup:resume() + self._paused = false + for _, anim in ipairs(self.animations) do + if type(anim.resume) == "function" then + anim:resume() + end + end +end + +--- Check if paused +---@return boolean paused +function AnimationGroup:isPaused() + return self._paused +end + +--- Reverse all animations +function AnimationGroup:reverse() + for _, anim in ipairs(self.animations) do + if type(anim.reverse) == "function" then + anim:reverse() + end + end +end + +--- Set speed for all animations +---@param speed number Speed multiplier +function AnimationGroup:setSpeed(speed) + for _, anim in ipairs(self.animations) do + if type(anim.setSpeed) == "function" then + anim:setSpeed(speed) + end + end +end + +--- Cancel all animations +---@param element table? Optional element reference +function AnimationGroup:cancel(element) + if self._state ~= "cancelled" and self._state ~= "completed" then + self._state = "cancelled" + for _, anim in ipairs(self.animations) do + if type(anim.cancel) == "function" then + anim:cancel(element) + end + end + end +end + +--- Reset all animations +function AnimationGroup:reset() + self._currentIndex = 1 + self._staggerElapsed = 0 + self._startedAnimations = {} + self._hasStarted = false + self._paused = false + self._state = "ready" + + for _, anim in ipairs(self.animations) do + if type(anim.reset) == "function" then + anim:reset() + end + end +end + +--- Get group state +---@return string state +function AnimationGroup:getState() + return self._state +end + +--- Get group progress +---@return number progress +function AnimationGroup:getProgress() + if #self.animations == 0 then + return 1 + end + + if self.mode == "sequence" then + local completedAnims = self._currentIndex - 1 + local currentProgress = 0 + + if self._currentIndex <= #self.animations then + local currentAnim = self.animations[self._currentIndex] + if type(currentAnim.getProgress) == "function" then + currentProgress = currentAnim:getProgress() + end + end + + return (completedAnims + currentProgress) / #self.animations + else + local totalProgress = 0 + for _, anim in ipairs(self.animations) do + if type(anim.getProgress) == "function" then + totalProgress = totalProgress + anim:getProgress() + else + totalProgress = totalProgress + 1 + end + end + return totalProgress / #self.animations + end +end + +--- Apply group to element +---@param element table The element to apply animations to +function AnimationGroup:apply(element) + if not element or type(element) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("AnimationGroup", "ANIM_003") + end + return + end + element.animationGroup = self +end + +-- ============================================================================ +-- MODULE INITIALIZATION +-- ============================================================================ + +--- Initialize Animation module with dependencies +---@param deps table Dependencies: { ErrorHandler = ErrorHandler, Color = Color? } +function Animation.init(deps) + if type(deps) == "table" then + Animation._ErrorHandler = deps.ErrorHandler + Animation._ColorModule = deps.Color + end +end + +Animation.Easing = Easing +Animation.Transform = Transform +Animation.Group = AnimationGroup + +return Animation diff --git a/libs/flexlove/modules/Behavior.lua b/libs/flexlove/modules/Behavior.lua new file mode 100644 index 00000000..c4c11b99 --- /dev/null +++ b/libs/flexlove/modules/Behavior.lua @@ -0,0 +1,188 @@ +-- modules/Behavior.lua +-- +-- Base module for the pluggable behavior system that drives the Behavior & +-- Mode Unification refactor. +-- +-- A *behavior* is a small, stateless table produced by `Behavior.new(spec)` +-- that implements a fixed lifecycle hook set. Concrete behaviors (Clickable, +-- Scrollable, TextEditable, Selectable, ...) each live in their own module and +-- are attached to an Element. The Element's `update`/`draw`/save-restore paths +-- iterate `element.behaviors` and dispatch to the appropriate hooks, replacing +-- the swarm of `if self.scrollable` / immediate-mode-branch checks previously +-- hard-coded in Element.lua. +-- +-- Element.new iterates a registry of behavior prototypes and auto-attaches +-- whichever return true from `shouldAttach(props)`. Element therefore never +-- needs to know what an individual behavior does — only that it conforms to +-- this interface. +-- +-- Design constraints (locked — tasks 02-13 depend on this API): +-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler. +-- Stays fully stub-testable standalone (see testing/__tests__/behavior_test.lua). +-- * Minimal interface — exactly 6 lifecycle hooks + a `shouldAttach` predicate. +-- Do NOT add hooks "just in case"; new capabilities become new behaviors, +-- not new hooks. Extending HOOK_NAMES is an architectural decision that must +-- be mirrored by every concrete behavior. +-- * Immutable instances — behavior tables are produced once and treated as +-- read-only. Per-element runtime state lives on the element (or a subsystem +-- the behavior attaches), NEVER on the behavior instance itself, so a single +-- behavior instance can be shared across many elements. +-- +-- Lifecycle hook contract (each receives the owning element as first argument): +-- onAttach(element) — called once when the behavior is attached +-- (element fully constructed). Allocate +-- subsystems / register listeners here. +-- onDetach(element) — called once when the behavior is detached +-- (element destroyed / mode switch). Tear +-- down anything onAttach created. +-- onUpdate(element, dt) — called every frame from Element:update. +-- onDraw(element, ctx) — called every frame from Element:draw; `ctx` +-- is the draw context (viewport transform, +-- scissor state, theme renderer, ...). +-- saveState(element) -> state — called during Element save-state; returns +-- a serializable snapshot (or nil) so the +-- behavior's runtime state survives the +-- immediate-mode recreation cycle. +-- restoreState(element, state) — called after reconstruction with the +-- snapshot previously returned by saveState. +-- +-- shouldAttach(props) -> boolean — class-level predicate (not a hook): given +-- an element's props table, return true if +-- this behavior should be auto-attached. +-- Defaults to false (opt-in). + +--- A behavior instance: a frozen table of lifecycle hooks + a shouldAttach +--- predicate. All hooks are always present (custom override or no-op default). +---@class Behavior +---@field onAttach fun(element:table) +---@field onDetach fun(element:table) +---@field onUpdate fun(element:table, dt:number) +---@field onDraw fun(element:table, ctx:table) +---@field saveState fun(element:table):any +---@field restoreState fun(element:table, state:any) +---@field shouldAttach fun(props:table):boolean + +local Behavior = {} + +-- The fixed, ordered lifecycle hook set. Order is preserved so downstream tasks +-- (Element behavior iteration) can rely on a deterministic dispatch sequence. +-- HOOK_NAMES is intentionally NOT extended casually — see file header. +Behavior.HOOK_NAMES = { + "onAttach", + "onDetach", + "onUpdate", + "onDraw", + "saveState", + "restoreState", +} + +-- Allowlist of spec keys accepted by Behavior.new. Anything else is rejected so +-- a typo (e.g. `onUpdat`) surfaces immediately instead of silently no-op'ing. +-- Hook keys (HOOK_NAMES + shouldAttach) MUST be functions; metadata keys +-- (drawLayer) may hold any value. +local ALLOWED_KEYS = { + onAttach = true, + onDetach = true, + onUpdate = true, + onDraw = true, + saveState = true, + restoreState = true, + shouldAttach = true, + drawLayer = true, +} + +-- Spec keys whose values are NOT required to be functions (passive metadata +-- consumed by dispatch sites, e.g. Element:draw's pre/post-children split). +local NON_FUNCTION_KEYS = { + drawLayer = true, +} + +-- Default no-op hook. Behaviors override only the hooks they need; every other +-- hook resolves to this so dispatch sites never have to nil-check. +local function noop() end + +-- Default shouldAttach predicate: never auto-attach unless the behavior opts in +-- by providing its own predicate. This is the safe default — a behavior with no +-- opinion about which elements it applies to stays inert in the auto-attach +-- pass (it can still be attached explicitly by name in a later task). +local function defaultShouldAttach() + return false +end + +-- Module-level default predicate exposed for callers/tests that want to +-- reference the base default directly without constructing an instance. +Behavior.shouldAttach = defaultShouldAttach + +--- Factory: create a frozen behavior instance from a spec table. +--- +--- `spec` is a table whose keys may be any subset of the 6 lifecycle hook names +--- plus `shouldAttach`; each value (when present) must be a function. The +--- returned table contains every lifecycle hook (custom override OR no-op) and +--- a `shouldAttach` predicate (custom OR always-false default), so dispatch +--- sites can call any hook unconditionally without nil-checking. +--- +--- Unknown spec keys and non-function values raise an error immediately so +--- mistakes fail fast at construction rather than as silent no-ops later. +--- +---@param spec table|nil spec table overriding select hooks / shouldAttach +---@return Behavior +function Behavior.new(spec) + spec = spec or {} + + -- Validate spec keys up front so typos surface here, not as silent no-ops. + for key, value in pairs(spec) do + if not ALLOWED_KEYS[key] then + error(string.format("Behavior.new: unknown spec key '%s'", tostring(key)), 2) + end + if not NON_FUNCTION_KEYS[key] and type(value) ~= "function" then + error(string.format("Behavior.new: spec key '%s' must be a function, got %s", tostring(key), type(value)), 2) + end + end + + local instance = {} + + -- Populate every lifecycle hook: custom override when provided, no-op default + -- otherwise. Guarantees `instance.hook` is always callable. + for _, hook in ipairs(Behavior.HOOK_NAMES) do + instance[hook] = spec[hook] or noop + end + + -- shouldAttach defaults to always-false; behaviors opt in by supplying one. + instance.shouldAttach = spec.shouldAttach or defaultShouldAttach + + -- drawLayer: optional metadata field (default nil = "background"/pre-children). + -- Dispatch sites (Element:draw) use it to split rendering into pre-children + -- (background layers) and post-children (overlay layers, e.g. scrollbars). + instance.drawLayer = spec.drawLayer + + -- Freeze: prevent adding new fields. Behavior instances are shared, stateless + -- objects; runtime state belongs on the element, never on the behavior. + -- (Reassigning an existing hook is still possible via direct index write — + -- Lua metatables cannot intercept that — but the freeze communicates intent + -- and catches accidental field additions.) + local mt = { + __newindex = function(_, key) + error(string.format("Behavior: behavior instances are immutable (cannot set '%s')", tostring(key)), 2) + end, + --- Mark the metatable so consumers can detect a Behavior instance. + ---@return string + __tostring = function() + return "Behavior" + end, + __metatable = "Behavior", + } + setmetatable(instance, mt) + + return instance +end + +--- Type guard: returns true if `value` is a Behavior instance produced by +--- `Behavior.new`. Used by Element's attach path to validate registry entries +--- without depending on identity. +---@param value any +---@return boolean +function Behavior.isBehavior(value) + return type(value) == "table" and getmetatable(value) == "Behavior" +end + +return Behavior diff --git a/libs/flexlove/modules/Blur.lua b/libs/flexlove/modules/Blur.lua new file mode 100644 index 00000000..413592a9 --- /dev/null +++ b/libs/flexlove/modules/Blur.lua @@ -0,0 +1,686 @@ +-- Lua 5.2+ compatibility for unpack +local unpack = table.unpack or unpack + +-- Warning cache to prevent duplicate warnings for the same element +local warningCache = {} + +local Cache = { + canvases = {}, + quads = {}, + blurInstances = {}, -- Cache blur instances by quality + blurredCanvases = {}, -- Cache pre-blurred canvases for immediate mode + MAX_CANVAS_SIZE = 20, + MAX_QUAD_SIZE = 20, + MAX_BLURRED_CANVAS_CACHE = 50, -- Maximum cached blurred canvases + RADIUS_THRESHOLD = 0.5, -- Skip blur below this radius + LARGE_BLUR_THRESHOLD = 250 * 250, -- Warn if blur area exceeds this (250x250px) +} + +--- Round canvas size to nearest bucket for better reuse +---@param size number Size to bucket +---@return number bucketSize Bucketed size +local function bucketSize(size) + if size <= 128 then + return math.ceil(size / 32) * 32 + elseif size <= 512 then + return math.ceil(size / 64) * 64 + elseif size <= 1024 then + return math.ceil(size / 128) * 128 + else + return math.ceil(size / 256) * 256 + end +end + +--- Get or create a canvas from cache +---@param width number Canvas width +---@param height number Canvas height +---@return love.Canvas canvas The cached or new canvas +function Cache.getCanvas(width, height) + -- Use bucketed sizes for better cache reuse + local bucketedWidth = bucketSize(width) + local bucketedHeight = bucketSize(height) + local key = string.format("%dx%d", bucketedWidth, bucketedHeight) + + if not Cache.canvases[key] then + Cache.canvases[key] = {} + end + + local cache = Cache.canvases[key] + + for i, entry in ipairs(cache) do + if not entry.inUse then + entry.inUse = true + return entry.canvas + end + end + + local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight) + table.insert(cache, { canvas = canvas, inUse = true }) + + if #cache > Cache.MAX_CANVAS_SIZE then + local removed = table.remove(cache, 1) + if removed and removed.canvas then + removed.canvas:release() + end + end + + return canvas +end + +--- Release a canvas back to the cache +---@param canvas love.Canvas Canvas to release +function Cache.releaseCanvas(canvas) + for _, sizeCache in pairs(Cache.canvases) do + for _, entry in ipairs(sizeCache) do + if entry.canvas == canvas then + entry.inUse = false + return + end + end + end +end + +--- Get or create a quad from cache +---@param x number X position +---@param y number Y position +---@param width number Quad width +---@param height number Quad height +---@param sw number Source width +---@param sh number Source height +---@return love.Quad quad The cached or new quad +function Cache.getQuad(x, y, width, height, sw, sh) + local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh) + + if not Cache.quads[key] then + Cache.quads[key] = {} + end + + local cache = Cache.quads[key] + + for i, entry in ipairs(cache) do + if not entry.inUse then + entry.inUse = true + return entry.quad + end + end + + local quad = love.graphics.newQuad(x, y, width, height, sw, sh) + table.insert(cache, { quad = quad, inUse = true }) + + if #cache > Cache.MAX_QUAD_SIZE then + table.remove(cache, 1) + end + + return quad +end + +--- Release a quad back to the cache +---@param quad love.Quad Quad to release +function Cache.releaseQuad(quad) + for _, keyCache in pairs(Cache.quads) do + for _, entry in ipairs(keyCache) do + if entry.quad == quad then + entry.inUse = false + return + end + end + end +end + +--- Generate cache key for blurred canvas +---@param elementId string Element ID +---@param x number X position +---@param y number Y position +---@param width number Width +---@param height number Height +---@param radius number Blur radius +---@param quality number Blur quality +---@param isBackdrop boolean Whether this is backdrop blur +---@return string key Cache key +function Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, quality, isBackdrop) + return string.format( + "%s:%d:%d:%d:%d:%.1f:%d:%s", + elementId, + x, + y, + width, + height, + radius, + quality, + tostring(isBackdrop) + ) +end + +--- Get cached blurred canvas +---@param key string Cache key +---@return love.Canvas|nil canvas Cached canvas or nil +function Cache.getBlurredCanvas(key) + local entry = Cache.blurredCanvases[key] + if entry then + entry.lastUsed = os.time() + return entry.canvas + end + return nil +end + +--- Store blurred canvas in cache +---@param key string Cache key +---@param canvas love.Canvas Canvas to cache +function Cache.setBlurredCanvas(key, canvas) + -- Limit cache size + local count = 0 + for _ in pairs(Cache.blurredCanvases) do + count = count + 1 + end + + if count >= Cache.MAX_BLURRED_CANVAS_CACHE then + -- Remove oldest entry + local oldestKey = nil + local oldestTime = math.huge + for k, v in pairs(Cache.blurredCanvases) do + if v.lastUsed < oldestTime then + oldestTime = v.lastUsed + oldestKey = k + end + end + + if oldestKey then + if Cache.blurredCanvases[oldestKey].canvas then + Cache.blurredCanvases[oldestKey].canvas:release() + end + Cache.blurredCanvases[oldestKey] = nil + end + end + + Cache.blurredCanvases[key] = { + canvas = canvas, + lastUsed = os.time(), + } +end + +--- Clear blurred canvas cache for specific element +---@param elementId string Element ID to clear cache for +function Cache.clearBlurredCanvasesForElement(elementId) + for key, entry in pairs(Cache.blurredCanvases) do + if key:match("^" .. elementId .. ":") then + if entry.canvas then + entry.canvas:release() + end + Cache.blurredCanvases[key] = nil + end + end +end + +--- Clear all caches +function Cache.clear() + -- Release all blurred canvases + for _, entry in pairs(Cache.blurredCanvases) do + if entry.canvas then + entry.canvas:release() + end + end + + Cache.canvases = {} + Cache.quads = {} + Cache.blurInstances = {} + Cache.blurredCanvases = {} + warningCache = {} -- Clear warning cache on cache clear +end + +-- ============================================================================ +-- SHADER BUILDER +-- ============================================================================ + +local ShaderBuilder = {} + +--- Build Gaussian blur shader with given parameters +---@param taps number Number of samples (must be odd, >= 3) +---@param offset number Offset value +---@param offsetType string "weighted" or "center" +---@param sigma number Sigma value for Gaussian distribution +---@return love.Shader shader The compiled blur shader +function ShaderBuilder.build(taps, offset, offsetType, sigma) + taps = math.floor(taps) + sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6 + sigma = math.max(sigma, 1) + + local steps = (taps + 1) / 2 + + local gOffsets = {} + local gWeights = {} + for i = 1, steps do + gOffsets[i] = offset * (i - 1) + gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2) + end + + local offsets = {} + local weights = {} + for i = #gWeights, 2, -2 do + local oA, oB = gOffsets[i], gOffsets[i - 1] + local wA, wB = gWeights[i], gWeights[i - 1] + wB = oB == 0 and wB / 2 or wB + local weight = wA + wB + offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight + weights[#weights + 1] = weight + end + + local code = { + [[ + extern vec2 direction; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]], + } + + local norm = 0 + if #gWeights % 2 == 0 then + code[#code + 1] = "vec4 c = vec4( 0.0 );" + else + local weight = gWeights[1] + norm = norm + weight + code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight) + end + + local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n" + for i = 1, #offsets do + local offset = offsets[i] + local weight = weights[i] + norm = norm + weight * 2 + code[#code + 1] = string.format(template, weight, offset, offset) + end + code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm) + + local shaderCode = table.concat(code) + return love.graphics.newShader(shaderCode) +end + +--- Get or create a blur instance from cache +---@param quality number Quality level (1-10) +---@return table blurData Cached blur data {shader, taps} +function Cache.getBlurInstance(quality) + if not Cache.blurInstances[quality] then + local taps = 3 + (quality - 1) * 1.5 + taps = math.floor(taps) + if taps % 2 == 0 then + taps = taps + 1 + end + + local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1) + Cache.blurInstances[quality] = { + shader = shader, + taps = taps, + } + end + + return Cache.blurInstances[quality] +end + +---@class BlurProps +---@field quality number? Quality level (1-10, default: 5) + +---@class Blur +---@field shader love.Shader The blur shader +---@field quality number Quality level (1-10) +---@field taps number Number of shader taps +---@field _ErrorHandler table? Reference to ErrorHandler module +local Blur = {} +Blur.__index = Blur + +--- Check if we should warn about large blur area in immediate mode +---@param elementId string|nil Element ID for caching warnings +---@param width number Blur area width +---@param height number Blur area height +---@param blurType string "content" or "backdrop" +local function checkLargeBlurWarning(elementId, width, height, blurType) + -- Skip if no ErrorHandler available + if not Blur._ErrorHandler then + return + end + + -- Skip if not in immediate mode + if not Blur._blurOptimizations then + return + end + + -- Calculate blur area + local area = width * height + + -- Skip if area is below threshold + if area <= Cache.LARGE_BLUR_THRESHOLD then + return + end + + -- Generate warning key (use elementId if available, otherwise use dimensions) + local warningKey = elementId or string.format("%dx%d:%s", width, height, blurType) + + -- Skip if already warned for this element/area + if warningCache[warningKey] then + return + end + + -- Mark as warned + warningCache[warningKey] = true + + -- Issue warning + local message = + string.format("Large %s blur area detected (%dx%d = %d pixels) in immediate mode", blurType, width, height, area) + + local suggestion = + "Consider using retained mode for this component to avoid recreating blur effects every frame. Large blur operations are expensive and can cause performance issues in immediate mode." + + Blur._ErrorHandler:warn("Blur", "PERF_003", { + area = string.format("%.0fx%.0f", width or 0, height or 0), + }) +end + +--- Create a new blur effect instance +---@param props BlurProps? Blur configuration +---@return Blur blur The new blur instance +function Blur.new(props) + props = props or {} + + local quality = props.quality or 5 + quality = math.max(1, math.min(10, quality)) + + -- Get cached blur instance for this quality level + local blurData = Cache.getBlurInstance(quality) + + local self = setmetatable({}, Blur) + self.shader = blurData.shader + self.quality = quality + self.taps = blurData.taps + + return self +end + +--- Apply blur to a region of the screen +---@param radius number Blur radius in pixels +---@param x number X position +---@param y number Y position +---@param width number Width of region +---@param height number Height of region +---@param drawFunc function Function to draw content to be blurred +function Blur:applyToRegion(radius, x, y, width, height, drawFunc) + if type(drawFunc) ~= "function" then + if Blur._ErrorHandler then + Blur._ErrorHandler:warn("Blur", "BLUR_001") + end + return + end + + if radius <= 0 or width <= 0 or height <= 0 then + drawFunc() + return + end + + -- Early exit for very low radius (optimization) + if radius < Cache.RADIUS_THRESHOLD then + drawFunc() + return + end + + -- Check for large blur area in immediate mode + checkLargeBlurWarning(nil, width, height, "content") + + -- Calculate offset multiplier based on radius and quality + -- Higher quality = more samples = smaller steps for same radius + local offsetMultiplier = radius / self.quality + + local canvas1 = Cache.getCanvas(width, height) + local canvas2 = Cache.getCanvas(width, height) + + local prevCanvas = love.graphics.getCanvas() + local prevShader = love.graphics.getShader() + local prevColor = { love.graphics.getColor() } + local prevBlendMode = love.graphics.getBlendMode() + + love.graphics.setCanvas(canvas1) + love.graphics.clear() + love.graphics.push() + love.graphics.origin() + love.graphics.translate(-x, -y) + drawFunc() + love.graphics.pop() + + love.graphics.setShader(self.shader) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("alpha", "premultiplied") + + -- Single pass with radius-controlled offset + love.graphics.setCanvas(canvas2) + love.graphics.clear() + self.shader:send("direction", { offsetMultiplier / width, 0 }) + love.graphics.draw(canvas1, 0, 0) + + love.graphics.setCanvas(canvas1) + love.graphics.clear() + self.shader:send("direction", { 0, offsetMultiplier / height }) + love.graphics.draw(canvas2, 0, 0) + + love.graphics.setCanvas(prevCanvas) + love.graphics.setShader() + love.graphics.setBlendMode(prevBlendMode) + love.graphics.draw(canvas1, x, y) + + love.graphics.setShader(prevShader) + love.graphics.setColor(unpack(prevColor)) + + Cache.releaseCanvas(canvas1) + Cache.releaseCanvas(canvas2) +end + +--- Apply backdrop blur effect (blur content behind a region) +---@param radius number Blur radius in pixels +---@param x number X position +---@param y number Y position +---@param width number Width of region +---@param height number Height of region +---@param backdropCanvas love.Canvas Canvas containing the backdrop content +function Blur:applyBackdrop(radius, x, y, width, height, backdropCanvas) + if not backdropCanvas then + if Blur._ErrorHandler then + Blur._ErrorHandler:warn("Blur", "BLUR_002") + end + return + end + + if radius <= 0 or width <= 0 or height <= 0 then + return + end + + -- Early exit for very low radius (optimization) + if radius < Cache.RADIUS_THRESHOLD then + return + end + + -- Calculate offset multiplier based on radius and quality + local offsetMultiplier = radius / self.quality + + local canvas1 = Cache.getCanvas(width, height) + local canvas2 = Cache.getCanvas(width, height) + + local prevCanvas = love.graphics.getCanvas() + local prevShader = love.graphics.getShader() + local prevColor = { love.graphics.getColor() } + local prevBlendMode = love.graphics.getBlendMode() + + love.graphics.setCanvas(canvas1) + love.graphics.clear() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("alpha", "premultiplied") + + local backdropWidth, backdropHeight = backdropCanvas:getDimensions() + local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight) + love.graphics.draw(backdropCanvas, quad, 0, 0) + + love.graphics.setShader(self.shader) + + -- Single pass with radius-controlled offset + love.graphics.setCanvas(canvas2) + love.graphics.clear() + self.shader:send("direction", { offsetMultiplier / width, 0 }) + love.graphics.draw(canvas1, 0, 0) + + love.graphics.setCanvas(canvas1) + love.graphics.clear() + self.shader:send("direction", { 0, offsetMultiplier / height }) + love.graphics.draw(canvas2, 0, 0) + + love.graphics.setCanvas(prevCanvas) + love.graphics.setShader() + love.graphics.setBlendMode(prevBlendMode) + love.graphics.draw(canvas1, x, y) + + love.graphics.setShader(prevShader) + love.graphics.setColor(unpack(prevColor)) + + Cache.releaseCanvas(canvas1) + Cache.releaseCanvas(canvas2) + Cache.releaseQuad(quad) +end + +--- Get the current quality level +---@return number quality Quality level (1-10) +function Blur:getQuality() + return self.quality +end + +--- Get the number of shader taps +---@return number taps Number of shader taps +function Blur:getTaps() + return self.taps +end + +--- Clear all caches (call on window resize or memory cleanup) +function Blur.clearCache() + Cache.clear() +end + +--- Apply backdrop blur with caching support +---@param radius number Blur radius in pixels +---@param x number X position +---@param y number Y position +---@param width number Width of region +---@param height number Height of region +---@param backdropCanvas love.Canvas Canvas containing the backdrop content +---@param elementId string|nil Element ID for caching (nil disables caching) +function Blur:applyBackdropCached(radius, x, y, width, height, backdropCanvas, elementId) + -- If caching is disabled or no element ID, fall back to regular apply + if not Blur._blurOptimizations or not elementId then + return self:applyBackdrop(radius, x, y, width, height, backdropCanvas) + end + + -- Generate cache key + local cacheKey = Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, self.quality, true) + + -- Check cache + local cachedCanvas = Cache.getBlurredCanvas(cacheKey) + if cachedCanvas then + -- Draw cached blur + local prevCanvas = love.graphics.getCanvas() + local prevShader = love.graphics.getShader() + local prevColor = { love.graphics.getColor() } + local prevBlendMode = love.graphics.getBlendMode() + + love.graphics.setCanvas(prevCanvas) + love.graphics.setShader() + love.graphics.setBlendMode(prevBlendMode) + love.graphics.draw(cachedCanvas, x, y) + + love.graphics.setShader(prevShader) + love.graphics.setColor(unpack(prevColor)) + return + end + + -- Not cached, render and cache + if not backdropCanvas then + if Blur._ErrorHandler then + Blur._ErrorHandler:warn("Blur", "BLUR_002") + end + return + end + + if radius <= 0 or width <= 0 or height <= 0 then + return + end + + -- Early exit for very low radius (optimization) + if radius < Cache.RADIUS_THRESHOLD then + return + end + + -- Check for large blur area in immediate mode + checkLargeBlurWarning(elementId, width, height, "backdrop") + + -- Calculate offset multiplier based on radius and quality + local offsetMultiplier = radius / self.quality + + local canvas1 = Cache.getCanvas(width, height) + local canvas2 = Cache.getCanvas(width, height) + + local prevCanvas = love.graphics.getCanvas() + local prevShader = love.graphics.getShader() + local prevColor = { love.graphics.getColor() } + local prevBlendMode = love.graphics.getBlendMode() + + love.graphics.setCanvas(canvas1) + love.graphics.clear() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("alpha", "premultiplied") + + local backdropWidth, backdropHeight = backdropCanvas:getDimensions() + local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight) + love.graphics.draw(backdropCanvas, quad, 0, 0) + + love.graphics.setShader(self.shader) + + -- Single pass with radius-controlled offset + love.graphics.setCanvas(canvas2) + love.graphics.clear() + self.shader:send("direction", { offsetMultiplier / width, 0 }) + love.graphics.draw(canvas1, 0, 0) + + love.graphics.setCanvas(canvas1) + love.graphics.clear() + self.shader:send("direction", { 0, offsetMultiplier / height }) + love.graphics.draw(canvas2, 0, 0) + + -- Cache the result + local cachedResult = love.graphics.newCanvas(width, height) + love.graphics.setCanvas(cachedResult) + love.graphics.clear() + love.graphics.setShader() + love.graphics.setBlendMode("alpha", "premultiplied") + love.graphics.draw(canvas1, 0, 0) + Cache.setBlurredCanvas(cacheKey, cachedResult) + + love.graphics.setCanvas(prevCanvas) + love.graphics.setShader() + love.graphics.setBlendMode(prevBlendMode) + love.graphics.draw(canvas1, x, y) + + love.graphics.setShader(prevShader) + love.graphics.setColor(unpack(prevColor)) + + Cache.releaseCanvas(canvas1) + Cache.releaseCanvas(canvas2) + Cache.releaseQuad(quad) +end + +--- Clear blur cache for specific element +---@param elementId string Element ID +function Blur.clearElementCache(elementId) + Cache.clearBlurredCanvasesForElement(elementId) +end + +--- Initialize Blur module with dependencies +---@param deps table Dependencies: { ErrorHandler = ErrorHandler?, immediateModeOptimizations = boolean? } +function Blur.init(deps) + if type(deps) == "table" then + Blur._ErrorHandler = deps.ErrorHandler + Blur._blurOptimizations = deps.immediateModeOptimizations or false + end +end + +Blur.Cache = Cache +Blur.ShaderBuilder = ShaderBuilder + +return Blur diff --git a/libs/flexlove/modules/Calc.lua b/libs/flexlove/modules/Calc.lua new file mode 100644 index 00000000..b62d6bb6 --- /dev/null +++ b/libs/flexlove/modules/Calc.lua @@ -0,0 +1,385 @@ +--- Utility module for parsing and evaluating CSS-like calc() expressions +--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh) +---@class Calc +local Calc = {} + +--- Initialize Calc module with dependencies +---@param deps CalcDependencies Dependencies: { ErrorHandler = ErrorHandler? } +function Calc.init(deps) + Calc._ErrorHandler = deps.ErrorHandler +end + +--- Token types for lexical analysis +local TokenType = { + NUMBER = "NUMBER", + UNIT = "UNIT", + PLUS = "PLUS", + MINUS = "MINUS", + MULTIPLY = "MULTIPLY", + DIVIDE = "DIVIDE", + LPAREN = "LPAREN", + RPAREN = "RPAREN", + EOF = "EOF", +} + +--- Tokenize a calc expression string into tokens +---@param expr string The expression to tokenize (e.g., "50% - 10vw") +---@return CalcToken[]? tokens Array of tokens with type, value, unit +---@return string? error Error message if tokenization fails +local function tokenize(expr) + local tokens = {} + local i = 1 + local len = #expr + + while i <= len do + local char = expr:sub(i, i) + + -- Skip whitespace + if char:match("%s") then + i = i + 1 + -- Number (including decimals, but NOT negative - handled separately below) + elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then + local numStr = "" + + -- Parse integer and decimal parts + while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do + numStr = numStr .. expr:sub(i, i) + i = i + 1 + end + + local num = tonumber(numStr) + if not num then + return nil, "Invalid number: " .. numStr + end + + -- Check for unit following the number + local unitStr = "" + while i <= len and expr:sub(i, i):match("[%a%%]") do + unitStr = unitStr .. expr:sub(i, i) + i = i + 1 + end + + -- Default to px if no unit + if unitStr == "" then + unitStr = "px" + end + + -- Validate unit + local validUnits = { px = true, ["%"] = true, vw = true, vh = true } + if not validUnits[unitStr] then + return nil, "Invalid unit: " .. unitStr + end + + table.insert(tokens, { + type = TokenType.NUMBER, + value = num, + unit = unitStr, + }) + -- Operators + elseif char == "+" then + table.insert(tokens, { type = TokenType.PLUS }) + i = i + 1 + elseif char == "-" then + -- Check if this is a negative number or subtraction + -- It's a negative number if previous token is an operator or opening paren + local prevToken = tokens[#tokens] + if + not prevToken + or prevToken.type == TokenType.PLUS + or prevToken.type == TokenType.MINUS + or prevToken.type == TokenType.MULTIPLY + or prevToken.type == TokenType.DIVIDE + or prevToken.type == TokenType.LPAREN + then + -- This is a negative number, continue to number parsing + local numStr = "-" + i = i + 1 + + -- Parse integer and decimal parts + while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do + numStr = numStr .. expr:sub(i, i) + i = i + 1 + end + + local num = tonumber(numStr) + if not num then + return nil, "Invalid number: " .. numStr + end + + -- Check for unit following the number + local unitStr = "" + while i <= len and expr:sub(i, i):match("[%a%%]") do + unitStr = unitStr .. expr:sub(i, i) + i = i + 1 + end + + -- Default to px if no unit + if unitStr == "" then + unitStr = "px" + end + + -- Validate unit + local validUnits = { px = true, ["%"] = true, vw = true, vh = true } + if not validUnits[unitStr] then + return nil, "Invalid unit: " .. unitStr + end + + table.insert(tokens, { + type = TokenType.NUMBER, + value = num, + unit = unitStr, + }) + else + -- This is subtraction operator + table.insert(tokens, { type = TokenType.MINUS }) + i = i + 1 + end + elseif char == "*" then + table.insert(tokens, { type = TokenType.MULTIPLY }) + i = i + 1 + elseif char == "/" then + table.insert(tokens, { type = TokenType.DIVIDE }) + i = i + 1 + elseif char == "(" then + table.insert(tokens, { type = TokenType.LPAREN }) + i = i + 1 + elseif char == ")" then + table.insert(tokens, { type = TokenType.RPAREN }) + i = i + 1 + else + return nil, "Unexpected character: " .. char + end + end + + table.insert(tokens, { type = TokenType.EOF }) + return tokens +end + +--- Parser for calc expressions using recursive descent +---@class Parser +---@field tokens CalcToken[] Array of tokens +---@field pos number Current token position +local Parser = {} +Parser.__index = Parser + +--- Create a new parser +---@param tokens CalcToken[] Array of tokens +---@return Parser +function Parser.new(tokens) + local self = setmetatable({}, Parser) + self.tokens = tokens + self.pos = 1 + return self +end + +--- Get current token +---@return CalcToken token Current token +function Parser:current() + return self.tokens[self.pos] +end + +--- Advance to next token +function Parser:advance() + self.pos = self.pos + 1 +end + +--- Parse expression (handles + and -) +---@return CalcASTNode ast Abstract syntax tree node +function Parser:parseExpression() + local left = self:parseTerm() + + while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do + local op = self:current().type + self:advance() + local right = self:parseTerm() + left = { + type = op == TokenType.PLUS and "add" or "subtract", + left = left, + right = right, + } + end + + return left +end + +--- Parse term (handles * and /) +---@return CalcASTNode ast Abstract syntax tree node +function Parser:parseTerm() + local left = self:parseFactor() + + while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do + local op = self:current().type + self:advance() + local right = self:parseFactor() + left = { + type = op == TokenType.MULTIPLY and "multiply" or "divide", + left = left, + right = right, + } + end + + return left +end + +--- Parse factor (handles numbers and parentheses) +---@return CalcASTNode ast Abstract syntax tree node +function Parser:parseFactor() + local token = self:current() + + if token.type == TokenType.NUMBER then + self:advance() + return { + type = "number", + value = token.value, + unit = token.unit, + } + elseif token.type == TokenType.LPAREN then + self:advance() + local expr = self:parseExpression() + if self:current().type ~= TokenType.RPAREN then + error("Expected closing parenthesis") + end + self:advance() + return expr + else + error("Unexpected token: " .. token.type) + end +end + +--- Parse the tokens into an AST +---@return CalcASTNode ast Abstract syntax tree +function Parser:parse() + local ast = self:parseExpression() + if self:current().type ~= TokenType.EOF then + error("Unexpected tokens after expression") + end + return ast +end + +--- Create a calc expression object that can be resolved later +--- This is the main API function that users call +---@param expr string The calc expression (e.g., "50% - 10vw") +---@return CalcObject calcObject A calc expression object with AST +function Calc.new(expr) + -- Tokenize + local tokens, err = tokenize(expr) + if not tokens then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "VAL_006", { + expression = expr, + error = err, + }) + end + -- Return a fallback calc object that resolves to 0 + return { + _isCalc = true, + _expr = expr, + _ast = nil, + _error = err, + } + end + + -- Parse + local parser = Parser.new(tokens) + local success, ast = pcall(function() + return parser:parse() + end) + + if not success then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "VAL_006", { + expression = expr, + error = ast, -- ast contains error message on failure + }) + end + -- Return a fallback calc object that resolves to 0 + return { + _isCalc = true, + _expr = expr, + _ast = nil, + _error = ast, + } + end + + return { + _isCalc = true, + _expr = expr, + _ast = ast, + } +end + +--- Check if a value is a calc expression +---@param value any The value to check +---@return boolean isCalc True if value is a calc expression +function Calc.isCalc(value) + return type(value) == "table" and value._isCalc == true +end + +--- Resolve a calc expression to pixel value +---@param calcObj CalcObject The calc expression object +---@param viewportWidth number Viewport width in pixels +---@param viewportHeight number Viewport height in pixels +---@param parentSize number? Parent dimension for percentage units +---@return number resolvedValue Resolved pixel value +function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize) + if not calcObj._ast then + -- Error during parsing, return 0 + return 0 + end + + --- Evaluate AST node recursively + ---@param node table AST node + ---@return number value Evaluated value in pixels + local function evaluate(node) + if node.type == "number" then + -- Convert unit to pixels + local value = node.value + local unit = node.unit + + if unit == "px" then + return value + elseif unit == "%" then + if not parentSize then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "LAY_003", { + unit = "%", + issue = "parent dimension not available", + }) + end + return 0 + end + return (value / 100) * parentSize + elseif unit == "vw" then + return (value / 100) * viewportWidth + elseif unit == "vh" then + return (value / 100) * viewportHeight + else + return 0 + end + elseif node.type == "add" then + return evaluate(node.left) + evaluate(node.right) + elseif node.type == "subtract" then + return evaluate(node.left) - evaluate(node.right) + elseif node.type == "multiply" then + return evaluate(node.left) * evaluate(node.right) + elseif node.type == "divide" then + local divisor = evaluate(node.right) + if divisor == 0 then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "VAL_006", { + expression = calcObj._expr, + error = "Division by zero", + }) + end + return 0 + end + return evaluate(node.left) / divisor + else + return 0 + end + end + + return evaluate(calcObj._ast) +end + +return Calc diff --git a/libs/flexlove/modules/Color.lua b/libs/flexlove/modules/Color.lua new file mode 100644 index 00000000..1ea41d37 --- /dev/null +++ b/libs/flexlove/modules/Color.lua @@ -0,0 +1,346 @@ +---@class Color +local Color = {} +Color.__index = Color + +--- Initialize module with shared dependencies +---@param deps table Dependencies {ErrorHandler} +function Color.init(deps) + if type(deps) == "table" then + Color._ErrorHandler = deps.ErrorHandler + end +end + +--- Build type-safe color objects with automatic validation and clamping +--- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range) +---@param r number? Red component (0-1), defaults to 0 +---@param g number? Green component (0-1), defaults to 0 +---@param b number? Blue component (0-1), defaults to 0 +---@param a number? Alpha component (0-1), defaults to 1 +---@return Color color The new color instance +function Color.new(r, g, b, a) + -- Sanitize and clamp color components + local _, sanitizedR = Color.validateColorChannel(r or 0, 1) + local _, sanitizedG = Color.validateColorChannel(g or 0, 1) + local _, sanitizedB = Color.validateColorChannel(b or 0, 1) + local _, sanitizedA = Color.validateColorChannel(a or 1, 1) + + -- FFI structs don't support metatables/methods without wrapping + -- The wrapping overhead negates the FFI benefits + local self = setmetatable({}, Color) + self.r = sanitizedR or 0 + self.g = sanitizedG or 0 + self.b = sanitizedB or 0 + self.a = sanitizedA or 1 + return self +end + +--- Extract individual color channels for use with love.graphics.setColor() +--- Use this to pass colors to LÖVE's rendering functions +---@return number r Red component (0-1) +---@return number g Green component (0-1) +---@return number b Blue component (0-1) +---@return number a Alpha component (0-1) +function Color:toRGBA() + return self.r, self.g, self.b, self.a +end + +--- Parse CSS-style hex colors into Color objects for designer-friendly workflows +--- Use this to work with colors from design tools that export hex values +---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA") +---@return Color color The parsed color (returns white on error with warning) +function Color.fromHex(hexWithTag) + -- Validate input type + if type(hexWithTag) ~= "string" then + Color._ErrorHandler:warn("Color", "VAL_004", { + input = tostring(hexWithTag), + issue = "not a string", + fallback = "white (#FFFFFF)", + }) + return Color.new(1, 1, 1, 1) + end + + local hex = hexWithTag:gsub("#", "") + if #hex == 6 then + local r = tonumber("0x" .. hex:sub(1, 2)) + local g = tonumber("0x" .. hex:sub(3, 4)) + local b = tonumber("0x" .. hex:sub(5, 6)) + if not r or not g or not b then + Color._ErrorHandler:warn("Color", "VAL_004", { + input = hexWithTag, + issue = "invalid hex digits", + fallback = "white (#FFFFFF)", + }) + return Color.new(1, 1, 1, 1) -- Return white as fallback + end + return Color.new(r / 255, g / 255, b / 255, 1) + elseif #hex == 8 then + local r = tonumber("0x" .. hex:sub(1, 2)) + local g = tonumber("0x" .. hex:sub(3, 4)) + local b = tonumber("0x" .. hex:sub(5, 6)) + local a = tonumber("0x" .. hex:sub(7, 8)) + if not r or not g or not b or not a then + Color._ErrorHandler:warn("Color", "VAL_004", { + input = hexWithTag, + issue = "invalid hex digits", + fallback = "white (#FFFFFFFF)", + }) + return Color.new(1, 1, 1, 1) -- Return white as fallback + end + return Color.new(r / 255, g / 255, b / 255, a / 255) + else + Color._ErrorHandler:warn("Color", "VAL_004", { + input = hexWithTag, + expected = "#RRGGBB or #RRGGBBAA", + hexLength = #hex, + fallback = "white (#FFFFFF)", + }) + return Color.new(1, 1, 1, 1) -- Return white as fallback + end +end + +--- Verify and sanitize individual color components to prevent rendering errors +--- Use this to safely process user input or external color data +---@param value any Value to validate +---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1 +---@return boolean valid True if valid +---@return number? clamped Clamped value in 0-1 range, nil if invalid +function Color.validateColorChannel(value, max) + max = max or 1 + + if type(value) ~= "number" then + return false, nil + end + + -- Check for NaN + if value ~= value then + return false, nil + end + + -- Check for Infinity + if value == math.huge or value == -math.huge then + return false, nil + end + + -- Normalize to 0-1 range + local normalized = value + if max == 255 then + normalized = value / 255 + end + + -- Clamp to valid range + normalized = math.max(0, math.min(1, normalized)) + + return true, normalized +end + +--- Validate hex color format +---@param hex string Hex color string (with or without #) +---@return boolean valid True if valid format +---@return string? error Error message if invalid, nil if valid +function Color.validateHexColor(hex) + if type(hex) ~= "string" then + return false, "Hex color must be a string" + end + + -- Remove # prefix + local cleanHex = hex:gsub("^#", "") + + -- Check length (3, 6, or 8 characters) + if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then + return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex) + end + + -- Check for valid hex characters + if not cleanHex:match("^[0-9A-Fa-f]+$") then + return false, "Invalid hex characters. Use only 0-9, A-F" + end + + return true, nil +end + +--- Validate RGB/RGBA color values +---@param r number Red component +---@param g number Green component +---@param b number Blue component +---@param a number? Alpha component (optional, defaults to max) +---@param max number? Maximum value (255 or 1), defaults to 1 +---@return boolean valid True if valid +---@return string? error Error message if invalid, nil if valid +function Color.validateRGBColor(r, g, b, a, max) + max = max or 1 + a = a or max + + local rValid = Color.validateColorChannel(r, max) + local gValid = Color.validateColorChannel(g, max) + local bValid = Color.validateColorChannel(b, max) + local aValid = Color.validateColorChannel(a, max) + + if not rValid then + return false, string.format("Invalid red channel: %s", tostring(r)) + end + if not gValid then + return false, string.format("Invalid green channel: %s", tostring(g)) + end + if not bValid then + return false, string.format("Invalid blue channel: %s", tostring(b)) + end + if not aValid then + return false, string.format("Invalid alpha channel: %s", tostring(a)) + end + + return true, nil +end + +--- Check if a value is a valid color format +---@param value any Value to check +---@return string? format Format type ("hex", "named", "table"), nil if invalid +function Color.isValidColorFormat(value) + local valueType = type(value) + + -- Check for hex string + if valueType == "string" then + if value:match("^#?[0-9A-Fa-f]+$") then + local valid = Color.validateHexColor(value) + if valid then + return "hex" + end + end + + return nil + end + + -- Check for table format + if valueType == "table" then + -- Check for Color instance + if getmetatable(value) == Color then + return "table" + end + + -- Check for array format {r, g, b, a} + if value[1] and value[2] and value[3] then + local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4]) + if valid then + return "table" + end + end + + -- Check for named format {r=, g=, b=, a=} + if value.r and value.g and value.b then + local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a) + if valid then + return "table" + end + end + + return nil + end + + return nil +end + +--- Convert any color format to a valid Color object with graceful fallbacks +--- Use this to robustly handle colors from any source without crashes +---@param value any Color value to sanitize (hex, named, table, or Color instance) +---@param default Color? Default color if invalid (defaults to black) +---@return Color color Sanitized color instance (guaranteed non-nil) +function Color.sanitizeColor(value, default) + default = default or Color.new(0, 0, 0, 1) + + local format = Color.isValidColorFormat(value) + + if not format then + return default + end + + -- Handle hex format + if format == "hex" then + local cleanHex = value:gsub("^#", "") + + -- Expand 3-digit hex to 6-digit + if #cleanHex == 3 then + cleanHex = cleanHex:gsub("(.)", "%1%1") + end + + -- Try to parse + local success, result = pcall(Color.fromHex, "#" .. cleanHex) + if success then + return result + else + return default + end + end + + if format == "table" then + -- Color instance + if getmetatable(value) == Color then + return value + end + + -- Array format + if value[1] then + local _, r = Color.validateColorChannel(value[1], 1) + local _, g = Color.validateColorChannel(value[2], 1) + local _, b = Color.validateColorChannel(value[3], 1) + local _, a = Color.validateColorChannel(value[4] or 1, 1) + + if r and g and b and a then + return Color.new(r, g, b, a) + end + end + + -- Named format + if value.r then + local _, r = Color.validateColorChannel(value.r, 1) + local _, g = Color.validateColorChannel(value.g, 1) + local _, b = Color.validateColorChannel(value.b, 1) + local _, a = Color.validateColorChannel(value.a or 1, 1) + + if r and g and b and a then + return Color.new(r, g, b, a) + end + end + end + + return default +end + +--- Universally convert any color format (hex, named, table) into a Color object +--- Use this as your main color input handler to accept flexible color specifications +---@param value any Color value (hex string, named color, table, or Color instance) +---@return Color color Parsed color instance (defaults to black on error) +function Color.parse(value) + return Color.sanitizeColor(value, Color.new(0, 0, 0, 1)) +end + +--- Smoothly transition between two colors for animations and gradients +--- Use this to create color-based animations without manual channel calculations +---@param colorA Color Starting color +---@param colorB Color Ending color +---@param t number Interpolation factor (0-1) +---@return Color color Interpolated color +function Color.lerp(colorA, colorB, t) + -- Sanitize inputs + if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then + colorA = Color.new(0, 0, 0, 1) + end + if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then + colorB = Color.new(0, 0, 0, 1) + end + if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then + t = 0 + end + + -- Clamp t to 0-1 range + t = math.max(0, math.min(1, t)) + + -- Linear interpolation for each channel + local oneMinusT = 1 - t + local r = colorA.r * oneMinusT + colorB.r * t + local g = colorA.g * oneMinusT + colorB.g * t + local b = colorA.b * oneMinusT + colorB.b * t + local a = colorA.a * oneMinusT + colorB.a * t + + return Color.new(r, g, b, a) +end + +return Color diff --git a/libs/flexlove/modules/Context.lua b/libs/flexlove/modules/Context.lua new file mode 100644 index 00000000..10f53015 --- /dev/null +++ b/libs/flexlove/modules/Context.lua @@ -0,0 +1,596 @@ +---@class Context +local modulePath = (...):match("(.-)[^%.]+$") +local ZIndex = require(modulePath .. "ZIndex") +local Element = require(modulePath .. "Element") +local Context = { + topElements = {}, + -- Base scale configuration + baseScale = nil, -- {width: number, height: number} + -- Current scale factors + scaleFactors = { x = 1.0, y = 1.0 }, + defaultTheme = nil, + _focusedElement = nil, + _focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames + _activeEventElement = nil, + _cachedViewport = { width = 0, height = 0 }, + -- Immediate mode state + _immediateMode = false, + _frameNumber = 0, + _currentFrameElements = {}, + _immediateModeState = nil, -- Will be initialized if immediate mode is enabled + _frameStarted = false, + _autoBeganFrame = false, + -- Z-index ordered element tracking for immediate mode + _zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest) + -- Focus management guard + _settingFocus = false, + -- Hook called whenever focus changes: function(element) or nil + _onFocusChanged = nil, + + -- Navigation state + _navigationContext = { + lastFocusedElement = nil, -- For returning from modals + navigationMode = "sequential", -- "sequential" or "directional" + containerElement = nil, -- Current navigation container + }, + + initialized = false, + + -- Expose internal hit-testing helpers for unit testing only. + -- These are populated below after their local definitions. They are NOT part + -- of the public API and must not be relied on by callers; they exist so the + -- shared hit-test core (the single place display:none guarding lives) can be + -- exercised directly by the test suite. Subsequent unified-event-routing + -- tasks consume these locals through the mode-agnostic query functions. + _test = { + pointHitsElement = nil, + elementHasScrollableOverflow = nil, + }, + + -- Debug draw overlay + _debugDraw = false, + _debugDrawKey = nil, + + -- Initialization state tracking + ---@type "uninitialized"|"initializing"|"ready" + _initState = "uninitialized", + ---@type table[] Queue of {props: ElementProps, callback: function(element)|nil} + _initQueue = {}, + + -- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's + -- per-element call (unified-event-routing task 05) doesn't re-walk the tree + -- + realloc + sort for every interactive element sharing the same cursor. + -- Invalidated explicitly by Context.clearInteractiveCache() at the start of + -- each flexlove.update (both modes) and in clearFrameElements (immediate + -- mid-frame rebuild). It also self-invalidates when the topElements table + -- reference changes (tests replace it per-case; immediate-mode beginFrame + -- reassigns it each frame), so direct callers that never go through + -- flexlove.update still see fresh results across tree swaps. + _interactiveLookupCache = { + valid = false, + x = nil, + y = nil, + result = nil, + topElementsRef = nil, + frameNumber = -1, + }, +} + +--- Check if a point hits an element, accounting for scroll offsets and display:none. +--- All mode-agnostic query functions use this as their single hit-test entry point, +--- ensuring fixes like display:none guarding apply everywhere. +--- +--- This is the single canonical place where `element.display == false` short- +--- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is +--- the caller's responsibility: callers walk the parent chain (using +--- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the +--- accumulated scroll offset in here. Keeping the parent walk outside this core +--- lets retained-mode (recursive tree descent) and immediate-mode (flat +--- z-index list) callers share the exact same primitive bounds/display logic. +---@param element Element +---@param mx number Screen X coordinate +---@param my number Screen Y coordinate +---@param scrollOffsetX number? Accumulated scroll offset from parent chain +---@param scrollOffsetY number? Accumulated scroll offset from parent chain +---@return boolean hits +local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY) + scrollOffsetX = scrollOffsetX or 0 + scrollOffsetY = scrollOffsetY or 0 + + -- Skip display:none elements entirely + if element.display == false then + return false + end + + local bx = element.x + local by = element.y + local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) + + local adjustedX = mx + scrollOffsetX + local adjustedY = my + scrollOffsetY + + return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh +end + +--- Check if an element has scrollable/clipped overflow (for scroll offset accumulation). +--- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the +--- overflow values that clip/translate descendant content and therefore require +--- scroll-offset compensation when hit testing descendants. +---@param element Element +---@return boolean +local function elementHasScrollableOverflow(element) + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + return overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" + or overflowX == "hidden" + or overflowY == "hidden" +end + +-- Expose the two core helpers for unit testing only (see Context._test above). +Context._test.pointHitsElement = pointHitsElement +Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow + +-- Public exposure of the canonical hit-test primitive so other modules +-- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition` +-- tree walks) can share the single implementation of bounds + display:none +-- guarding instead of duplicating the `display == false` check inline. +-- This keeps "display == false" in exactly one place for hit-testing. +Context.pointHitsElement = pointHitsElement +Context.elementHasScrollableOverflow = elementHasScrollableOverflow + +--- Find the first scrollable element at a screen position, regardless of mode. +--- This is the mode-agnostic successor to the two duplicated scrollable lookups +--- that previously lived inline in `flexlove.wheelmoved`: +--- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and +--- re-implemented bounds + parent-chain clipping + scroll-offset math; and +--- * retained mode — recursed through `Context.topElements` with a private +--- `findScrollableAtPosition(elements, x, y)` helper. +--- Both paths now collapse into this single function, which routes every +--- hit test through `pointHitsElement` (the single place `display == false` +--- is guarded) and every scroll-offset decision through +--- `elementHasScrollableOverflow`. As a result display:none elements are never +--- returned in either mode, fixing the latent bug where the immediate-mode +--- path's `isPointInElement` did not skip display:none elements. +--- +--- The retained-mode branch intentionally mirrors the original +--- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins, +--- children checked before self) but is upgraded to thread accumulated scroll +--- offsets through `pointHitsElement` so nested scrolled containers are tested +--- against their visible position. The original helper is removed once +--- `flexlove.wheelmoved` is rerouted onto this function in task 04. +---@param x number Screen X coordinate +---@param y number Screen Y coordinate +---@return Element|nil The scrollable element, or nil +function Context.findScrollableAtPosition(x, y) + if Context.isImmediateMode() then + -- Immediate mode: iterate the z-index ordered list (reverse order = + -- topmost first). pointHitsElement supplies the bounds + display guard. + for i = #Context._zIndexOrderedElements, 1, -1 do + local element = Context._zIndexOrderedElements[i] + if pointHitsElement(element, x, y) then + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + if + (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") + and (element._overflowX or element._overflowY) + then + return element + end + end + end + return nil + else + -- Retained mode: recursive tree walk from topElements. Children are + -- checked before self (deepest scrollable wins); accumulated scroll + -- offsets are threaded through pointHitsElement so descendants of + -- scrolled containers are hit-tested against their translated position. + local function findInTree(elements, scrollOffsetX, scrollOffsetY) + scrollOffsetX = scrollOffsetX or 0 + scrollOffsetY = scrollOffsetY or 0 + for i = #elements, 1, -1 do + local element = elements[i] + if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then + if #element.children > 0 then + local childScrollOffsetX = scrollOffsetX + local childScrollOffsetY = scrollOffsetY + if elementHasScrollableOverflow(element) then + childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0) + childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0) + end + local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY) + if childResult then + return childResult + end + end + -- No descendant was scrollable — check self. + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + if + (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") + and (element._overflowX or element._overflowY) + then + return element + end + end + end + return nil + end + return findInTree(Context.topElements) + end +end + +--- Check whether immediate mode is active. +--- This is the single canonical accessor for the mode flag consumed throughout +--- the framework. Mode-aware branches elsewhere call this instead of reading +--- `Context._immediateMode` directly, so the literal mode flag only appears +--- here (its definition) and in StateManager (its mirrored storage) — never +--- scattered across Element / behaviors / managers (behavior-mode-unification +--- task 11). +---@return boolean +function Context.isImmediateMode() + return Context._immediateMode +end + +---@return number, number -- scaleX, scaleY +function Context.getScaleFactors() + return Context.scaleFactors.x, Context.scaleFactors.y +end + +--- Register an element in the z-index ordered tree (for immediate mode) +---@param element Element The element to register +function Context.registerElement(element) + if not Context.isImmediateMode() then + return + end + + table.insert(Context._zIndexOrderedElements, element) +end + +function Context.clearFrameElements() + Context._zIndexOrderedElements = {} + Context.clearInteractiveCache() +end + +--- Compute the composite z-index key for an element. +--- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ +--- +--- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance. +--- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above +--- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the +--- remaining 3 digits without interfering with the depth component. +--- +--- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which +--- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15). +--- +--- This is the SINGLE canonical z-index ordering function, used by both +--- sortElementsByZIndex (the immediate-mode flat list sort) and +--- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them +--- on the same key ensures the interactive topmost element matches the visual +--- draw order — a button in a z=50 MainMenu window must occlude a button in a +--- z=0 BottomBar even when both buttons default to own z=0. +local function getEffectiveZIndex(elem) + local ownZ = elem.z or 0 + local rootZ = ownZ + local depth = 0 + local current = elem.parent + while current do + rootZ = current.z or 0 + depth = depth + 1 + current = current.parent + end + return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ +end + +-- Public exposure so FlexLove.getElementAtPosition shares the single +-- implementation instead of duplicating the parent-chain walk as a closure. +Context.getEffectiveZIndex = getEffectiveZIndex + +--- Sort elements by z-index (called after all elements are registered) +function Context.sortElementsByZIndex() + -- Precompute the composite key ONCE per element so the sort comparator is a + -- pure table lookup (O(1)) instead of re-walking the parent chain on every + -- O(N log N) comparison. This function runs every frame in immediate mode. + local elements = Context._zIndexOrderedElements + local zIndices = {} + for i = 1, #elements do + zIndices[elements[i]] = getEffectiveZIndex(elements[i]) + end + table.sort(elements, function(a, b) + return zIndices[a] < zIndices[b] + end) +end + +--- Find the topmost interactive element at a screen position, regardless of mode. +--- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed +--- in unified-event-routing task 05) and the retained-mode `_activeEventElement` +--- mechanism — both are now funneled through this single entry point. +--- +--- In immediate mode this replaces Context.getTopElementAt() (which only worked +--- in immediate mode). In retained mode this provides the same role as the +--- _activeEventElement set by flexlove.getElementAtPosition(). +--- +--- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable. +---@param x number Screen X coordinate +---@param y number Screen Y coordinate +---@return Element|nil The topmost interactive element, or nil +function Context.findInteractiveAtPosition(x, y) + -- Per-frame cache: Clickable.onUpdate runs this for every interactive + -- element under the same cursor, but the result for a given (x,y) is + -- identical across all of them within a single update pass. Returning a + -- cached element restores the old 1x/frame cost of the _activeEventElement + -- mechanism that task 05 replaced. Cache auto-invalidates when the + -- topElements table reference changes (so tests and mid-frame rebuilds get + -- fresh results) and is cleared explicitly per-frame in flexlove.update. + local cache = Context._interactiveLookupCache + if + cache.valid + and cache.x == x + and cache.y == y + and cache.topElementsRef == Context.topElements + and cache.frameNumber == Context._frameNumber + then + return cache.result + end + + local interactiveCandidates = {} + + local function collectInteractive(element, scrollOffsetX, scrollOffsetY) + scrollOffsetX = scrollOffsetX or 0 + scrollOffsetY = scrollOffsetY or 0 + + if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then + return + end + + -- Check if this element is interactive + if element.onEvent or element.themeComponent or element.editable then + table.insert(interactiveCandidates, element) + end + + -- Recurse into children with accumulated scroll offset + local childScrollOffsetX = scrollOffsetX + local childScrollOffsetY = scrollOffsetY + if elementHasScrollableOverflow(element) then + childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0) + childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0) + end + + for _, child in ipairs(element.children) do + collectInteractive(child, childScrollOffsetX, childScrollOffsetY) + end + end + + -- Always traverse the tree (works in both modes — topElements exists always) + for _, element in ipairs(Context.topElements) do + collectInteractive(element) + end + + -- Sort by composite z-index descending — topmost wins. The composite key + -- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering + -- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive + -- topmost element matches the visual draw order. This is critical for the + -- game's multi-window layout: a button inside a z=50 MainMenu window must + -- occlude a button inside a z=0 BottomBar even when both buttons default to + -- own z=0. Sorting by own-z alone (the original implementation) couldn't + -- distinguish them, so the wrong window's button could win, leaving the + -- visible button's isActiveElement=false and clicks/hover dead. + local zIndices = {} + for _, el in ipairs(interactiveCandidates) do + zIndices[el] = getEffectiveZIndex(el) + end + table.sort(interactiveCandidates, function(a, b) + return zIndices[a] > zIndices[b] + end) + + local result = interactiveCandidates[1] + + cache.x = x + cache.y = y + cache.result = result + cache.topElementsRef = Context.topElements + cache.frameNumber = Context._frameNumber + cache.valid = true + + return result +end + +--- Invalidate the per-frame `findInteractiveAtPosition` cache. +--- Called once at the top of `flexlove.update` (the natural per-frame boundary +--- in both modes) and from `clearFrameElements` (immediate-mode mid-frame +--- rebuild). After invalidation the next lookup recomputes fresh. +function Context.clearInteractiveCache() + local cache = Context._interactiveLookupCache + cache.valid = false + cache.x = nil + cache.y = nil + cache.result = nil + cache.topElementsRef = nil + cache.frameNumber = -1 +end + +--- Set the focused element (centralizes focus management) +--- Automatically blurs the previously focused element if different +---@param element Element|nil The element to focus (nil to clear focus) +function Context.setFocused(element) + if Context._focusedElement == element then + return -- Already focused + end + + -- Prevent re-entry during focus change + if Context._settingFocus then + return + end + Context._settingFocus = true + + -- Save reference to previously focused element before updating + local oldFocusedElement = Context._focusedElement + + -- Blur previously focused element + if oldFocusedElement and oldFocusedElement ~= element then + if oldFocusedElement._textEditor then + oldFocusedElement._textEditor:blur(oldFocusedElement) + end + end + + -- Set new focused element and persist its id for immediate-mode rehydration + Context._focusedElement = element + Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil + + -- Notify any registered focus change hook (e.g. FocusIndicator) + if Context._onFocusChanged then + Context._onFocusChanged(element) + end + + -- Focus the new element's text editor if it has one + if element and element._textEditor then + element._textEditor._focused = true + end + + Context._settingFocus = false +end + +--- Recursively search for an element by id in an element tree +---@param root Element The root element to start searching from +---@param targetId string The id to search for +---@return Element|nil The element with the matching id, or nil if not found +local function findElementById(root, targetId) + if root.id == targetId then + return root + end + for _, child in ipairs(root.children or {}) do + local found = findElementById(child, targetId) + if found then + return found + end + end + return nil +end + +--- Rehydrate _focusedElement from _focusedElementId by scanning live elements. +--- Called at the start of getFocused() in immediate mode so stale references +--- are always replaced with the current-frame object before use. +function Context._rehydrateFocus() + if not Context._focusedElementId then + Context._focusedElement = nil + return + end + + -- First, try a fast linear search through all registered elements + for _, elem in ipairs(Context._zIndexOrderedElements) do + if elem.id == Context._focusedElementId then + Context._focusedElement = elem + return + end + end + + -- If not found, recursively search from top-level elements + -- This handles cases where elements may not be in _zIndexOrderedElements + for _, topLevel in ipairs(Context.topElements or {}) do + local found = findElementById(topLevel, Context._focusedElementId) + if found then + Context._focusedElement = found + return + end + end + + -- Element with that id is not present this frame (e.g. screen changed) + Context._focusedElement = nil +end + +--- Get the currently focused element +---@return Element|nil The focused element, or nil if none +function Context.getFocused() + if Context.isImmediateMode() then + Context._rehydrateFocus() + end + return Context._focusedElement +end + +--- Clear focus from any element +function Context.clearFocus() + Context._focusedElementId = nil + Context.setFocused(nil) +end + +--- Get all focusable elements in tab order, regardless of mode. +--- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted). +--- In retained mode it walks the element tree (DOM order). +--- In both modes, display:none elements are excluded. +---@return table List of focusable elements in tab order +function Context.getFocusableElements() + local focusable = {} + + local function isFocusable(elem) + if elem.display == false then + return false + end + -- Use Element:isFocusable() for consistent behavior + return Element.isFocusable(elem) + end + + local function collectFromTree(elements) + for _, elem in ipairs(elements) do + if isFocusable(elem) then + table.insert(focusable, elem) + end + if #elem.children > 0 then + collectFromTree(elem.children) + end + end + end + + if Context._immediateMode then + -- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first), + -- which approximates tab order for most UIs. + for _, elem in ipairs(Context._zIndexOrderedElements) do + if isFocusable(elem) then + table.insert(focusable, elem) + end + end + else + -- Retained mode: walk the top element trees in DOM order + collectFromTree(Context.topElements) + end + + return focusable +end + +-- ==================== +-- Navigation Context +-- ==================== + +--- Push current focus onto stack (for modals/dialogs) +---@param element Element? +function Context.pushFocusStack(element) + Context._navigationContext.lastFocusedElement = Context._focusedElement + if element then + Context.setFocused(element) + end +end + +--- Pop focus from stack (return from modal) +---@return Element? +function Context.popFocusStack() + local previous = Context._navigationContext.lastFocusedElement + Context._navigationContext.lastFocusedElement = nil + Context.setFocused(previous) + return previous +end + +--- Set navigation container (scope for tab navigation) +---@param element Element? +function Context.setNavigationContainer(element) + Context._navigationContext.containerElement = element +end + +--- Get navigation container +---@return Element? +function Context.getNavigationContainer() + return Context._navigationContext.containerElement +end + +return Context diff --git a/libs/flexlove/modules/Element.lua b/libs/flexlove/modules/Element.lua new file mode 100644 index 00000000..5a2da8db --- /dev/null +++ b/libs/flexlove/modules/Element.lua @@ -0,0 +1,3904 @@ +---@class Element +---@field id string +---@field children Element[] +---@field parent Element|nil +---@field userdata any|nil +---@field onEvent fun(self: Element, event: table)|nil +---@field onEventDeferred boolean|nil +---@field onFocus fun(self: Element)|nil +---@field onFocusDeferred boolean +---@field dropFocusOnSelection boolean|nil +---@field onBlur fun(self: Element)|nil +---@field onBlurDeferred boolean +---@field onTextInput fun(self: Element, text: string)|nil +---@field onTextInputDeferred boolean +---@field onTextChange fun(self: Element, text: string)|nil +---@field onTextChangeDeferred boolean +---@field onEnter fun(self: Element)|nil +---@field onEnterDeferred boolean +---@field customDraw fun(self: Element)|nil +---@field onTouchEvent fun(self: Element, event: table)|nil +---@field onTouchEventDeferred boolean +---@field onGesture fun(self: Element, gesture: table)|nil +---@field onGestureDeferred boolean +---@field touchEnabled boolean +---@field multiTouchEnabled boolean +---@field theme table|nil +---@field themeComponent string|nil +---@field disabled boolean +---@field active boolean +---@field disableHighlight boolean +---@field contentAutoSizingMultiplier number[]|nil +---@field scaleCorners boolean|nil +---@field scalingAlgorithm string|nil +---@field contentBlur {radius:number, quality?:number}|nil +---@field backdropBlur {radius:number, quality?:number}|nil +---@field editable boolean +---@field multiline boolean +---@field passwordMode boolean +---@field textWrap string|boolean +---@field maxLines number|nil +---@field maxLength number|nil +---@field placeholder string|nil +---@field inputType string +---@field textOverflow string +---@field scrollable boolean +---@field autoGrow boolean +---@field selectOnFocus boolean +---@field cursorColor Color|nil +---@field selectionColor Color|nil +---@field cursorBlinkRate number +---@field selectParent Element|nil +---@field selectOption table|nil +---@field onChange fun(self: Element, value: any, option: Element)|nil +---@field border number|table|nil +---@field borderColor Color +---@field backgroundColor Color +---@field opacity number +---@field visibility string +---@field display boolean +---@field transform table|nil +---@field cornerRadius number|{topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|nil +---@field text string|nil +---@field textAlign string|table|nil +---@field textAlignHorizontal string +---@field textAlignVertical string +---@field imagePath string|nil +---@field image table|nil +---@field objectFit string +---@field objectPosition string +---@field imageOpacity number +---@field imageRepeat string +---@field imageTint Color|nil +---@field onImageLoad fun(self: Element, image: table)|nil +---@field onImageLoadDeferred boolean +---@field onImageError fun(self: Element, err: string)|nil +---@field onImageErrorDeferred boolean +---@field prevGameSize {width:number, height:number} +---@field autosizing {width:boolean, height:boolean} +---@field units table +---@field minTextSize number|nil +---@field maxTextSize number|nil +---@field autoScaleText boolean +---@field fontFamily string|nil +---@field textSize number +---@field width number +---@field height number +---@field x number +---@field y number +---@field z number +---@field gap number +---@field flexGrow number +---@field flexShrink number +---@field flexBasis number|string +---@field padding {top:number, right:number, bottom:number, left:number} +---@field margin {top:number, right:number, bottom:number, left:number} +---@field tabIndex number|nil +---@field textColor Color +---@field positioning string +---@field top number|nil +---@field right number|nil +---@field bottom number|nil +---@field left number|nil +---@field flexDirection string|nil +---@field flexWrap string|nil +---@field justifyContent string|nil +---@field alignItems string|nil +---@field alignContent string|nil +---@field justifySelf string|nil +---@field alignSelf string +---@field gridRows number|nil +---@field gridColumns number|nil +---@field columnGap number|nil +---@field rowGap number|nil +---@field transition table +---@field transitions table|nil +---@field animation table|nil +---@field overflow string|nil +---@field overflowX string|nil +---@field overflowY string|nil +---@field scrollbarWidth number|nil +---@field scrollbarColor Color|nil +---@field scrollbarBackgroundColor Color|nil +---@field scrollbarTrackColor Color|nil +---@field scrollbarRadius number|nil +---@field scrollbarPadding number|nil +---@field scrollSpeed number|nil +---@field invertScroll boolean|nil +---@field scrollBarStyle string|nil +---@field scrollbarKnobOffset number|nil +---@field hideScrollbars boolean|nil +---@field scrollbarPlacement string|nil +---@field scrollbarBalance number|nil +---@field borderWidth number|nil +---@field fontSize number|nil +---@field lineHeight number|nil +local Element = {} +Element.__index = Element + +-- Forward declarations for the special-handler binding helpers used by +-- Element:_applyProps (behavior-mode-unification task 08 capstone). These +-- absorb the former subsystem-init and visual-state phase bodies (ThemeManager +-- creation + theme-field exposure + editable/text/scroll/autoGrow/select-field +-- defaults + parent assignment, and border/cornerRadius/display/text/textAlign +-- normalization). They are now private implementation of the props-binding phase +-- rather than standalone Element methods, so there are no longer per-capability +-- init phases on Element. +local bindThemeAndFields, bindVisualState + +-- NOTE: There is intentionally NO custom Element.__newindex for dimension properties. +-- Lua's __newindex fires ONLY when the key is ABSENT from the raw table, but width/ +-- height/x/y are all assigned during Element.new, so they already exist post- +-- construction. A __newindex handler therefore CANNOT intercept retained-mode bare +-- writes like `element.width = "42%"` (it just rawsets the broken string). +-- Dimensions are instead validated lazily in Element:_checkDimensionTypes() at the +-- start of each reflow, and must be changed via :setProperty() for resolution + +-- layout invalidation. Keeping the metatable free of __newindex also avoids a +-- per-field-write function call on every absent-key assignment (perf). + +local MAX_DEFER_RETRIES = 10 +local MAX_DEFERRED_METHODS = 100 +local _DEFERRED_NIL = {} +local unpack = table.unpack or unpack + +---Initialize Element module with required dependencies +---@param deps table Dependency table containing all required modules +function Element.init(deps) + Element._ErrorHandler = deps.ErrorHandler + Element._Color = deps.Color + Element._Context = deps.Context + Element._Units = deps.Units + Element._Calc = deps.Calc + Element._utils = deps.utils + Element._InputEvent = deps.InputEvent + Element._EventHandler = deps.EventHandler + Element._Renderer = deps.Renderer + Element._LayoutEngine = deps.LayoutEngine + Element._TextEditor = deps.TextEditor + Element._ScrollManager = deps.ScrollManager + Element._Theme = deps.Theme + Element._RoundedRect = deps.RoundedRect + Element._NinePatch = deps.NinePatch + Element._ImageRenderer = deps.ImageRenderer + Element._ImageCache = deps.ImageCache + Element._ImageScaler = deps.ImageScaler + Element._Blur = deps.Blur + Element._Transform = deps.Transform + Element._Grid = deps.Grid + Element._StateManager = deps.StateManager + Element._GestureRecognizer = deps.GestureRecognizer + Element._Performance = deps.Performance + Element._Animation = deps.Animation + Element._ZIndex = deps.ZIndex + Element._Select = deps.Select + Element._PropertySchema = deps.PropertySchema or require("modules.PropertySchema") + Element._Select.init({ + ErrorHandler = Element._ErrorHandler, + Context = Element._Context, + StateManager = Element._StateManager, + utils = Element._utils, + Element = Element, + }) + Element._ScrollManager.init({ + ErrorHandler = Element._ErrorHandler, + Context = Element._Context, + StateManager = Element._StateManager, + }) + + -- Bind Element scroll/scrollbar API directly onto ScrollManager. + -- ScrollManager owns all scroll interaction logic; Element retains only + -- 1-line delegates (no hand-written sync/nil-guard boilerplate). + local SM = Element._ScrollManager + Element._syncScrollManagerState = SM.syncToElement + Element._detectOverflow = SM._detectOverflow + Element.setScrollPosition = SM.setScrollPosition + Element._calculateScrollbarDimensions = SM._calculateScrollbarDimensions + Element._getScrollbarAtPosition = SM._getScrollbarAtPosition + Element._handleScrollbarPress = SM._handleScrollbarPress + Element._handleScrollbarDrag = SM._handleScrollbarDrag + Element._handleScrollbarRelease = SM._handleScrollbarRelease + Element._handleWheelScroll = SM._handleWheelScroll + Element.getScrollPosition = SM.getScrollPosition + Element.getMaxScroll = SM.elementGetMaxScroll + Element.getScrollPercentage = SM.elementGetScrollPercentage + Element.hasOverflow = SM.elementHasOverflow + Element.getContentSize = SM.elementGetContentSize + Element.scrollBy = SM.elementScrollBy + Element.scrollToTop = SM.scrollToTop + Element.scrollToBottom = SM.scrollToBottom + Element.scrollToLeft = SM.scrollToLeft + Element.scrollToRight = SM.scrollToRight + + -- Hoist subsystem dependency tables: created once at init time, not rebuilt + -- per Element.new() call. Staged initializers reference these directly. + Element._eventHandlerDeps = { + InputEvent = Element._InputEvent, + Context = Element._Context, + utils = Element._utils, + } + + -- Behavior registry (behavior-mode-unification). Concrete behaviors live in + -- modules/behaviors/ and auto-attach during Element.new when their + -- shouldAttach(props) predicate returns true. Element.update/draw/save-restore + -- dispatch over `element.behaviors` instead of branching on capability flags. + -- Registry order matters for onDraw layering: Themed (core Renderer:draw) must + -- run before Clickable (pressed overlay) so pressed feedback paints on top. + -- Imageable (image config) runs last. Animated (task 06) is late-attach-only. + -- Task 02 wires Clickable; task 05 Selectable; task 06 Animated; task 07 + -- Themed + Imageable; task 04 TextEditable (cursor blink + TextEditor + -- ownership + the 27 text-delegate forwarders). Scrollable is pending. + Element._behaviorRegistry = deps.behaviors or deps.clickableBehaviors or {} + -- TextEditable module reference: Element's 1-line text-delegate forwarders + -- route through `Element._TextEditable.(self, ...)` (task 04). Resolved + -- from deps (wired by FlexLove alongside the behavior registry) so minimal + -- builds without TextEditable leave forwarders inert (guarded by their + -- callers / the behavior's nil-checks). + Element._TextEditable = deps.TextEditable + -- Cached lookup of the Animated behavior instance for late-attach. Resolved + -- lazily (behaviors are optional in minimal builds) the first time an + -- animation is created on an element. + Element._animatedBehavior = nil + Element._rendererDeps = { + Color = Element._Color, + RoundedRect = Element._RoundedRect, + NinePatch = Element._NinePatch, + ImageRenderer = Element._ImageRenderer, + ImageCache = Element._ImageCache, + Theme = Element._Theme, + Blur = Element._Blur, + Transform = Element._Transform, + utils = Element._utils, + } + Element._layoutEngineDeps = { + utils = Element._utils, + Grid = Element._Grid, + Units = Element._Units, + Context = Element._Context, + ErrorHandler = Element._ErrorHandler, + } + Element._textEditorDeps = { + Context = Element._Context, + StateManager = Element._StateManager, + Color = Element._Color, + utils = Element._utils, + } + Element._scrollManagerDeps = { + utils = Element._utils, + Color = Element._Color, + } +end + +-- Module-level helper: resolve a dimensional property with CSS-like unit support (px, %, vw, vh, calc) +-- Defined once (not inside new()) to avoid per-element closure allocation. +-- Handles parsing, defensive checks, and storage in both self and self.units tables. +---@param self table Element instance +---@param raw any Raw property value (string, number, CalcObject, or nil) +---@param key string Field name on self and self.units (e.g., "width", "x") +---@param ref number Reference dimension for percentage resolution +---@param ctx {vw:number, vh:number, sx:number, sy:number} Viewport and scale context +---@param opts {offset?: number, scaleAxis?: "x"|"y", default?: number, nullable?: boolean}? +---@return number? resolved Resolved pixel value (or nil if opts.nullable and input is missing/invalid) +local function _resolveUnit(self, raw, key, ref, ctx, opts) + opts = opts or {} + if raw == nil then + if opts.nullable then + return nil + end + local default = opts.default or 0 + self[key] = (opts.offset or 0) + default + self.units[key] = { value = default, unit = "px" } + return self[key] + end + local isCalc = Element._Calc and Element._Calc.isCalc(raw) + if type(raw) == "string" or isCalc then + local value, unit = Element._Units.parse(raw) + local resolved = Element._Units.resolve(value, unit, ctx.vw, ctx.vh, ref) + if type(resolved) ~= "number" then + if opts.nullable then + return nil + end + Element._ErrorHandler:warn("Element", "LAY_003", { + issue = key .. " resolution returned non-number value", + type = type(resolved), + value = tostring(resolved), + }) + resolved = 0 + end + self.units[key] = { value = value, unit = unit } + self[key] = (opts.offset or 0) + resolved + else + local val = raw + if opts.scaleAxis and Element._Context.baseScale then + val = raw * (opts.scaleAxis == "x" and ctx.sx or ctx.sy) + end + self[key] = (opts.offset or 0) + val + self.units[key] = { value = raw, unit = "px" } + end + return self[key] +end + +-- Module-level helper: re-resolve a stored unit spec against a new viewport/parent reference. +-- Used by resize() to refresh min/max constraints declared with %/vw/vh units. +local function _refreshUnit(self, key, ref, ctx, scaleAxis) + local u = self.units[key] + if not u or u.value == nil then + return + end + if u.unit == "px" then + self[key] = Element._Context.baseScale and (u.value * (scaleAxis == "x" and ctx.sx or ctx.sy)) or u.value + return + end + local resolved = Element._Units.resolve(u.value, u.unit, ctx.vw, ctx.vh, ref) + self[key] = type(resolved) == "number" and resolved or nil +end + +-- --------------------------------------------------------------------------- +-- Consolidated warn helpers (Task 11). +-- Each duplicated "expecting X, got Y" / guard instrumentation block lived at +-- its own use site; these single-reference helpers centralize the emit so call +-- sites are thin invocations. Validation semantics (warn+fallback vs. throw) are +-- preserved exactly — instrumentation is consolidated, not deleted. +-- --------------------------------------------------------------------------- + +-- Emit a VAL_001 invalid-enum warn for a textAlign sub-field and return the +-- fallback. textAlign's schema entry is type "any" (string | table | compound), +-- so this IS the boundary validator for the 4 textAlign parse branches in the +-- props-phase visual-state helper. +local function _warnTextAlign(field, expected, got, fallback) + Element._ErrorHandler:warn("Element", "VAL_001", { + property = field, + expected = expected, + got = tostring(got), + }) + return fallback +end + +-- Emit a FLEX_00x warn for an invalid flexGrow/flexShrink/flexBasis and return +-- the fallback value. These props are SPECIAL_PROPS (warn+fallback, not throw) +-- so this is their boundary validator. +local function _warnFlexInvalid(self, code, issue, value, fallback) + Element._ErrorHandler:warn("Element", code, { + element = self.id or "unnamed", + issue = issue, + value = tostring(value), + }) + return fallback +end + +-- Emit an ELEM_010/011/012 warn for malformed declarative children entries. +local function _warnChildrenInvalid(self, code, issue, value) + local details = { element = self.id or "unnamed", issue = issue } + if value ~= nil then + details.value = tostring(value) + end + Element._ErrorHandler:warn("Element", code, details) +end + +-- Emit LAY_011 when CSS positioning props (top/right/bottom/left) are supplied +-- without absolute positioning. Called from both the no-parent and with-parent +-- branches of _initPositioning. +local function _warnCssPositioningWithoutAbsolute(self, props) + local properties = {} + if props.top then + table.insert(properties, "top") + end + if props.bottom then + table.insert(properties, "bottom") + end + if props.left then + table.insert(properties, "left") + end + if props.right then + table.insert(properties, "right") + end + Element._ErrorHandler:warn("Element", "LAY_011", { + element = self.id or "unnamed", + positioning = self._originalPositioning or "relative", + properties = table.concat(properties, ", "), + }) +end + +-- Emit an ELEM_003/004/005 guard warn for the animation/transition public API +-- (deps-missing, non-table arg, invalid duration, non-table property list). +-- All warn + fall back rather than throw. `value` nil => warn carries no details. +local function _warnAnimApi(code, value) + if value ~= nil then + Element._ErrorHandler:warn("Element", code, { value = tostring(value) }) + else + Element._ErrorHandler:warn("Element", code) + end +end + +-- Image loading + image callback firing now live in the Imageable behavior +-- (modules/behaviors/Imageable.lua) — moved out of Element per +-- behavior-mode-unification task 07. Element is decoupled from image concern; +-- the Imageable behavior enriches `element._renderer` with image config, runs +-- the deferred load pipeline, and persists `_loadedImage` across immediate-mode +-- frames. The fire-callback helper (formerly `_fireImageCallback` here) is +-- reproduced inside Imageable as `fireImageCallback`. + +-- --------------------------------------------------------------------------- +-- Data-driven prop binding (Task 03) +-- --------------------------------------------------------------------------- +-- SPECIAL_PROPS is the documented boundary of the schema-driven _applyProps +-- loop. Props listed here are bound explicitly by bindThemeAndFields / +-- bindVisualState / the staged initializers instead of the generic registry +-- loop, for one of five load-bearing reasons (none represent unfinished +-- migration — moving them into the generic loop would require extending the +-- PropertySchema DSL, which is deliberately kept small and declarative): +-- +-- 1. SUBSYSTEM ORDERING — the prop needs a subsystem constructed first. +-- Theme props (theme/themeComponent/disabled/active/...) depend on the +-- ThemeManager being alive so their defaults can be read from it; the +-- generic loop runs before subsystem creation in _attachBehaviors. +-- +-- 2. NON-LITERAL DEFAULTS — the default is not a static value the schema's +-- `default:` field can express. borderColor/backgroundColor/textColor +-- default to Color.new(...); text defaults to "" only when editable; +-- scrollable/autoGrow default from multiline. The schema only stores +-- literal defaults (pure-Lua constraint; see PropertySchema.lua header). +-- +-- 3. UNIT RESOLUTION / VIEWPORT CONTEXT — dimension props (width/height/x/y +-- /gap/padding/...) accept unit strings ("50%", "10px") or CalcObjects that +-- resolve against parent size and viewport, which a pure-Lua schema cannot +-- see. setProperty routes these via the `isDimension` flag at runtime, but +-- construction-time binding needs the sizing context from _initSizingContext. +-- +-- 4. WARN-AND-FALLBACK vs. THROW — display and the flex props validate with a +-- non-throwing warn+fallback path; the schema's `validator` field throws +-- (VAL_001) for invalid enum/range values. These need their own boundary +-- validators (_warnFlexInvalid / the display type-check). +-- +-- 5. SUBSYSTEM OWNERSHIP — overflow/scrollbar* are owned by ScrollManager, +-- selectParent/selectOption by the Select subsystem, border/cornerRadius +-- use schema normalizers but bind with a special shape (all-false→nil). +-- These props' storage is owned by their subsystem, not the element core. +-- +-- Adding a new SIMPLE prop requires only a PropertySchema entry; adding a prop +-- that needs any of the above additionally requires listing it here and binding +-- it in the matching special-handler phase. Props NOT listed here (e.g. +-- callbacks, editable, multiline, passwordMode, autoScaleText, cursorColor, +-- selectionColor, opacity, visibility, transform, imagePath/objectFit/..., +-- minTextSize/maxTextSize, alignSelf, transition) are bound generically by +-- _applyProps (defaults + normalizers + validators + onX/onXDeferred +-- auto-wiring). +local function _set(...) + local t = {} + for _, name in ipairs({ ... }) do + t[name] = true + end + return t +end + +local SPECIAL_PROPS = _set( + -- identity / tree + "id", + "parent", + "children", + -- theme-driven (ThemeManager owns these / computes defaults) + "theme", + "themeComponent", + "disabled", + "isDisabled", + "active", + "disableHighlight", + "themeStateLock", + "themeComponentDisabledStates", + "scaleCorners", + "scalingAlgorithm", + "contentAutoSizingMultiplier", + -- color defaults that require the Color module + "borderColor", + "backgroundColor", + "textColor", + -- display: non-throwing warn+fallback (unlike throwing range/enum validators) + "display", + -- text editing (validation side-effects / computed defaults) + "textWrap", + "scrollable", + "autoGrow", + "text", + "textAlign", + "textAlignHorizontal", + "textAlignVertical", + "textSize", + "fontFamily", + -- box model / dimensions (unit resolution) + "width", + "height", + "x", + "y", + "z", + "minWidth", + "maxWidth", + "minHeight", + "maxHeight", + "gap", + "top", + "right", + "bottom", + "left", + "columnGap", + "rowGap", + "padding", + "margin", + -- layout enums (positioning-mode validation + LayoutEngine config) + "positioning", + "flexDirection", + "flexWrap", + "justifyContent", + "alignItems", + "alignContent", + "justifySelf", + "gridRows", + "gridColumns", + -- flex shorthand + validated numerics (custom FLEX_xx warnings) + "flex", + "flexGrow", + "flexShrink", + "flexBasis", + -- scroll / scrollbar (ScrollManager owns these) + "overflow", + "overflowX", + "overflowY", + "scrollbarWidth", + "scrollbarColor", + "scrollbarTrackColor", + "scrollbarRadius", + "scrollbarPadding", + "scrollSpeed", + "invertScroll", + "smoothScrollEnabled", + "scrollBarStyle", + "scrollbarKnobOffset", + "hideScrollbars", + "scrollbarPlacement", + "scrollbarBalance", + "_scrollX", + "_scrollY", + -- select (Select subsystem owns these) + "selectParent", + "selectOption", + -- border / cornerRadius use schema normalizers but are bound as special handlers + "border", + "cornerRadius", + -- misc instance-only fields derived during construction + "tabIndex" +) + +--- Bind every schema-driven, side-effect-free prop onto `self` in one pass. +--- Iterates PropertySchema entries: applies defaults, normalizers, validators, +--- and auto-wires `onX` + `onXDeferred` companion pairs. Props listed in +--- SPECIAL_PROPS are skipped (they are handled explicitly in Element.new). +---@param props table Element construction props +function Element:_applyProps(props) + local schema = Element._PropertySchema + local registry = schema.all() + for name, meta in pairs(registry) do + -- Skip deferred companion entries (auto-wired by their base callback's + -- hasDeferred branch below) and SPECIAL_PROPS (handled in Element.new). + if not (name:match("Deferred$") or SPECIAL_PROPS[name]) then + local value = props[name] + if value == nil then + value = meta.default + end + if meta.normalizer then + value = meta.normalizer(value) + end + if meta.validator and value ~= nil and not meta.validator(value) then + -- Mirror the legacy throwing validateRange/validateEnum behavior: invalid + -- enum/range values error during construction (validated props: opacity, + -- imageOpacity, objectFit, imageRepeat). display is a special handler that + -- warns + falls back instead. + Element._ErrorHandler:error("Element", "VAL_001", { + property = name, + expected = meta.type, + got = tostring(value), + }) + end + local key = meta.storageKey or name + self[key] = value + -- Auto-wire deferred companion for callbacks that declare hasDeferred. + if meta.hasDeferred then + local deferredName = name .. "Deferred" + local deferredValue = props[deferredName] + self[deferredName] = deferredValue ~= nil and deferredValue or false + end + end + end + + -- Special-handler binding (behavior-mode-unification task 08 capstone): the + -- props below need side-effects, ordering relative to subsystems, non-literal + -- defaults, or unit resolution, so they cannot be bound by the generic schema + -- loop above. The two helpers below fold in the former subsystem-init phase + -- (ThemeManager creation + theme-field exposure + editable/multiline/ + -- passwordMode validation + textWrap/scrollable/autoGrow defaults + + -- selectParent/selectOption/_selectState + parent assignment) and visual-state + -- phase (border/cornerRadius/display/text/textAlign normalization). Subsystem + -- CREATION (EventHandler / TextEditor / ScrollManager / Renderer) is owned by + -- behavior onAttach hooks dispatched in _attachBehaviors; these helpers only + -- bind FIELDS that the core sizing/box/positioning phases and the behavior + -- onAttach hooks read. + bindThemeAndFields(self, props) + bindVisualState(self, props) +end + +---@param props ElementProps +---@return Element +--- Construct a new Element. Orchestrator only; real work is in the staged +--- initializers below (Task 10). No single phase exceeds ~400 LOC. +function Element.new(props) + -- Staged initializers (behavior-mode-unification task 08 capstone). The + -- orchestrator is a thin dispatcher: it runs core-data phases only + -- (construct → props → sizing → box model → positioning → finalize), then + -- attaches behaviors. The former behavioral phases (subsystem-init, visual- + -- state, image/renderer, scroll-manager) are deleted: their field-binding logic + -- folded into _applyProps and their subsystem creation logic moved into + -- behavior onAttach hooks (Clickable / TextEditable / Selectable / Themed / + -- Imageable / Scrollable). _attachBehaviors runs at the tail so + -- Selectable.onAttach can re-scan declarative children built by + -- _finalizeConstruction and so onAttach sees all element fields bound. + local self = Element:_construct(props) + self:_applyProps(props) + self:_initSizingContext(props) + self:_initBoxModel(props) + self:_initPositioning(props) + self:_finalizeConstruction(props) + self:_attachBehaviors(props) + return self +end + +--- Phase 1: metatable, schema-driven prop normalization (for special-handler +--- props), default tables (children, _deferredMethods), and ID generation. +--- Schema-driven binding (_applyProps) is invoked separately by Element.new. +function Element:_construct(props) + local instance = setmetatable({}, Element) + + -- Stash the construction props so behavior onAttach hooks (which receive only + -- the element per the locked `(element, ...)` signature) can read SPECIAL_PROPS + -- config that is NOT bound onto the element by the schema-driven _applyProps + -- loop (e.g. the scrollbar config consumed by Scrollable.onAttach). Prefixed + -- with `_` so the immediate-mode saveState public-prop scan skips it. + instance._initProps = props + + -- Apply schema-driven shape normalizers to props for the special-handler + -- props (padding/margin/flexDirection) whose downstream unit-resolution logic + -- reads from `props` directly. Generic props are bound by _applyProps below. + local schema = Element._PropertySchema + props.flexDirection = schema.get("flexDirection").normalizer(props.flexDirection) + props.padding = schema.get("padding").normalizer(props.padding) + props.margin = schema.get("margin").normalizer(props.margin) + + -- Behavior registry (Task 01 of behavior-mode-unification). Concrete + -- behaviors (Clickable, Scrollable, ...) are attached here in later tasks; + -- Element:update/draw/save-restore dispatch over this table instead of + -- branching on individual capability flags. Initially empty so existing + -- behavior is identical to pre-refactor until behaviors are wired in. + instance.children = {} + instance.behaviors = {} + instance._deferredMethods = {} + + -- Track whether ID was auto-generated (before ID assignment) + local idWasAutoGenerated = not props.id or props.id == "" + + -- Auto-generate ID if not provided (for all elements) + if idWasAutoGenerated then + instance.id = Element._StateManager.generateID(props, props.parent) + else + instance.id = props.id + end + + -- Initialize state manager ID for immediate mode (use self.id which may be auto-generated) + instance._stateId = instance.id + + -- Register with StateManager for state access (both immediate and retained modes) + if instance._stateId and instance._stateId ~= "" then + Element._StateManager.registerStateful(instance._stateId, instance) + end + return instance +end + +--- Attach behaviors whose shouldAttach(props) predicate matches this element's +--- props. Runs after prop binding + subsystem init (so Deferred flags and the +--- Select subsystem are in place) and dispatches onAttach for each match. The +--- EventHandler (Clickable) is created here rather than in the former subsystem- +--- init phase so Element never needs to know what an individual behavior does — +--- it only iterates the registry (behavior-mode-unification task 02). +function Element:_attachBehaviors(props) + local registry = Element._behaviorRegistry + if registry then + for _, behavior in ipairs(registry) do + if behavior.shouldAttach(props) then + table.insert(self.behaviors, behavior) + behavior.onAttach(self) + end + end + end +end + +--- Resolve the (lazily cached) Animated behavior instance from the registry. +-- task 09: since the behavior loop no longer excludes Animated, elements WITH +-- the behavior attached get the loop dispatch and `_dispatchAnimatedUpdate` no-ops +-- for them. +-- task 09 consolidation: `_ensureAnimatedAttached` / `_isAnimatedBehavior` +-- (dead code) were removed; Animated.ensureAttached remains the late-attach +-- entry point for callers that route through it. +function Element._resolveAnimatedBehavior() + local animated = Element._animatedBehavior + if animated == nil then + local registry = Element._behaviorRegistry + if registry then + for _, behavior in ipairs(registry) do + -- Animated exposes ensureAttached; Clickable/Themed/Imageable do not. + if type(behavior.ensureAttached) == "function" then + animated = behavior + break + end + end + end + Element._animatedBehavior = animated or false + end + return animated +end + +--- Dispatch Animated.onUpdate for an element EARLY in Element:update (before +--- the behavior loop) so animated geometry (x/y/width/height) is current for +--- Clickable hit-testing and Scrollable interaction this frame. NO-OPS for +--- elements that already have the Animated behavior attached (the loop +--- dispatches those) to avoid a double update — animation:update(dt) is not +--- idempotent within a frame. This handles the direct-assignment path +--- (`element.animation = ...` / `anim:apply`) that bypasses +--- Animated.ensureAttached; for elements with no animation the behavior's +--- onUpdate reads `element.animation` and returns. Not a behavioral capability +--- branch — iterates `element.behaviors`. (behavior-mode-unification task 09.) +function Element._dispatchAnimatedUpdate(element, dt) + if not element then + return + end + local animated = Element._resolveAnimatedBehavior() + if not animated then + return + end + -- Already attached? The behavior loop will dispatch it; bail to avoid a + -- double update (animation:update advances twice if called twice). + local behaviors = element.behaviors + if behaviors then + for i = 1, #behaviors do + if behaviors[i] == animated then + return + end + end + end + animated.onUpdate(element, dt) +end + +--- Special-handler binding helper for the props phase (behavior-mode- +--- unification task 08). Formerly the Element subsystem-init phase. Binds the +--- ThemeManager (or no-op fallback) + exposes theme fields, validates +--- editable/multiline/passwordMode combos, sets textWrap/scrollable/autoGrow +--- defaults, initializes selectParent/selectOption/_selectState fields (the +--- Select subsystem itself is initialized by Selectable.onAttach), and assigns +--- self.parent. EventHandler creation is owned by Clickable.onAttach and +--- TextEditor creation by TextEditable.onAttach — both run in _attachBehaviors at +--- the tail of Element.new, so this helper does not touch either subsystem. +bindThemeAndFields = function(self, props) + if Element._Theme then + self._themeManager = Element._Theme.Manager.new({ + theme = props.theme or Element._Context.defaultTheme, + themeComponent = props.themeComponent or nil, + disabled = props.isDisabled or props.disabled or false, + active = props.active or false, + disableHighlight = props.disableHighlight, + themeStateLock = props.themeStateLock or false, + themeComponentDisabledStates = props.themeComponentDisabledStates, + scaleCorners = props.scaleCorners, + scalingAlgorithm = props.scalingAlgorithm, + }) + else + -- Theme module absent (minimal build) — plain no-op ThemeManager + local noPadding = { top = 0, right = 0, bottom = 0, left = 0 } + self._themeManager = { + theme = nil, + themeComponent = props.themeComponent or nil, + disabled = props.isDisabled or props.disabled or false, + active = props.active or false, + themeComponentDisabledStates = {}, + scaleCorners = props.scaleCorners, + scalingAlgorithm = props.scalingAlgorithm, + validateThemeStateLock = function() end, + getState = function() + return "normal" + end, + setState = function() end, + updateState = function() + return false + end, + hasThemeComponent = function() + return false + end, + getTheme = function() + return nil + end, + getComponent = function() + return nil + end, + getStateComponent = function() + return nil + end, + getScrollbarComponent = function() + return nil + end, + getDefaultFontFamily = function() + return nil + end, + getContentAutoSizingMultiplier = function() + return nil + end, + getScaledContentPadding = function() + return noPadding + end, + getScaledContentPaddingForState = function() + return noPadding + end, + _getScaledContentPaddingForState = function() + return noPadding + end, + getStyle = function() + return nil + end, + } + end + + -- Validate themeStateLock after ThemeManager is created + if props.themeStateLock and props.themeComponent then + self._themeManager:validateThemeStateLock() + end + + -- Expose theme properties for backward compatibility + self.theme = self._themeManager.theme + self.themeComponent = self._themeManager.themeComponent + self.disabled = self._themeManager.disabled + self.active = self._themeManager.active + self._themeState = self._themeManager:getState() + + -- disableHighlight defaults to true when using themeComponent (themes handle their own visual feedback) + -- Can be explicitly overridden by setting props.disableHighlight + if props.disableHighlight ~= nil then + self.disableHighlight = props.disableHighlight + else + self.disableHighlight = self.themeComponent ~= nil + end + + -- Initialize contentAutoSizingMultiplier after theme is set + -- Priority: element props > theme component > theme default + if props.contentAutoSizingMultiplier then + self.contentAutoSizingMultiplier = props.contentAutoSizingMultiplier + else + local multiplier = self._themeManager:getContentAutoSizingMultiplier() + self.contentAutoSizingMultiplier = multiplier or { 1, 1 } + end + + -- Expose 9-patch corner scaling properties for backward compatibility + self.scaleCorners = self._themeManager.scaleCorners + self.scalingAlgorithm = self._themeManager.scalingAlgorithm + + self._blurInstance = nil + + -- editable/multiline/passwordMode are bound by _applyProps (default false). + -- Validate combinations: passwordMode disables multiline. + if self.passwordMode and self.multiline then + Element._ErrorHandler:warn("Element", "ELEM_006") + self.multiline = false + elseif self.passwordMode then + self.multiline = false + end + + self.textWrap = props.textWrap + if self.textWrap == nil then + self.textWrap = self.multiline and "word" or false + end + + self.scrollable = props.scrollable + if self.scrollable == nil then + self.scrollable = self.multiline + end + -- autoGrow defaults to true for multiline, false for single-line + if props.autoGrow ~= nil then + self.autoGrow = props.autoGrow + else + self.autoGrow = self.multiline + end + + self.selectParent = nil + self.selectOption = nil + self._selectState = nil + + if type(props.selectParent) == "table" then + self.selectParent = props.selectParent + end + + if type(props.selectOption) == "table" then + self.selectOption = props.selectOption + end + + -- TextEditor creation + immediate-mode state restore is owned by the + -- TextEditable behavior's onAttach (task 04), which runs in _attachBehaviors + -- at the tail of Element.new (after this helper has bound self.text and the + -- schema-driven callback fields). This subsystem-init helper no longer touches + -- the TextEditor. + + -- Set parent first so it's available for size calculations + self.parent = props.parent +end + +--- Special-handler binding helper for the props phase (behavior-mode- +--- unification task 08). Formerly the Element visual-state phase. Normalizes +--- border/cornerRadius/display/text/textAlign. The branch count comment below +--- refers to the 4 textAlign parse branches (table / simple-string / compound- +--- string / invalid). +bindVisualState = function(self, props) + local schema = Element._PropertySchema + ------ add non-hereditary ------ + --- self drawing --- + -- Border shape-normalization via the schema normalizer (special handler: the + -- number-vs-table-vs-nil shape and the all-false→nil collapse are intentional). + self.border = schema.get("border").normalizer(props.border) + self.borderColor = props.borderColor or Element._Color.new(0, 0, 0, 1) + self.backgroundColor = props.backgroundColor or Element._Color.new(0, 0, 0, 0) + + -- cornerRadius shape-normalization via the schema normalizer (special handler: + -- number-vs-table-vs-nil and the all-zero→nil collapse are intentional). + self.cornerRadius = schema.get("cornerRadius").normalizer(props.cornerRadius) + + -- display: default true; invalid (non-boolean) warns + falls back to true + -- (non-throwing, unlike range/enum validators). + if props.display ~= nil then + if type(props.display) == "boolean" then + self.display = props.display + else + self.display = true + Element._ErrorHandler:warn( + "Element", + "ELEM_010", + "display must be a boolean (true/false), got " .. type(props.display) .. ". Defaulting to true." + ) + end + else + self.display = true + end + + -- For editable elements, default text to empty string if not provided + if self.editable and props.text == nil then + self.text = "" + else + self.text = props.text + end + + -- Validate and set textAlign (supports simple string, compound string, or + -- table format). Enum membership is checked via PropertySchema validators + -- (the valid H/V sets live there, matching the objectFit/imageRepeat pattern); + -- compound-string parsing and warn+fallback stay here because they need + -- ErrorHandler, which the pure-Lua schema cannot depend on. + local textAlignMeta = schema.get("textAlign") + local textVAlignMeta = schema.get("textAlignVertical") + local textAlignDefault = textAlignMeta.default + local vAlignDefault = textVAlignMeta.default + + self.textAlign = props.textAlign or textAlignDefault + self.textAlignHorizontal = textAlignDefault + self.textAlignVertical = vAlignDefault + + if props.textAlign ~= nil then + if type(props.textAlign) == "table" then + -- Table format: {horizontal = "start", vertical = "center"} + local hAlign = props.textAlign.horizontal or textAlignDefault + local vAlign = props.textAlign.vertical or vAlignDefault + + if not textAlignMeta.validator(hAlign) then + hAlign = _warnTextAlign("textAlign.horizontal", "valid TextAlign value", hAlign, textAlignDefault) + end + if not textVAlignMeta.validator(vAlign) then + vAlign = _warnTextAlign("textAlign.vertical", "valid TextAlignVertical value", vAlign, vAlignDefault) + end + + self.textAlignHorizontal = hAlign + self.textAlignVertical = vAlign + elseif type(props.textAlign) == "string" then + if textAlignMeta.validator(props.textAlign) then + -- Known simple TextAlign value (backward compatible) + self.textAlignHorizontal = props.textAlign + self.textAlignVertical = vAlignDefault + else + -- Treat as compound string: "top-left" through "bottom-right" + local parts = {} + for part in props.textAlign:gmatch("[^-]+") do + table.insert(parts, part) + end + + if #parts == 2 then + local verticalMap = { top = "start", center = "center", bottom = "end" } + local horizontalMap = { left = "start", center = "center", right = "end" } + + local vStr = parts[1]:lower() + local hStr = parts[2]:lower() + local resolvedV = verticalMap[vStr] + local resolvedH = horizontalMap[hStr] + + if resolvedV and resolvedH then + self.textAlignHorizontal = resolvedH + self.textAlignVertical = resolvedV + else + _warnTextAlign( + "textAlign", + "valid compound string (e.g., 'top-left', 'center-right')", + props.textAlign, + nil + ) + end + else + _warnTextAlign("textAlign", "valid TextAlign value or compound string", props.textAlign, nil) + end + end + end + end +end + +--- Phase 5 (image + renderer init) is owned by the Themed and Imageable +--- behaviors (modules/behaviors/), attached in _attachBehaviors. Themed.onAttach +--- creates the Renderer (theme/blur config); Imageable.onAttach enriches it with +--- image config + deferred image loading. There is no longer a stub Element +--- phase for this — the behavior onAttach hooks ARE the phase +--- (behavior-mode-unification task 07/08). + +--- Phase 6a: viewport/scale context, LayoutEngine (defaults), unit specs table, +--- fontFamily, and textSize resolution. +function Element:_initSizingContext(props) + --- self positioning --- + local viewportWidth, viewportHeight = Element._Units.getViewport() + + ---- Sizing ---- + local gw, gh = love.window.getMode() + self.prevGameSize = { width = gw, height = gh } + self.autosizing = { width = false, height = false } + + -- Initialize LayoutEngine early with default values for auto-sizing calculations + -- It will be re-configured later with actual layout properties + self._layoutEngine = Element._LayoutEngine.new({ + positioning = Element._utils.enums.Positioning.RELATIVE, + flexDirection = Element._utils.enums.FlexDirection.HORIZONTAL, + flexWrap = Element._utils.enums.FlexWrap.NOWRAP, + justifyContent = Element._utils.enums.JustifyContent.FLEX_START, + alignItems = Element._utils.enums.AlignItems.STRETCH, + alignContent = Element._utils.enums.AlignContent.STRETCH, + gap = 0, + gridRows = 1, + gridColumns = 1, + + columnGap = 0, + rowGap = 0, + }, Element._layoutEngineDeps) + self._layoutEngine:initialize(self) + + -- Store unit specifications for responsive behavior + self.units = { + width = { value = nil, unit = "px" }, + height = { value = nil, unit = "px" }, + x = { value = nil, unit = "px" }, + y = { value = nil, unit = "px" }, + textSize = { value = nil, unit = "px" }, + gap = { value = nil, unit = "px" }, + flexBasis = { value = nil, unit = "auto" }, + padding = { + top = { value = nil, unit = "px" }, + right = { value = nil, unit = "px" }, + bottom = { value = nil, unit = "px" }, + left = { value = nil, unit = "px" }, + horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right + vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom + }, + margin = { + top = { value = nil, unit = "px" }, + right = { value = nil, unit = "px" }, + bottom = { value = nil, unit = "px" }, + left = { value = nil, unit = "px" }, + horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right + vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom + }, + } + + local _, scaleY = Element._Context.getScaleFactors() + + -- minTextSize/maxTextSize/autoScaleText are bound by _applyProps (autoScaleText + -- defaults true). They are needed before textSize processing below. + + -- Handle fontFamily (can be font name from theme or direct path to font file) + -- Priority: explicit props.fontFamily > parent fontFamily > theme default + if props.fontFamily then + -- Explicitly set fontFamily takes highest priority + self.fontFamily = props.fontFamily + elseif self.parent and self.parent.fontFamily then + -- Inherit from parent if parent has fontFamily set + self.fontFamily = self.parent.fontFamily + elseif props.themeComponent then + -- If using themeComponent, try to get default from theme via ThemeManager + local defaultFont = self._themeManager:getDefaultFontFamily() + self.fontFamily = defaultFont and "default" or nil + else + self.fontFamily = nil + end + + -- Handle textSize BEFORE width/height calculation (needed for auto-sizing) + if props.textSize then + if type(props.textSize) == "string" then + -- Check if it's a preset first + local presetValue, presetUnit = Element._utils.resolveTextSizePreset(props.textSize) + local value, unit + + if presetValue then + -- It's a preset, use the preset value and unit + value, unit = presetValue, presetUnit + self.units.textSize = { value = value, unit = unit } + else + -- Not a preset, parse normally + value, unit = Element._Units.parse(props.textSize) + self.units.textSize = { value = value, unit = unit } + end + + -- Resolve textSize based on unit type + if unit == "%" or unit == "vh" then + -- Percentage and vh are relative to viewport height + self.textSize = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) + elseif unit == "vw" then + -- vw is relative to viewport width + self.textSize = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) + elseif unit == "px" then + -- Pixel units + self.textSize = value + else + Element._ErrorHandler:error("Element", "ELEM_002", { + unit = unit, + }) + end + else + -- Validate pixel textSize value + if props.textSize <= 0 then + Element._ErrorHandler:error("Element", "ELEM_001", { + value = tostring(props.textSize), + }) + end + + -- Pixel textSize value + if self.autoScaleText and Element._Context.baseScale then + -- With base scaling: store original pixel value and scale relative to base resolution + self.units.textSize = { value = props.textSize, unit = "px" } + self.textSize = props.textSize * scaleY + elseif self.autoScaleText then + -- Without base scaling: convert to viewport units for auto-scaling + -- Calculate what percentage of viewport height this represents + local vhValue = (props.textSize / viewportHeight) * 100 + self.units.textSize = { value = vhValue, unit = "vh" } + self.textSize = props.textSize -- Initial size is the specified pixel value + else + -- No auto-scaling: apply base scaling if set, otherwise use raw value + self.textSize = Element._Context.baseScale and (props.textSize * scaleY) or props.textSize + self.units.textSize = { value = props.textSize, unit = "px" } + end + end + else + -- No textSize specified - use auto-scaling default + if self.autoScaleText and Element._Context.baseScale then + -- With base scaling: use 12px as default and scale + self.units.textSize = { value = 12, unit = "px" } + self.textSize = 12 * scaleY + elseif self.autoScaleText then + -- Without base scaling: default to 1.5vh (1.5% of viewport height) + self.units.textSize = { value = 1.5, unit = "vh" } + self.textSize = (1.5 / 100) * viewportHeight + else + -- No auto-scaling: use 12px with optional base scaling + self.textSize = Element._Context.baseScale and (12 * scaleY) or 12 + self.units.textSize = { value = nil, unit = "px" } + end + end +end + +--- Phase 6b: width/height/min-max/clamp, gap, flex shorthand/grow/shrink/basis, +--- 9-patch/border-box model, and padding/margin resolution + unit storage. +function Element:_initBoxModel(props) + local viewportWidth, viewportHeight = Element._Units.getViewport() + local scaleX, scaleY = Element._Context.getScaleFactors() + local _ctx = { vw = viewportWidth, vh = viewportHeight, sx = scaleX, sy = scaleY } + -- Handle width (both w and width properties, prefer w if both exist) + -- "auto" is treated as content-sized (same as omitting the property), per CSS semantics. + local widthProp = props.width + if widthProp == "auto" then + widthProp = nil + end + local tempWidth -- Temporary width for padding resolution + if widthProp then + local parentWidth = self.parent and self.parent.width or viewportWidth + tempWidth = _resolveUnit(self, widthProp, "width", parentWidth, _ctx, { scaleAxis = "x" }) + else + self.autosizing.width = true + -- Special case: if textWrap is enabled and parent exists, constrain width to parent + -- Text wrapping requires a width constraint, so use parent's content width + if props.textWrap and self.parent and self.parent.width then + tempWidth = self.parent.width + self.width = tempWidth + self.units.width = { value = 100, unit = "%" } -- Mark as parent-constrained + self.autosizing.width = false -- Not truly autosizing, constrained by parent + else + tempWidth = self:calculateAutoWidth() + self.width = tempWidth + self.units.width = { value = nil, unit = "auto" } -- Mark as auto-sized + end + end + + -- Handle height (both h and height properties, prefer h if both exist) + -- "auto" is treated as content-sized (same as omitting the property), per CSS semantics. + local heightProp = props.height + if heightProp == "auto" then + heightProp = nil + end + local tempHeight -- Temporary height for padding resolution + if heightProp then + local parentHeight = self.parent and self.parent.height or viewportHeight + tempHeight = _resolveUnit(self, heightProp, "height", parentHeight, _ctx, { scaleAxis = "y" }) + else + self.autosizing.height = true + -- Calculate auto-height without padding first + tempHeight = self:calculateAutoHeight() + self.height = tempHeight + self.units.height = { value = nil, unit = "auto" } -- Mark as auto-sized + end + + local constraintParentW = self.parent and self.parent.width or viewportWidth + local constraintParentH = self.parent and self.parent.height or viewportHeight + _resolveUnit(self, props.minWidth, "minWidth", constraintParentW, _ctx, { scaleAxis = "x", nullable = true }) + _resolveUnit(self, props.maxWidth, "maxWidth", constraintParentW, _ctx, { scaleAxis = "x", nullable = true }) + _resolveUnit(self, props.minHeight, "minHeight", constraintParentH, _ctx, { scaleAxis = "y", nullable = true }) + _resolveUnit(self, props.maxHeight, "maxHeight", constraintParentH, _ctx, { scaleAxis = "y", nullable = true }) + + if not self.autosizing.width then + self.width = Element._utils.clamp(tempWidth, self.minWidth, self.maxWidth) + tempWidth = self.width + else + self.width = Element._utils.clamp(self.width, self.minWidth, self.maxWidth) + end + if not self.autosizing.height then + self.height = Element._utils.clamp(tempHeight, self.minHeight, self.maxHeight) + tempHeight = self.height + else + self.height = Element._utils.clamp(self.height, self.minHeight, self.maxHeight) + end + + --- child positioning --- + if props.gap then + local flexDir = props.flexDirection or Element._utils.enums.FlexDirection.HORIZONTAL + local isHorizontalDir = flexDir == Element._utils.enums.FlexDirection.HORIZONTAL + or flexDir == Element._utils.enums.FlexDirection.HORIZONTAL_REVERSE + local containerSize = isHorizontalDir and self.width or self.height + _resolveUnit(self, props.gap, "gap", containerSize, _ctx) + else + self.gap = 0 + self.units.gap = { value = 0, unit = "px" } + end + + -- Handle flex shorthand property (sets flexGrow, flexShrink, flexBasis) + if props.flex ~= nil then + local grow, shrink, basis = Element._Units.parseFlexShorthand(props.flex) + + -- Only set individual properties if they weren't explicitly provided + if props.flexGrow == nil then + props.flexGrow = grow + end + if props.flexShrink == nil then + props.flexShrink = shrink + end + if props.flexBasis == nil then + props.flexBasis = basis + end + end + + -- Track whether flex-shrink was explicitly provided (directly or via flex shorthand) + self._hasExplicitFlexShrink = props.flexShrink ~= nil + + -- Handle flexGrow property + if props.flexGrow ~= nil then + if type(props.flexGrow) == "number" and props.flexGrow >= 0 then + self.flexGrow = props.flexGrow + else + self.flexGrow = _warnFlexInvalid(self, "FLEX_001", "flexGrow must be a non-negative number", props.flexGrow, 0) + end + else + self.flexGrow = 0 + end + + -- Handle flexShrink property + if props.flexShrink ~= nil then + if type(props.flexShrink) == "number" and props.flexShrink >= 0 then + self.flexShrink = props.flexShrink + else + self.flexShrink = + _warnFlexInvalid(self, "FLEX_002", "flexShrink must be a non-negative number", props.flexShrink, 1) + end + else + self.flexShrink = 1 + end + + -- Handle flexBasis property + if props.flexBasis ~= nil then + local isCalc = Element._Calc and Element._Calc.isCalc(props.flexBasis) + if props.flexBasis == "auto" then + self.flexBasis = "auto" + self.units.flexBasis = { value = nil, unit = "auto" } + elseif type(props.flexBasis) == "string" or isCalc then + local value, unit = Element._Units.parse(props.flexBasis) + self.units.flexBasis = { value = value, unit = unit } + -- Don't resolve yet - LayoutEngine will handle this during layout + self.flexBasis = props.flexBasis + elseif type(props.flexBasis) == "number" then + self.flexBasis = props.flexBasis + self.units.flexBasis = { value = props.flexBasis, unit = "px" } + else + self.flexBasis = + _warnFlexInvalid(self, "FLEX_003", "flexBasis must be a number, string, or 'auto'", props.flexBasis, "auto") + self.units.flexBasis = { value = nil, unit = "auto" } + end + else + self.flexBasis = "auto" + self.units.flexBasis = { value = nil, unit = "auto" } + end + + -- BORDER-BOX MODEL: For auto-sizing, we need to add padding to content dimensions + -- For explicit sizing, width/height already include padding (border-box) + + -- Check if we should use 9-patch content padding for auto-sizing + local use9PatchPadding = false + local ninePatchContentPadding = nil + if self._themeManager:hasThemeComponent() then + local component = self._themeManager:getComponent() + if component and component._ninePatchData and component._ninePatchData.contentPadding then + -- Only use 9-patch padding if no explicit padding was provided + if + not props.padding + or ( + not props.padding.top + and not props.padding.right + and not props.padding.bottom + and not props.padding.left + and not props.padding.horizontal + and not props.padding.vertical + ) + then + use9PatchPadding = true + ninePatchContentPadding = component._ninePatchData.contentPadding + end + end + end + + -- First, resolve padding using temporary dimensions + -- For auto-sized elements, this is content width; for explicit sizing, this is border-box width + local tempPadding + if use9PatchPadding then + -- tempWidth/tempHeight are guaranteed numbers by _resolveUnit (which warns + + -- clamps non-numbers) and calculateAutoWidth/Height; the prior defensive + -- re-check duplicated that boundary validation (Task 11). + + -- Get scaled 9-patch content padding from ThemeManager + local scaledPadding = self._themeManager:getScaledContentPadding(tempWidth, tempHeight) + if scaledPadding then + tempPadding = scaledPadding + else + -- Fallback if scaling fails + tempPadding = { + left = ninePatchContentPadding.left, + top = ninePatchContentPadding.top, + right = ninePatchContentPadding.right, + bottom = ninePatchContentPadding.bottom, + } + end + else + tempPadding = Element._Units.resolveSpacing(props.padding, self.width, self.height) + end + + -- Margin percentages are relative to parent's dimensions (CSS spec) + local parentWidth = self.parent and self.parent.width or viewportWidth + local parentHeight = self.parent and self.parent.height or viewportHeight + self.margin = Element._Units.resolveSpacing(props.margin, parentWidth, parentHeight) + + -- For auto-sized elements, add padding to get border-box dimensions + if self.autosizing.width then + self._borderBoxWidth = self.width + tempPadding.left + tempPadding.right + else + -- For explicit sizing, width is already border-box + self._borderBoxWidth = self.width + end + + if self.autosizing.height then + self._borderBoxHeight = self.height + tempPadding.top + tempPadding.bottom + else + -- For explicit sizing, height is already border-box + self._borderBoxHeight = self.height + end + + -- Set final padding + if use9PatchPadding then + -- Use 9-patch content padding + self.padding = { + left = ninePatchContentPadding.left, + top = ninePatchContentPadding.top, + right = ninePatchContentPadding.right, + bottom = ninePatchContentPadding.bottom, + } + else + -- Re-resolve padding based on final border-box dimensions (important for percentage padding) + self.padding = Element._Units.resolveSpacing(props.padding, self._borderBoxWidth, self._borderBoxHeight) + end + + -- Calculate final content dimensions by subtracting padding from border-box + self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) + self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) + + -- Re-resolve textSize presets now that width/height are set + -- (presets like "vw" need the viewport; others are resolved during constructor) + + -- Apply min/max constraints (also scaled) + local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) + local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) + + if minSize and self.textSize < minSize then + self.textSize = minSize + end + if maxSize and self.textSize > maxSize then + self.textSize = maxSize + end + + -- Protect against too-small text sizes (minimum 1px) + if self.textSize < 1 then + self.textSize = 1 -- Minimum 1px + end + + -- Store original spacing values for proper resize handling + -- Store spacing unit specs (padding + margin share identical structure) + local sides = { "top", "right", "bottom", "left" } + for _, kind in ipairs({ "padding", "margin" }) do + local src = props[kind] + if src then + for _, axis in ipairs({ "horizontal", "vertical" }) do + if src[axis] then + if type(src[axis]) == "string" then + local value, unit = Element._Units.parse(src[axis]) + self.units[kind][axis] = { value = value, unit = unit } + else + self.units[kind][axis] = { value = src[axis], unit = "px" } + end + end + end + end + for _, side in ipairs(sides) do + if src and src[side] then + if type(src[side]) == "string" then + local value, unit = Element._Units.parse(src[side]) + self.units[kind][side] = { value = value, unit = unit, explicit = true } + else + self.units[kind][side] = { value = src[side], unit = "px", explicit = true } + end + else + self.units[kind][side] = { value = self[kind][side], unit = "px", explicit = false } + end + end + end + + -- Grid properties are set later in the constructor +end + +--- Phase 7: hereditary positioning (no-parent and with-parent), flex/grid +--- container properties, select-frame adopt, and LayoutEngine config update. +function Element:_initPositioning(props) + local viewportWidth, viewportHeight = Element._Units.getViewport() + local scaleX, scaleY = Element._Context.getScaleFactors() + local _ctx = { vw = viewportWidth, vh = viewportHeight, sx = scaleX, sy = scaleY } + ------ add hereditary ------ + if props.parent == nil then + table.insert(Element._Context.topElements, self) + + -- Handle x position with units + _resolveUnit(self, props.x, "x", viewportWidth, _ctx, { scaleAxis = "x", default = 0 }) + + -- Handle y position with units + _resolveUnit(self, props.y, "y", viewportHeight, _ctx, { scaleAxis = "y", default = 0 }) + + self.z = Element._ZIndex.clamp(props.z or 0) + self.tabIndex = props.tabIndex -- nil/0 = document order, >0 = explicit order, -1 = excluded from keyboard nav + + -- Set textColor with priority: props > theme text color > black + if props.textColor then + self.textColor = props.textColor + else + -- Try to get text color from theme via ThemeManager + local themeToUse = self._themeManager:getTheme() + if themeToUse and themeToUse.colors and themeToUse.colors.text then + self.textColor = themeToUse.colors.text + else + -- Fallback to black + self.textColor = Element._Color.new(0, 0, 0, 1) + end + end + + -- Track if positioning was explicitly set + if props.positioning then + Element._utils.validateEnum(props.positioning, Element._utils.enums.Positioning, "positioning") + self.positioning = props.positioning + self._originalPositioning = props.positioning + self._explicitlyAbsolute = (props.positioning == Element._utils.enums.Positioning.ABSOLUTE) + else + self.positioning = Element._utils.enums.Positioning.RELATIVE + self._originalPositioning = nil -- No explicit positioning + self._explicitlyAbsolute = false + end + + -- Handle positioning properties for elements without parent + -- Warn if CSS positioning properties are supplied but will be ignored. + -- Relative elements honor the offsets as visual deltas (see + -- _applyRelativeOffsets); absolute elements use applyPositioningOffsets. + -- Only flex-participating children (positioning coerced to ABSOLUTE but not + -- explicitly absolute) actually drop the offsets and warrant the warning. + if + (props.top or props.bottom or props.left or props.right) + and not self._explicitlyAbsolute + and self.positioning ~= Element._utils.enums.Positioning.RELATIVE + then + _warnCssPositioningWithoutAbsolute(self, props) + end + + -- Handle top/right/bottom/left positioning with units + if props.top then + _resolveUnit(self, props.top, "top", viewportHeight, _ctx) + end + if props.right then + _resolveUnit(self, props.right, "right", viewportWidth, _ctx) + end + if props.bottom then + _resolveUnit(self, props.bottom, "bottom", viewportHeight, _ctx) + end + if props.left then + _resolveUnit(self, props.left, "left", viewportWidth, _ctx) + end + + -- position: relative offsets are applied as visual deltas in + -- LayoutEngine:layoutChildren (after the flex flow places children), so + -- they survive the addChild -> layoutChildren re-entry here. + else + -- Set positioning first and track if explicitly set + self._originalPositioning = props.positioning -- Track original intent + if props.positioning == Element._utils.enums.Positioning.ABSOLUTE then + self.positioning = Element._utils.enums.Positioning.ABSOLUTE + self._explicitlyAbsolute = true -- Explicitly set to absolute by user + elseif props.positioning == Element._utils.enums.Positioning.FLEX then + self.positioning = Element._utils.enums.Positioning.FLEX + self._explicitlyAbsolute = false + elseif props.positioning == Element._utils.enums.Positioning.GRID then + self.positioning = Element._utils.enums.Positioning.GRID + self._explicitlyAbsolute = false + else + -- Default: children in flex/grid containers participate in parent's layout + -- children in relative/absolute containers default to relative + if + self.parent.positioning == Element._utils.enums.Positioning.FLEX + or self.parent.positioning == Element._utils.enums.Positioning.GRID + then + self.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid + self._explicitlyAbsolute = false -- Participate in parent's layout + else + self.positioning = Element._utils.enums.Positioning.RELATIVE + self._explicitlyAbsolute = false -- Default for relative/absolute containers + end + end + + -- Set initial position + local parentPadding = self.parent.padding or { left = 0, top = 0 } + if self.positioning == Element._utils.enums.Positioning.ABSOLUTE then + -- Absolute positioning is relative to parent's content area (padding box) + local baseX = self.parent.x + parentPadding.left + local baseY = self.parent.y + parentPadding.top + + -- Handle x/y position with units + _resolveUnit(self, props.x, "x", self.parent.width, _ctx, { scaleAxis = "x", offset = baseX, default = 0 }) + _resolveUnit(self, props.y, "y", self.parent.height, _ctx, { scaleAxis = "y", offset = baseY, default = 0 }) + + self.z = Element._ZIndex.clamp(props.z or 0) + self.tabIndex = props.tabIndex + else + -- Children in flex containers start at parent position but will be repositioned by layoutChildren + -- Children in absolute/relative containers start at parent's content area (accounting for padding) + local baseX = self.parent.x + parentPadding.left + local baseY = self.parent.y + parentPadding.top + + -- Warn if explicit x/y is set on a child that will be positioned by flex layout + -- This position will be overridden unless the child has positioning="absolute" + local parentWillUseFlex = self.parent.positioning ~= "grid" + local childIsRelative = self.positioning ~= "absolute" or not self._explicitlyAbsolute + if parentWillUseFlex and childIsRelative and (props.x or props.y) then + Element._ErrorHandler:warn("Element", "LAY_008", { + element = self.id or "unnamed", + parent = self.parent.id or "unnamed", + properties = (props.x and props.y) and "x, y" or (props.x and "x" or "y"), + }) + end + + _resolveUnit(self, props.x, "x", self.parent.width, _ctx, { scaleAxis = "x", offset = baseX, default = 0 }) + _resolveUnit(self, props.y, "y", self.parent.height, _ctx, { scaleAxis = "y", offset = baseY, default = 0 }) + + self.z = Element._ZIndex.clamp(props.z or self.parent.z or 0) + self.tabIndex = props.tabIndex + end + + if props.textColor then + self.textColor = props.textColor + elseif self.parent.textColor then + self.textColor = self.parent.textColor + else + local themeToUse = self._themeManager:getTheme() + if themeToUse and themeToUse.colors and themeToUse.colors.text then + self.textColor = themeToUse.colors.text + else + -- Fallback to black + self.textColor = Element._Color.new(0, 0, 0, 1) + end + end + + -- Handle positioning properties BEFORE adding to parent (so they're available during layout) + -- Warn if CSS positioning properties are supplied but will be ignored. + -- Relative elements honor the offsets as visual deltas (see + -- _applyRelativeOffsets); absolute elements use applyPositioningOffsets. + -- Only flex-participating children (positioning coerced to ABSOLUTE but not + -- explicitly absolute) actually drop the offsets and warrant the warning. + if + (props.top or props.bottom or props.left or props.right) + and not self._explicitlyAbsolute + and self.positioning ~= Element._utils.enums.Positioning.RELATIVE + then + _warnCssPositioningWithoutAbsolute(self, props) + end + + -- Handle top/right/bottom/left positioning with units + if props.top then + _resolveUnit(self, props.top, "top", viewportHeight, _ctx) + end + if props.right then + _resolveUnit(self, props.right, "right", viewportWidth, _ctx) + end + if props.bottom then + _resolveUnit(self, props.bottom, "bottom", viewportHeight, _ctx) + end + if props.left then + _resolveUnit(self, props.left, "left", viewportWidth, _ctx) + end + + -- position: relative offsets are applied as visual deltas in + -- LayoutEngine:layoutChildren (after the flex flow places children), so + -- they survive the addChild -> layoutChildren re-entry here. + + props.parent:addChild(self) + end + + if self.positioning == Element._utils.enums.Positioning.FLEX then + -- Validate enum properties + if props.flexDirection then + Element._utils.validateEnum(props.flexDirection, Element._utils.enums.FlexDirection, "flexDirection") + end + if props.flexWrap then + Element._utils.validateEnum(props.flexWrap, Element._utils.enums.FlexWrap, "flexWrap") + end + if props.justifyContent then + Element._utils.validateEnum(props.justifyContent, Element._utils.enums.JustifyContent, "justifyContent") + end + if props.alignItems then + Element._utils.validateEnum(props.alignItems, Element._utils.enums.AlignItems, "alignItems") + end + if props.alignContent then + Element._utils.validateEnum(props.alignContent, Element._utils.enums.AlignContent, "alignContent") + end + if props.justifySelf then + Element._utils.validateEnum(props.justifySelf, Element._utils.enums.JustifySelf, "justifySelf") + end + + -- Warn if grid properties are set with flex positioning + if props.gridRows or props.gridColumns then + Element._ErrorHandler:warn("Element", "LAY_010", { + element = self.id or "unnamed", + positioning = "flex", + properties = "gridRows/gridColumns", + }) + end + + self.flexDirection = props.flexDirection or Element._utils.enums.FlexDirection.HORIZONTAL + self.flexWrap = props.flexWrap or Element._utils.enums.FlexWrap.NOWRAP + self.justifyContent = props.justifyContent or Element._utils.enums.JustifyContent.FLEX_START + self.alignItems = props.alignItems or Element._utils.enums.AlignItems.STRETCH + self.alignContent = props.alignContent or Element._utils.enums.AlignContent.STRETCH + self.justifySelf = props.justifySelf or Element._utils.enums.JustifySelf.AUTO + end + + -- Grid container properties + if self.positioning == Element._utils.enums.Positioning.GRID then + -- Warn if flex properties are set with grid positioning + if props.flexDirection or props.flexWrap or props.justifyContent then + Element._ErrorHandler:warn("Element", "LAY_009", { + element = self.id or "unnamed", + positioning = "grid", + properties = "flexDirection/flexWrap/justifyContent", + }) + end + + self.gridRows = props.gridRows + self.gridColumns = props.gridColumns + self.alignItems = props.alignItems or Element._utils.enums.AlignItems.STRETCH + + -- Handle columnGap and rowGap + _resolveUnit(self, props.columnGap, "columnGap", self.width, _ctx, { default = 0 }) + _resolveUnit(self, props.rowGap, "rowGap", self.height, _ctx, { default = 0 }) + end + + -- alignSelf is bound by _applyProps (default "auto"). + + -- Update the LayoutEngine with actual layout properties + -- (it was initialized early with defaults for auto-sizing calculations) + self._layoutEngine.positioning = self.positioning + if self.flexDirection then + self._layoutEngine.flexDirection = self.flexDirection + end + if self.flexWrap then + self._layoutEngine.flexWrap = self.flexWrap + end + if self.justifyContent then + self._layoutEngine.justifyContent = self.justifyContent + end + if self.alignItems then + self._layoutEngine.alignItems = self.alignItems + end + if self.alignContent then + self._layoutEngine.alignContent = self.alignContent + end + if self.gap then + self._layoutEngine.gap = self.gap + end + if self.gridRows then + self._layoutEngine.gridRows = self.gridRows + end + if self.gridColumns then + self._layoutEngine.gridColumns = self.gridColumns + end + + if self.columnGap then + self._layoutEngine.columnGap = self.columnGap + end + if self.rowGap then + self._layoutEngine.rowGap = self.rowGap + end + + -- transform is bound by _applyProps; transition is bound by _applyProps (default {}). + -- (Previously set inline here; both are now registry-driven.) +end + +--- Phase 8 (ScrollManager instantiation + immediate-mode scrollbar restore) is +--- owned by the Scrollable behavior (modules/behaviors/Scrollable.lua), attached +--- in _attachBehaviors. There is no longer an Element phase for this — the +--- behavior onAttach hook IS the phase (behavior-mode-unification task 03/08). +--- `overflow` / `overflowX` / `overflowY` are bound onto the element as plain +--- fields by bindThemeAndFields/`_applyProps` so that `Element:addChild`'s +--- scroll-container auto-size guard sees them during declarative-children +--- processing in _finalizeConstruction (which runs before _attachBehaviors); +--- Scrollable.onAttach then overwrites them with the ScrollManager's normalized +--- values, matching the legacy field-exposure order. + +--- Phase 9: immediate-mode registration, dirty flags, debug draw color, +--- declarative children tree, onCreate callback, and constructed flag. +function Element:_finalizeConstruction(props) + -- Register element in z-index tracking. registerElement is a mode-aware + -- no-op outside immediate mode, so no mode check is needed here + -- (behavior-mode-unification task 11). + Element._Context.registerElement(self) + + -- Performance optimization: dirty flags for layout tracking + -- These flags help skip unnecessary layout recalculations + self._dirty = false -- Element properties have changed, needs layout + self._childrenDirty = false -- Children have changed, needs layout + + -- Debug draw: assign a deterministic color for element boundary visualization + -- Uses a hash of the element ID to produce a stable hue, so colors don't flash each frame + local function hashStringToHue(str) + local hash = 5381 + for i = 1, #str do + hash = ((hash * 33) + string.byte(str, i)) % 360 + end + return hash + end + local hue = hashStringToHue(self.id or tostring(self)) + local function hslToRgb(h) + local s, l = 0.9, 0.55 + local c = (1 - math.abs(2 * l - 1)) * s + local x = c * (1 - math.abs((h / 60) % 2 - 1)) + local m = l - c / 2 + local r, g, b + if h < 60 then + r, g, b = c, x, 0 + elseif h < 120 then + r, g, b = x, c, 0 + elseif h < 180 then + r, g, b = 0, c, x + elseif h < 240 then + r, g, b = 0, x, c + elseif h < 300 then + r, g, b = x, 0, c + else + r, g, b = c, 0, x + end + return r + m, g + m, b + m + end + local dr, dg, db = hslToRgb(hue) + self._debugColor = { dr, dg, db } + + -- Process declarative children prop: build child tree from property tables + -- Placed after all self properties are initialized so children can safely access parent state + if props.children then + if type(props.children) ~= "table" then + _warnChildrenInvalid(self, "ELEM_010", "children must be a table array", props.children) + else + for i = 1, #props.children do + local childProps = props.children[i] + if childProps == nil then + _warnChildrenInvalid(self, "ELEM_011", "nil entry in children array, skipping", nil) + elseif type(childProps) ~= "table" then + _warnChildrenInvalid(self, "ELEM_012", "non-table entry in children array, skipping", childProps) + else + local childCopy = {} + for k, v in pairs(childProps) do + childCopy[k] = v + end + childCopy.parent = self + local child = Element.new(childCopy) + + -- Set up state management for declarative children so mutations + -- made in event callbacks persist across frames. Mode-aware via + -- StateManager.isImmediateMode (behavior-mode-unification task 11): + -- this whole block is immediate-mode-only frame bookkeeping. + if Element._StateManager.isImmediateMode() then + if not child.id or child.id == "" then + child.id = Element._StateManager.generateID(childCopy, self) + end + local childState = Element._StateManager.getState(child.id, {}) + Element._StateManager.markStateUsed(child.id) + child:restoreState(childState) + child._stateId = child.id + + -- Restore theme state from event handler state + if child.themeComponent then + local eventState = childState.eventHandler or {} + if child.disabled or eventState.disabled then + child._themeState = "disabled" + elseif child.active or eventState.active then + child._themeState = "active" + elseif eventState._pressed and next(eventState._pressed) then + child._themeState = "pressed" + elseif eventState._hovered then + child._themeState = "hover" + else + child._themeState = "normal" + end + end + + -- Add to current frame elements for saveState tracking + if Element._Context._currentFrameElements then + table.insert(Element._Context._currentFrameElements, child) + end + end + end + end + end + end + + -- Fire onCreate callback if provided + if self.onCreate then + if self.onCreateDeferred then + local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] + if FlexLove and FlexLove.deferCallback then + FlexLove.deferCallback(function() + self.onCreate(self, props) + end) + else + self.onCreate(self, props) + end + else + self.onCreate(self, props) + end + end + + -- Mark element as fully constructed. + -- NOTE: no longer gates an __newindex dimension warning (removed — see comment + -- at top of file). Retained lazily in case future write-interception is added. + self._constructed = true +end + +--- Retrieve the element's screen-space rectangle for collision detection and positioning calculations +--- Use this for custom layout logic, tooltips, or detecting overlaps between elements +---@return { x:number, y:number, width:number, height:number } +function Element:getBounds() + return { x = self.x, y = self.y, width = self:getBorderBoxWidth(), height = self:getBorderBoxHeight() } +end + +--- Test if a screen coordinate falls within the element's clickable area +--- Use this for custom hit detection or determining which element the mouse is over +--- @param x number +--- @param y number +--- @return boolean +function Element:contains(x, y) + local bounds = self:getBounds() + return bounds.x <= x and bounds.y <= y and bounds.x + bounds.width >= x and bounds.y + bounds.height >= y +end + +--- Get the element's total width including padding for layout calculations +--- Use this when you need the full visual width rather than just content width +---@return number +function Element:getBorderBoxWidth() + return self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) +end + +--- Get the element's total height including padding for layout calculations +--- Use this when you need the full visual height rather than just content height +---@return number +function Element:getBorderBoxHeight() + return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) +end + +--- Get computed box dimensions (content area position and size) +--- Returns the position and size of the content area (inside padding) +---@return {x: number, y: number, width: number, height: number} +function Element:getComputedBox() + return { + x = self.x + self.padding.left, + y = self.y + self.padding.top, + width = self.width, + height = self.height, + } +end + +--- Mark this element and its ancestors as dirty, requiring layout recalculation +--- Call this when element properties change that affect layout +function Element:invalidateLayout() + self._dirty = true + + -- Invalidate dimension caches + self._borderBoxWidthCache = nil + self._borderBoxHeightCache = nil + + -- Mark parent as having dirty children + if self.parent then + self.parent._childrenDirty = true + -- Propagate up the tree (parents need to know their descendants changed) + local ancestor = self.parent + while ancestor do + ancestor._childrenDirty = true + ancestor = ancestor.parent + end + end +end + +-- Scroll / scrollbar methods (_syncScrollManagerState, _detectOverflow, setScrollPosition, +-- _calculateScrollbarDimensions, _getScrollbarAtPosition, _handleScrollbarPress/Drag/Release, +-- _handleWheelScroll, getScrollPosition, getMaxScroll, getScrollPercentage, hasOverflow, +-- getContentSize, scrollBy, scrollToTop) are bound to ScrollManager in Element.init. ScrollManager +-- owns all scrollbar interaction logic; Element retains only 1-line delegates (see ScrollManager.lua). + +--- Mark a method for deferred retry during the update phase. +--- Methods that depend on layout calculations (e.g., scroll, sizing) +--- can defer themselves when preconditions aren't met. They'll be +--- retried automatically each frame in update() until they succeed. +---@param methodName string The method name to retry +---@param ... any? Arguments to forward on retry +function Element:_deferMethod(methodName, ...) + if type(self[methodName]) ~= "function" then + Element._ErrorHandler:warn("Element", "CORE_005", { + element = self.id, + method = tostring(methodName), + }) + return + end + + if #self._deferredMethods >= MAX_DEFERRED_METHODS then + Element._ErrorHandler:warn("Element", "CORE_004", { + element = self.id, + method = tostring(methodName), + retryCount = MAX_DEFERRED_METHODS, + }) + return + end + + local argc = select("#", ...) + local args = {} + for i = 1, argc do + local val = select(i, ...) + args[i] = val == nil and _DEFERRED_NIL or val + end + table.insert(self._deferredMethods, { + methodName = methodName, + args = args, + argc = argc, + retryCount = 0, + }) +end + +-- Deferred image loading is owned by the Imageable behavior +-- (modules/behaviors/Imageable.lua). Imageable.onAttach installs an instance +-- closure on `element._loadImage` and defers it via _deferMethod; the deferred- +-- method dispatcher (which resolves `self[methodName]`) invokes that closure. +-- Element no longer owns the load logic itself and has zero image-branch logic. +-- (behavior-mode-unification task 07) + +-- scrollToBottom / scrollToLeft / scrollToRight are bound to ScrollManager in Element.init. + +--- Get the current state's scaled content padding +--- Returns the contentPadding for the current theme state, scaled to the element's size +---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding +function Element:getScaledContentPadding() + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + return self._themeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight) +end + +--- Get draw-time content offset from state-specific theme padding changes +---@return number offsetX, number offsetY +function Element:getContentStateOffset() + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + + local currentPadding = self:getScaledContentPadding() + local basePadding = self._themeManager:_getScaledContentPaddingForState("normal", borderBoxWidth, borderBoxHeight) + + if not currentPadding or not basePadding then + return 0, 0 + end + + local offsetX = currentPadding.left - basePadding.left + local offsetY = currentPadding.top - basePadding.top + + if math.abs(offsetX) < 0.001 then + offsetX = 0 + end + if math.abs(offsetY) < 0.001 then + offsetY = 0 + end + + return offsetX, offsetY +end + +--- Get or create blur instance for this element +---@return table? -- Blur instance or nil if no blur configured +function Element:getBlurInstance() + -- Determine quality from contentBlur or backdropBlur + local quality = 5 -- Default quality + if self.contentBlur and self.contentBlur.quality then + quality = self.contentBlur.quality + elseif self.backdropBlur and self.backdropBlur.quality then + quality = self.backdropBlur.quality + end + + -- Create blur instance if needed + if not self._blurInstance or self._blurInstance.quality ~= quality then + self._blurInstance = Element._Blur.new({ quality = quality }) + end + + return self._blurInstance +end + +--- Get available content width for children (accounting for 9-patch content padding) +--- This is the width that children should use when calculating percentage widths +---@return number +function Element:getAvailableContentWidth() + local availableWidth = self.width + + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + -- Check if the element is using the scaled 9-patch contentPadding as its padding + -- Allow small floating point differences (within 0.1 pixels) + local usingContentPaddingAsPadding = ( + math.abs(self.padding.left - scaledContentPadding.left) < 0.1 + and math.abs(self.padding.right - scaledContentPadding.right) < 0.1 + ) + + if not usingContentPaddingAsPadding then + -- Element has explicit padding different from contentPadding + -- Subtract scaled contentPadding to get the area children should use + availableWidth = availableWidth - scaledContentPadding.left - scaledContentPadding.right + end + end + + return math.max(0, availableWidth) +end + +--- Get available content height for children (accounting for 9-patch content padding) +--- This is the height that children should use when calculating percentage heights +---@return number +function Element:getAvailableContentHeight() + local availableHeight = self.height + + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + -- Check if the element is using the scaled 9-patch contentPadding as its padding + -- Allow small floating point differences (within 0.1 pixels) + local usingContentPaddingAsPadding = ( + math.abs(self.padding.top - scaledContentPadding.top) < 0.1 + and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1 + ) + + if not usingContentPaddingAsPadding then + -- Element has explicit padding different from contentPadding + -- Subtract scaled contentPadding to get the area children should use + availableHeight = availableHeight - scaledContentPadding.top - scaledContentPadding.bottom + end + end + + return math.max(0, availableHeight) +end + +function Element:openSelect() + Element._Select.openSelect(self) +end + +function Element:closeSelect() + Element._Select.closeSelect(self) +end + +function Element:toggleSelect() + Element._Select.toggleSelect(self) +end + +---@return boolean +function Element:isSelectOpen() + return Element._Select.isSelectOpen(self) +end + +---@return any +function Element:getSelectValue() + return Element._Select.getSelectValue(self) +end + +---@return string? +function Element:getSelectLabel() + return Element._Select.getSelectLabel(self) +end + +---@return boolean +function Element:isSelectedSelectOption() + return Element._Select.isSelectedOption(self) +end + +---@param value any +---@param optionElement Element? +function Element:setSelectValue(value, optionElement) + Element._Select.setSelectValue(self, value, optionElement) +end + +function Element:_handleSelectRelease() + Element._Select.handleRelease(self) +end + +--- Dynamically insert a child element into the hierarchy for runtime UI construction +--- Use this to build interfaces procedurally or add elements based on application state +---@param child Element +function Element:addChild(child) + if self._managedSelectFrame and child.selectOption and self._managedSelectOwner then + child._selectParentHint = self._managedSelectOwner + end + + child.parent = self + + -- Re-evaluate positioning now that we have a parent + -- If child was created without explicit positioning, inherit from parent + if child._originalPositioning == nil then + -- No explicit positioning was set during construction + if + self.positioning == Element._utils.enums.Positioning.FLEX + or self.positioning == Element._utils.enums.Positioning.GRID + then + child.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid + child._explicitlyAbsolute = false -- Participate in parent's layout + else + child.positioning = Element._utils.enums.Positioning.RELATIVE + child._explicitlyAbsolute = false -- Default for relative/absolute containers + end + end + -- If child._originalPositioning is set, it means explicit positioning was provided + -- and _explicitlyAbsolute was already set correctly during construction + + table.insert(self.children, child) + Element._Select.registerWithSelectParent(child) + + -- Mark parent as having dirty children to trigger layout recalculation + self._childrenDirty = true + + -- Only recalculate auto-sizing if the child participates in layout + -- (CSS: absolutely positioned children don't affect parent auto-sizing) + if not child._explicitlyAbsolute then + local sizeChanged = false + + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + local isScrollContainer = overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" + + if self.autosizing.height and not isScrollContainer then + local oldHeight = self.height + local contentHeight = self:calculateAutoHeight() + -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content + self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom + self.height = contentHeight + if oldHeight ~= self.height then + sizeChanged = true + end + end + if self.autosizing.width and not isScrollContainer then + local oldWidth = self.width + local contentWidth = self:calculateAutoWidth() + -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content + self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right + self.width = contentWidth + if oldWidth ~= self.width then + sizeChanged = true + end + end + + -- Propagate size change up the tree + if sizeChanged and self.parent and (self.parent.autosizing.width or self.parent.autosizing.height) then + -- Trigger parent to recalculate its size by re-adding this child's contribution + -- This ensures grandparents are notified of size changes + if self.parent.autosizing.height then + local contentHeight = self.parent:calculateAutoHeight() + self.parent._borderBoxHeight = contentHeight + self.parent.padding.top + self.parent.padding.bottom + self.parent.height = contentHeight + end + if self.parent.autosizing.width then + local contentWidth = self.parent:calculateAutoWidth() + self.parent._borderBoxWidth = contentWidth + self.parent.padding.left + self.parent.padding.right + self.parent.width = contentWidth + end + end + end + + -- Layout is deferred to FlexLove.endFrame in immediate mode (all elements + -- for the frame must exist before layout). shouldLayout() encapsulates the + -- mode check (behavior-mode-unification task 11). + if Element._StateManager.shouldLayout() then + self:layoutChildren() + end + + if + self._selectState + and self._selectState.selectFrame + and child.selectOption + and child ~= self._selectState.selectFrame + then + Element._Select.attachOptionToManagedFrame(child) + end +end + +--- Remove a child element from the hierarchy to dynamically update UIs +--- Use this to delete elements when they're no longer needed or respond to user actions +---@param child Element +function Element:removeChild(child) + for i, c in ipairs(self.children) do + if c == child then + Element._Select.handleChildRemoved(self, child) + Element._Select.unregisterFromSelectParent(child) + table.remove(self.children, i) + child.parent = nil + + -- Recalculate auto-sizing if needed + if self.autosizing.width or self.autosizing.height then + if self.autosizing.width then + local contentWidth = self:calculateAutoWidth() + self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right + self.width = contentWidth + end + if self.autosizing.height then + local contentHeight = self:calculateAutoHeight() + self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom + self.height = contentHeight + end + end + + -- Re-layout children after removal (deferred in immediate mode). + if Element._StateManager.shouldLayout() then + self:layoutChildren() + end + + break + end + end +end + +--- Reparent this element to a new parent, properly detaching from the current location +--- and inserting into the new parent's children hierarchy with correct layout and alignment. +--- If newParent is nil, the element becomes a top-level element. +--- Works whether the element was originally created with or without a parent. +---@param newParent Element? +function Element:setParent(newParent) + local expectedManagedSelectParent = nil + if self._managedSelectFrame and self._managedSelectOwner then + expectedManagedSelectParent = self._managedSelectOwner + if self._managedSelectOwner._selectState and self._managedSelectOwner._selectState.selectAnchor then + expectedManagedSelectParent = self._managedSelectOwner._selectState.selectAnchor + end + end + + if self._managedSelectFrame and self._managedSelectOwner and newParent ~= expectedManagedSelectParent then + Element._Select.warnSelectFrame(self._managedSelectOwner, "ELEM_009", { + element = self._managedSelectOwner.id, + frame = self.id, + expectedParent = expectedManagedSelectParent and expectedManagedSelectParent.id or nil, + actualParent = newParent and newParent.id or nil, + }) + end + + if self.parent == newParent then + return -- Already at this parent, no-op + end + + -- Remove from current location + if self.parent then + -- removeChild sets child.parent = nil and recalculates parent layout + self.parent:removeChild(self) + else + -- Remove from topElements (element was created without a parent) + for i, elem in ipairs(Element._Context.topElements) do + if elem == self then + table.remove(Element._Context.topElements, i) + break + end + end + self.parent = nil + end + + if newParent then + -- addChild handles: setting self.parent, re-evaluating positioning, + -- inserting into children, marking dirty, auto-sizing, and layoutChildren + newParent:addChild(self) + else + -- Become a top-level element + self.parent = nil + self.x = self.x or 0 + self.y = self.y or 0 + self.z = Element._ZIndex.clamp(self.z or 0) + table.insert(Element._Context.topElements, self) + end +end + +--- Delete all child elements at once for resetting containers or clearing lists +--- Use this to efficiently empty containers when rebuilding UI from scratch +function Element:clearChildren() + -- Clear parent references for all children + for _, child in ipairs(self.children) do + Element._Select.unregisterFromSelectParent(child) + child.parent = nil + end + + -- Clear the children table + self.children = {} + + -- Recalculate auto-sizing if needed + if self.autosizing.width or self.autosizing.height then + if self.autosizing.width then + local contentWidth = self:calculateAutoWidth() + self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right + self.width = contentWidth + end + if self.autosizing.height then + local contentHeight = self:calculateAutoHeight() + self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom + self.height = contentHeight + end + end + + -- Re-layout (though there are no children now; deferred in immediate mode). + if Element._StateManager.shouldLayout() then + self:layoutChildren() + end +end + +--- Get the number of children this element has +---@return number +function Element:getChildCount() + return #self.children +end + +--- Apply positioning offsets (top, right, bottom, left) to an element +-- @param element The element to apply offsets to +function Element:applyPositioningOffsets(element) + -- Delegate to LayoutEngine + self._layoutEngine:applyPositioningOffsets(element) +end + +function Element:layoutChildren() + -- Check performance warnings (only on root elements to avoid spam) + if not self.parent then + self:_checkPerformanceWarnings() + end + + -- Catch stale bare dimension writes that bypassed setProperty (e.g. + -- `element.width = "42%"` stores a raw string). Lua __newindex cannot intercept + -- these at write time (the keys exist post-construction), so we validate lazily + -- here, once per element per property, only when a reflow is already pending. + if self._dirty then + self:_checkDimensionTypes() + end + + -- Delegate layout to LayoutEngine + self._layoutEngine:layoutChildren() +end + +--- Warn once per stale dimension property that holds a non-number value, which +--- indicates a bare write (e.g. `element.width = "42%"`) bypassed setProperty. +--- Bare dimension writes neither resolve unit strings nor invalidate layout; +--- the element renders with the wrong size until :setProperty() is used. +function Element:_checkDimensionTypes() + if not self._dimWarned then + self._dimWarned = {} + end + for _, prop in ipairs({ "width", "height", "x", "y" }) do + local v = self[prop] + if v ~= nil and type(v) ~= "number" then + if not self._dimWarned[prop] then + self._dimWarned[prop] = true + Element._ErrorHandler:warn("Element", "ELM_001", { + property = prop, + message = string.format( + 'element.%s holds a non-number value (%s); a bare write bypassed setProperty and was not resolved to pixels. Use element:setProperty("%s", value) instead.', + prop, + type(v), + prop + ), + }) + end + end + end +end + +--- Warn about percentage sizing with auto-sizing parent +---@param child Element +---@param axis string "width" or "height" +function Element:_warnIfPercentageWithAutoSizing(child, axis) + if self._managedSelectFrame then + return + end + Element._ErrorHandler:warn("LayoutEngine", "LAY_004", { + child = child.id or "unnamed", + issue = "percentage " .. axis .. " with parent auto-sizing", + }) +end + +--- Whether element needs cross-axis percentage dimension syncing +--- Managed select frames sync percentage children with container dimensions +---@return boolean +function Element:_shouldSyncPercentageDimensions() + return self._managedSelectFrame == true +end + +--- Adjust cross-axis percentage width for managed select minimum +---@param child Element +---@param newBorderBoxWidth number +---@return number +function Element:_adjustCrossAxisPercentageWidth(child, newBorderBoxWidth) + if self._managedSelectFrame and self.autosizing and self.autosizing.width then + local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right + return math.max(newBorderBoxWidth, intrinsicBorderBoxWidth) + end + return newBorderBoxWidth +end + +--- Layout-path delegate: adjust child border-box width for a managed-select frame. +--- Owned by Select; routed through here so the layout path stays free of dropdown details. +---@param child Element +---@param childBorderBoxWidth number +---@return number +function Element:_adjustAutoWidthChildBorderBoxForManagedSelect(child, childBorderBoxWidth) + return Element._Select.adjustAutoWidthChild(self, child, childBorderBoxWidth) +end + +--- Destroy element and its children +function Element:destroy() + -- Remove from global elements list + for i, win in ipairs(Element._Context.topElements) do + if win == self then + table.remove(Element._Context.topElements, i) + break + end + end + + if self.parent then + for i, child in ipairs(self.parent.children) do + if child == self then + Element._Select.unregisterFromSelectParent(self) + table.remove(self.parent.children, i) + break + end + end + self.parent = nil + end + + -- Destroy all children + for _, child in ipairs(self.children) do + child:destroy() + end + + -- Clear children table + self.children = {} + + -- Clear parent reference + if self.parent then + self.parent = nil + end + + -- Clear animation reference + self.animation = nil + + -- Clear onEvent to prevent closure leaks + self.onEvent = nil + + -- Clear touch callbacks to prevent closure leaks + self.onTouchEvent = nil + self.onGesture = nil + + Element._Select.cleanupDestroy(self) +end + +--- Retry deferred methods queued via `_deferMethod` during this frame. Each +--- pending entry is invoked through pcall; failures are reported to the +--- ErrorHandler instead of aborting the frame, and entries that re-defer are +--- retried next frame with an incremented retry count up to MAX_DEFER_RETRIES. +--- Extracted from the tail of Element:update so update stays a thin +--- behavior-dispatch orchestrator (behavior-mode-unification task 09). +function Element:_processDeferredMethods() + if #self._deferredMethods == 0 then + return + end + local pending = self._deferredMethods + self._deferredMethods = {} + for _, entry in ipairs(pending) do + if entry.retryCount >= MAX_DEFER_RETRIES then + Element._ErrorHandler:warn("Element", "CORE_004", { + element = self.id, + method = tostring(entry.methodName), + retryCount = entry.retryCount, + }) + else + local beforeCount = #self._deferredMethods + local callArgs = {} + for j = 1, entry.argc do + local val = entry.args[j] + if val == _DEFERRED_NIL then + callArgs[j] = nil + else + callArgs[j] = val + end + end + local success, err = pcall(function() + self[entry.methodName](self, unpack(callArgs, 1, entry.argc)) + end) + if not success then + Element._ErrorHandler:warn("Element", "CORE_002", { + element = self.id, + method = tostring(entry.methodName), + error = tostring(err), + }) + end + -- Propagate retry count to any new deferred entry for the same method + for i = beforeCount + 1, #self._deferredMethods do + if self._deferredMethods[i].methodName == entry.methodName then + self._deferredMethods[i].retryCount = entry.retryCount + 1 + end + end + end + end +end + +--- Draw element and its children +function Element:draw(backdropCanvas) + -- Early exit if element is display:none or invisible (optimization) + if self.display == false or self.opacity <= 0 or self.visibility == "hidden" then + return + end + + -- Background behaviors (drawLayer ~= "overlay") render BEFORE children in + -- registry order: Themed (core Renderer:draw), Clickable (pressed overlay), + -- ... Overlay behaviors (Scrollable scrollbars) render AFTER children below. + local drawCtx = { backdropCanvas = backdropCanvas } + local behaviors = self.behaviors + for i = 1, #behaviors do + local b = behaviors[i] + if b.drawLayer ~= "overlay" then + b.onDraw(self, drawCtx) + end + end + + -- Core child hierarchy rendering (clipping, sorting, scroll offset, blur). + -- Stays in Element: it is structural, not a per-capability behavior. + self:_drawChildren(backdropCanvas) + + -- Overlay behaviors (drawLayer == "overlay") render AFTER children so they + -- paint on top, e.g. Scrollable's scrollbars (behavior-mode-unification 09). + for i = 1, #behaviors do + local b = behaviors[i] + if b.drawLayer == "overlay" then + b.onDraw(self, drawCtx) + end + end +end + +--- Core child-drawing pipeline extracted from Element:draw so the draw entry +--- point stays a thin behavior-dispatch orchestrator (task 09). Owns z-sort, +--- rounded-corner/overflow clipping (stencil > scissor), scroll/content offset, +--- optional content-blur application, and recursive child:draw. Not a behavior +--- — this is structural hierarchy rendering shared by every element. +function Element:_drawChildren(backdropCanvas) + local sortedChildren = {} + for _, child in ipairs(self.children) do + table.insert(sortedChildren, child) + end + if #sortedChildren == 0 then + return + end + table.sort(sortedChildren, function(a, b) + return a.z < b.z + end) + + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + + -- Check if we need to clip children to rounded corners + local hasRoundedCorners = false + if self.cornerRadius then + if type(self.cornerRadius) == "number" then + hasRoundedCorners = self.cornerRadius > 0 + else + hasRoundedCorners = self.cornerRadius.topLeft > 0 + or self.cornerRadius.topRight > 0 + or self.cornerRadius.bottomLeft > 0 + or self.cornerRadius.bottomRight > 0 + end + end + + -- Render the (possibly clipped + offset) child layer, applying content blur + -- when configured. The inner closure performs clipping/offset/draw; blur + -- wraps it in a region pass when a blur instance is available. + local function renderChildLayer() + local contentOffsetX, contentOffsetY = self:getContentStateOffset() + + -- Determine overflow behavior per axis (matches HTML/CSS behavior) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible") + and (overflowX ~= nil or overflowY ~= nil) + + -- Apply scroll/content offset after clipping is set + local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0) + local hasContentOffset = contentOffsetX ~= 0 or contentOffsetY ~= 0 + local hasOffset = hasScrollOffset or hasContentOffset + + -- Set up clipping: rounded-corners (stencil) > overflow (scissor) > none + local clipMode = "none" + if hasRoundedCorners then + local roundedBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local roundedBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + local stencilFunc = + Element._RoundedRect.stencilFunction(self.x, self.y, roundedBoxWidth, roundedBoxHeight, self.cornerRadius) + local currentCanvas = love.graphics.getCanvas() + love.graphics.setCanvas() + love.graphics.stencil(stencilFunc, "replace", 1) + love.graphics.setCanvas(currentCanvas) + love.graphics.setStencilTest("greater", 0) + clipMode = "stencil" + elseif needsOverflowClipping then + love.graphics.setScissor(self.x + self.padding.left, self.y + self.padding.top, self.width, self.height) + clipMode = "scissor" + end + + if hasOffset then + love.graphics.push() + love.graphics.translate( + (hasScrollOffset and -self._scrollX or 0) + contentOffsetX, + (hasScrollOffset and -self._scrollY or 0) + contentOffsetY + ) + end + + for _, child in ipairs(sortedChildren) do + child:draw(backdropCanvas) + end + + if hasOffset then + love.graphics.pop() + end + + -- Restore clipping state + if clipMode == "stencil" then + love.graphics.setStencilTest() + elseif clipMode == "scissor" then + love.graphics.setScissor() + end + end + + -- Apply content blur if configured + if self.contentBlur and self.contentBlur.radius > 0 then + local blurInstance = self:getBlurInstance() + if blurInstance then + Element._Blur.applyToRegion( + blurInstance, + self.contentBlur.radius, + self.x, + self.y, + borderBoxWidth, + borderBoxHeight, + renderChildLayer + ) + else + renderChildLayer() + end + else + renderChildLayer() + end +end + +--- Update element (propagate to children) +---@param dt number +function Element:update(dt) + if self.display == false then + return + end + if not self.parent then + self:_trackActiveAnimations() + end + for _, child in ipairs(self.children) do + child:update(dt) + end + -- Advance direct-assignment animations before the loop so geometry is current + -- for hit-testing; no-ops when the Animated behavior is already attached. + Element._dispatchAnimatedUpdate(self, dt) + for _, b in ipairs(self.behaviors) do + b.onUpdate(self, dt) + end + self:_processDeferredMethods() +end + +--- Handle a touch event directly (for external touch routing) +--- Invokes both onEvent and onTouchEvent callbacks if set +---@param touchEvent InputEvent The touch event to handle +function Element:handleTouchEvent(touchEvent) + if not self.touchEnabled or self.disabled then + return + end + if self._eventHandler then + self._eventHandler:_invokeCallback(self, touchEvent) + self._eventHandler:_invokeTouchCallback(self, touchEvent) + end +end + +--- Handle a gesture event (from GestureRecognizer or external routing) +---@param gesture table The gesture data (type, position, velocity, etc.) +function Element:handleGesture(gesture) + if not self.touchEnabled or self.disabled then + return + end + if self._eventHandler then + self._eventHandler:_invokeGestureCallback(self, gesture) + end +end + +--- Get active touches currently tracked on this element +---@return table Active touches keyed by touch ID +function Element:getTouches() + if self._eventHandler then + return self._eventHandler:getActiveTouches() + end + return {} +end + +---@param newViewportWidth number +---@param newViewportHeight number +function Element:recalculateUnits(newViewportWidth, newViewportHeight) + self._layoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) +end + +--- Resize element and its children based on game window size change +---@param newGameWidth number +---@param newGameHeight number +function Element:resize(newGameWidth, newGameHeight) + self:recalculateUnits(newGameWidth, newGameHeight) + self:_refreshSizeConstraints(newGameWidth, newGameHeight) + + -- For non-auto-sized elements with viewport/percentage units, update content dimensions from border-box + if not self.autosizing.width and self._borderBoxWidth and self.units.width.unit ~= "px" then + self._borderBoxWidth = Element._utils.clamp(self._borderBoxWidth, self.minWidth, self.maxWidth) + self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) + end + if not self.autosizing.height and self._borderBoxHeight and self.units.height.unit ~= "px" then + self._borderBoxHeight = Element._utils.clamp(self._borderBoxHeight, self.minHeight, self.maxHeight) + self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) + end + + -- Update children + for _, child in ipairs(self.children) do + child:resize(newGameWidth, newGameHeight) + end + + -- Recalculate auto-sized dimensions after children are resized + if self.autosizing.width then + local contentWidth = self:calculateAutoWidth() + -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content + self._borderBoxWidth = + Element._utils.clamp(contentWidth + self.padding.left + self.padding.right, self.minWidth, self.maxWidth) + self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) + -- CONTENT-LEVEL CLAMP: CSS min-width/max-width also bound the content width. + -- Subtracting padding from the clamped border-box can drop the content width + -- below minWidth (e.g. minWidth=200, horizontal padding=100 => content=100), + -- so re-clamp the content dimension with the shared size-clamping utility. + self.width = Element._utils.clampSize(self.width, self.minWidth, self.maxWidth) + end + if self.autosizing.height then + local contentHeight = self:calculateAutoHeight() + -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content + self._borderBoxHeight = + Element._utils.clamp(contentHeight + self.padding.top + self.padding.bottom, self.minHeight, self.maxHeight) + self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) + -- CONTENT-LEVEL CLAMP: CSS min-height/max-height also bound the content height. + -- Subtracting padding from the clamped border-box can drop the content height + -- below minHeight (e.g. minHeight=200, vertical padding=100 => content=100), + -- so re-clamp the content dimension with the shared size-clamping utility. + self.height = Element._utils.clampSize(self.height, self.minHeight, self.maxHeight) + end + + -- Re-resolve textSize if it uses viewport-relative units after dimensions are finalized + + self:layoutChildren() + self.prevGameSize.width = newGameWidth + self.prevGameSize.height = newGameHeight +end + +function Element:_refreshSizeConstraints(newViewportWidth, newViewportHeight) + local scaleX, scaleY = Element._Context.getScaleFactors() + local ctx = { vw = newViewportWidth, vh = newViewportHeight, sx = scaleX, sy = scaleY } + local parentW = self.parent and self.parent.width or newViewportWidth + local parentH = self.parent and self.parent.height or newViewportHeight + _refreshUnit(self, "minWidth", parentW, ctx, "x") + _refreshUnit(self, "maxWidth", parentW, ctx, "x") + _refreshUnit(self, "minHeight", parentH, ctx, "y") + _refreshUnit(self, "maxHeight", parentH, ctx, "y") +end + +--- Calculate text width for button +---@return number +function Element:calculateTextWidth() + if self.text == nil then + return 0 + end + + local font = Element._utils.getFont(self.textSize, self.fontFamily, self.themeComponent, self._themeManager) + local width = font:getWidth(self.text) + return Element._utils.applyContentMultiplier(width, self.contentAutoSizingMultiplier, "width") +end + +---@return number +function Element:calculateTextHeight() + if self.text == nil then + return 0 + end + + local font = Element._utils.getFont(self.textSize, self.fontFamily, self.themeComponent, self._themeManager) + local height = font:getHeight() + + if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then + local availableWidth = self.width + + if (not availableWidth or availableWidth <= 0) and self.parent then + availableWidth = self.parent.width + end + + if availableWidth and availableWidth > 0 then + local _, wrappedLines = font:getWrap(self.text, availableWidth) + height = height * #wrappedLines + end + end + + return Element._utils.applyContentMultiplier(height, self.contentAutoSizingMultiplier, "height") +end + +function Element:calculateAutoWidth() + local contentWidth = self._layoutEngine:calculateAutoWidth() + if self._managedSelectMinimumBorderBoxWidth then + local minimumContentWidth = + math.max(0, self._managedSelectMinimumBorderBoxWidth - self.padding.left - self.padding.right) + contentWidth = math.max(contentWidth, minimumContentWidth) + end + return contentWidth +end + +--- Calculate auto height based on children +function Element:calculateAutoHeight() + return self._layoutEngine:calculateAutoHeight() +end + +---@param newText string +---@param autoresize boolean? --default: false +function Element:updateText(newText, autoresize) + self.text = newText or self.text + if autoresize then + self.width = self:calculateTextWidth() + self.height = self:calculateTextHeight() + end +end + +---@param newOpacity number +function Element:updateOpacity(newOpacity) + self.opacity = newOpacity + for _, child in ipairs(self.children) do + child:updateOpacity(newOpacity) + end +end + +--- same as calling updateOpacity(0) +function Element:hide() + self:updateOpacity(0) +end + +--- same as calling updateOpacity(1) +function Element:show() + self:updateOpacity(1) +end + +-- ==================== +-- Input Handling - Text Editing (behavior-delegated, task 04) +-- ==================== +-- All text-editor operations are dispatched through the TextEditable behavior +-- (modules/behaviors/TextEditable.lua). Element retains only thin 1-line +-- forwarders for backward-compat with external callers (EventHandler, +-- KeyboardNavigation, Renderer, game UI). The behavior owns the TextEditor +-- subsystem (onAttach creates it, onUpdate drives cursor blink, saveState / +-- restoreState persist it) AND implements the delegate bodies (text sync, +-- auto-grow, nil-guarding element._textEditor) — so Element carries zero +-- text-editor nil-guard branches and zero text-editor logic. +-- +-- `_wrapLine` / `_getFont` remain here: they are RENDERER forwarders (not +-- TextEditor delegates), and the TextEditor has its own implementations. +-- `updateText` (above) is a plain-label text setter, not a TextEditor delegate. +-- ==================== + +--- Set cursor position (delegates to TextEditable behavior) +---@param position number -- Character index (0-based) +function Element:setCursorPosition(position) + return Element._TextEditable.setCursorPosition(self, position) +end + +--- Get cursor position (delegates to TextEditable behavior) +---@return number -- Character index (0-based) +function Element:getCursorPosition() + return Element._TextEditable.getCursorPosition(self) +end + +--- Move cursor by delta characters (delegates to TextEditable behavior) +---@param delta number -- Number of characters to move (positive or negative) +function Element:moveCursorBy(delta) + return Element._TextEditable.moveCursorBy(self, delta) +end + +--- Move cursor to start of text (delegates to TextEditable behavior) +function Element:moveCursorToStart() + return Element._TextEditable.moveCursorToStart(self) +end + +--- Move cursor to end of text (delegates to TextEditable behavior) +function Element:moveCursorToEnd() + return Element._TextEditable.moveCursorToEnd(self) +end + +--- Move cursor to start of current line (delegates to TextEditable behavior) +function Element:moveCursorToLineStart() + return Element._TextEditable.moveCursorToLineStart(self) +end + +--- Move cursor to end of current line (delegates to TextEditable behavior) +function Element:moveCursorToLineEnd() + return Element._TextEditable.moveCursorToLineEnd(self) +end + +--- Move cursor to start of previous word (delegates to TextEditable behavior) +function Element:moveCursorToPreviousWord() + return Element._TextEditable.moveCursorToPreviousWord(self) +end + +--- Move cursor to start of next word (delegates to TextEditable behavior) +function Element:moveCursorToNextWord() + return Element._TextEditable.moveCursorToNextWord(self) +end + +--- Set selection range (delegates to TextEditable behavior) +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +function Element:setSelection(startPos, endPos) + return Element._TextEditable.setSelection(self, startPos, endPos) +end + +--- Get selection range (delegates to TextEditable behavior) +---@return number?, number? -- Start and end positions, or nil if no selection +function Element:getSelection() + return Element._TextEditable.getSelection(self) +end + +--- Check if there is an active selection (delegates to TextEditable behavior) +---@return boolean +function Element:hasSelection() + return Element._TextEditable.hasSelection(self) +end + +--- Clear selection (delegates to TextEditable behavior) +function Element:clearSelection() + return Element._TextEditable.clearSelection(self) +end + +--- Select all text (delegates to TextEditable behavior) +function Element:selectAll() + return Element._TextEditable.selectAll(self) +end + +--- Get selected text (delegates to TextEditable behavior) +---@return string? -- Selected text or nil if no selection +function Element:getSelectedText() + return Element._TextEditable.getSelectedText(self) +end + +--- Delete selected text (delegates to TextEditable behavior, which owns text sync + auto-grow) +---@return boolean -- True if text was deleted +function Element:deleteSelection() + return Element._TextEditable.deleteSelection(self) +end + +--- Give this element keyboard focus to enable text input or keyboard navigation +--- Use this to automatically focus text fields when showing forms or dialogs +function Element:focus() + return Element._TextEditable.focus(self) +end + +--- Remove keyboard focus to stop capturing input events +--- Use this when closing popups or switching focus to other elements +function Element:blur() + return Element._TextEditable.blur(self) +end + +--- Query focus state to conditionally render focus indicators or handle keyboard input +--- Use this to style focused elements or determine which element receives keyboard events +---@return boolean +function Element:isFocused() + return Element._TextEditable.isFocused(self) +end + +--- Retrieve the element's current text content for processing or validation +--- Use this to read user input from text fields or get display text +---@return string +function Element:getText() + return Element._TextEditable.getText(self) +end + +--- Update the element's text content programmatically for dynamic labels or resetting inputs +--- Use this to change text without user input, like clearing fields or updating status messages +---@param text string +function Element:setText(text) + return Element._TextEditable.setText(self, text) +end + +--- Programmatically insert text at any position for autocomplete or text manipulation +--- Use this to implement suggestions, templates, or text snippets +---@param text string -- Text to insert +---@param position number? -- Position to insert at (default: cursor position) +function Element:insertText(text, position) + return Element._TextEditable.insertText(self, text, position) +end + +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +function Element:deleteText(startPos, endPos) + return Element._TextEditable.deleteText(self, startPos, endPos) +end + +--- Replace text in range +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +---@param newText string -- Replacement text +function Element:replaceText(startPos, endPos, newText) + return Element._TextEditable.replaceText(self, startPos, endPos, newText) +end + +--- Wrap a single line of text +---@param line string -- Line to wrap +---@param maxWidth number -- Maximum width in pixels +---@return table -- Array of wrapped line parts +function Element:_wrapLine(line, maxWidth) + return self._renderer:wrapLine(self, line, maxWidth) +end + +---@return love.Font +function Element:_getFont() + return self._renderer:getFont(self) +end + +-- ==================== +-- Input Handling - Mouse Selection +-- ==================== + +--- Handle mouse click on text (set cursor position or start selection) +--- Delegates to the TextEditable behavior, which owns drag tracking. +---@param mouseX number -- Mouse X coordinate +---@param mouseY number -- Mouse Y coordinate +---@param clickCount number -- Number of clicks (1=single, 2=double, 3=triple) +function Element:_handleTextClick(mouseX, mouseY, clickCount) + return Element._TextEditable._handleTextClick(self, mouseX, mouseY, clickCount) +end + +--- Handle mouse drag for text selection +--- Delegates to the TextEditable behavior, which owns drag tracking. +---@param mouseX number -- Mouse X coordinate +---@param mouseY number -- Mouse Y coordinate +function Element:_handleTextDrag(mouseX, mouseY) + return Element._TextEditable._handleTextDrag(self, mouseX, mouseY) +end + +-- ==================== +-- Input Handling - Keyboard Input (behavior-delegated, task 04) +-- ==================== + +--- Handle text input (character input) — delegates to the TextEditable behavior. +---@param text string -- Character(s) to insert +function Element:textinput(text) + return Element._TextEditable.textinput(self, text) +end + +--- Handle key press (special keys) — delegates to the TextEditable behavior. +---@param key string -- Key name +---@param scancode string -- Scancode +---@param isrepeat boolean -- Whether this is a key repeat +function Element:keypressed(key, scancode, isrepeat) + return Element._TextEditable.keypressed(self, key, scancode, isrepeat) +end + +-- ==================== +-- Performance Monitoring +-- ==================== + +--- Get hierarchy depth of this element +---@return number depth Depth in the element tree (0 for root) +function Element:getHierarchyDepth() + local depth = 0 + local current = self.parent + while current do + depth = depth + 1 + current = current.parent + end + return depth +end + +--- Count total elements in this tree +---@return number count Total number of elements including this one and all descendants +function Element:countElements() + local count = 1 -- Count self + for _, child in ipairs(self.children) do + count = count + child:countElements() + end + return count +end + +function Element:_checkPerformanceWarnings() + if not Element._Performance or not Element._Performance.warningsEnabled then + return + end + + -- Check hierarchy depth + local depth = self:getHierarchyDepth() + if depth >= 15 then + Element._Performance:logWarning( + string.format("hierarchy_depth_%s", self.id), + "Element", + string.format("Element hierarchy depth is %d levels for element '%s'", depth, self.id or "unnamed"), + { depth = depth, elementId = self.id or "unnamed" }, + "Deep nesting can impact performance. Consider flattening the structure or using absolute positioning" + ) + end + + -- Check total element count (only for root elements) + if not self.parent then + local totalElements = self:countElements() + if totalElements >= 1000 then + Element._Performance:logWarning( + "element_count_high", + "Element", + string.format("UI contains %d+ elements", totalElements), + { elementCount = totalElements }, + "Large element counts may impact performance. Consider virtualization for long lists or pagination for large datasets" + ) + end + end +end + +--- Count active animations in tree +---@return number count Number of active animations +function Element:_countActiveAnimations() + local count = self.animation and 1 or 0 + for _, child in ipairs(self.children) do + count = count + child:_countActiveAnimations() + end + return count +end + +--- Track active animations and warn if too many +function Element:_trackActiveAnimations() + -- Get Performance instance from deps if available + if not Element._Performance or not Element._Performance.warningsEnabled then + return + end + + local animCount = self:_countActiveAnimations() + if animCount >= 50 then + Element._Performance:logWarning( + "animation_count_high", + "Element", + string.format("%d+ animations running simultaneously", animCount), + { animationCount = animCount }, + "High animation counts may impact frame rate. Consider reducing concurrent animations or using CSS-style transitions" + ) + end +end + +--- Change the tint color of an image element dynamically for hover effects or state indication +--- Use this to recolor images without replacing the asset, like highlighting selected items +---@param color Color Color to tint the image +function Element:setImageTint(color) + self.imageTint = color +end + +--- Adjust image transparency independently from the element for fade effects +--- Use this to create image-specific fade animations or disabled states +---@param opacity number Opacity 0-1 +function Element:setImageOpacity(opacity) + if opacity ~= nil then + Element._utils.validateRange(opacity, 0, 1, "imageOpacity") + end + self.imageOpacity = opacity +end + +--- Set image repeat mode +---@param repeatMode string Repeat mode: "no-repeat", "repeat", "repeat-x", "repeat-y", "space", "round" +function Element:setImageRepeat(repeatMode) + local validImageRepeat = { + ["no-repeat"] = "no-repeat", + ["repeat"] = "repeat", + ["repeat-x"] = "repeat-x", + ["repeat-y"] = "repeat-y", + space = "space", + round = "round", + } + Element._utils.validateEnum(repeatMode, validImageRepeat, "imageRepeat") + self.imageRepeat = repeatMode +end + +--- Apply rotation transform to create spinning animations or rotated layouts +--- Use this for loading spinners, compass needles, or angled UI elements +---@param angle number Angle in radians +function Element:rotate(angle) + if not self.transform then + self.transform = Element._Transform.new({}) + end + self.transform.rotate = angle +end + +--- Resize element visually using scale transforms for zoom effects +--- Use this for hover magnification, shrinking animations, or responsive scaling +---@param scaleX number X-axis scale +---@param scaleY number? Y-axis scale (defaults to scaleX) +function Element:scale(scaleX, scaleY) + if not self.transform then + self.transform = Element._Transform.new({}) + end + self.transform.scaleX = scaleX + self.transform.scaleY = scaleY or scaleX +end + +--- Offset element position using transforms for smooth movement without layout recalculation +--- Use this for parallax effects, draggable elements, or position animations +---@param x number X translation +---@param y number Y translation +function Element:translate(x, y) + if not self.transform then + self.transform = Element._Transform.new({}) + end + self.transform.translateX = x + self.transform.translateY = y +end + +--- Define the pivot point for rotation and scaling transforms +--- Use this to rotate around corners, edges, or custom points rather than the center +---@param originX number X origin (0-1, where 0.5 is center) +---@param originY number Y origin (0-1, where 0.5 is center) +function Element:setTransformOrigin(originX, originY) + if not self.transform then + self.transform = Element._Transform.new({}) + end + self.transform.originX = originX + self.transform.originY = originY +end + +--- Animate element to new property values with automatic transition +--- Captures current values as start, uses provided values as final, and applies the animation +---@param props table Target property values +---@param duration number? Animation duration in seconds (default: 0.3) +---@param easing string? Easing function name (default: "linear") +---@return Element self For method chaining +function Element:animateTo(props, duration, easing) + if not Element._Animation then + _warnAnimApi("ELEM_003") + return self + end + + if type(props) ~= "table" then + _warnAnimApi("ELEM_003") + return self + end + + duration = duration or 0.3 + easing = easing or "linear" + + -- Collect current values as start + local startValues = {} + for key, _ in pairs(props) do + startValues[key] = self[key] + end + + -- Create and apply animation + local anim = Element._Animation.new({ + duration = duration, + start = startValues, + final = props, + easing = easing, + }) + + anim:apply(self) + return self +end + +--- Fade element to full opacity +---@param duration number? Duration in seconds (default: 0.3) +---@param easing string? Easing function name +---@return Element self For method chaining +function Element:fadeIn(duration, easing) + return self:animateTo({ opacity = 1 }, duration or 0.3, easing) +end + +--- Fade element to zero opacity +---@param duration number? Duration in seconds (default: 0.3) +---@param easing string? Easing function name +---@return Element self For method chaining +function Element:fadeOut(duration, easing) + return self:animateTo({ opacity = 0 }, duration or 0.3, easing) +end + +--- Scale element to target scale value using transforms +---@param targetScale number Target scale multiplier +---@param duration number? Duration in seconds (default: 0.3) +---@param easing string? Easing function name +---@return Element self For method chaining +function Element:scaleTo(targetScale, duration, easing) + if not Element._Animation or not Element._Transform then + _warnAnimApi("ELEM_003") + return self + end + + -- Ensure element has a transform + if not self.transform then + self.transform = Element._Transform.new({}) + end + + local currentScaleX = self.transform.scaleX or 1 + local currentScaleY = self.transform.scaleY or 1 + + local anim = Element._Animation.new({ + duration = duration or 0.3, + start = { scaleX = currentScaleX, scaleY = currentScaleY }, + final = { scaleX = targetScale, scaleY = targetScale }, + easing = easing or "linear", + }) + + anim:apply(self) + return self +end + +--- Move element to target position +---@param x number Target x position +---@param y number Target y position +---@param duration number? Duration in seconds (default: 0.3) +---@param easing string? Easing function name +---@return Element self For method chaining +function Element:moveTo(x, y, duration, easing) + return self:animateTo({ x = x, y = y }, duration or 0.3, easing) +end + +--- Set transition configuration for a property +---@param property string Property name or "all" for all properties +---@param config table Transition config {duration, easing, delay, onComplete} +function Element:setTransition(property, config) + if not self.transitions then + self.transitions = {} + end + + if type(config) ~= "table" then + _warnAnimApi("ELEM_003") + config = {} + end + + -- Validate config + if config.duration and (type(config.duration) ~= "number" or config.duration < 0) then + _warnAnimApi("ELEM_004", config.duration) + config.duration = 0.3 + end + + self.transitions[property] = { + duration = config.duration or 0.3, + easing = config.easing or "easeOutQuad", + delay = config.delay or 0, + onComplete = config.onComplete, + } +end + +--- Set transition configuration for multiple properties +---@param groupName string Name for this transition group +---@param config table Transition config {duration, easing, delay, onComplete} +---@param properties table Array of property names +function Element:setTransitionGroup(_, config, properties) + if type(properties) ~= "table" then + _warnAnimApi("ELEM_005") + return + end + + for _, prop in ipairs(properties) do + self:setTransition(prop, config) + end +end + +--- Remove transition configuration for a property +---@param property string Property name or "all" to remove all +function Element:removeTransition(property) + if not self.transitions then + return + end + + if property == "all" then + self.transitions = {} + else + self.transitions[property] = nil + end +end + +--- Resolve a unit-based dimension property (width/height) from a string or CalcObject +--- Parses the value, updates self.units, resolves to pixels, and updates border-box dimensions +---@param property string "width" or "height" +---@param value string|table The unit string (e.g., "50%", "10vw") or CalcObject +---@return number resolvedValue The resolved pixel value +function Element:_resolveDimensionProperty(property, value) + local viewportWidth, viewportHeight = Element._Units.getViewport() + local parsedValue, parsedUnit = Element._Units.parse(value) + self.units[property] = { value = parsedValue, unit = parsedUnit } + + local parentDimension + if property == "width" then + parentDimension = self.parent and self.parent.width or viewportWidth + else + parentDimension = self.parent and self.parent.height or viewportHeight + end + + local resolved = Element._Units.resolve(parsedValue, parsedUnit, viewportWidth, viewportHeight, parentDimension) + + if type(resolved) ~= "number" then + Element._ErrorHandler:warn("Element", "LAY_003", { + issue = string.format("%s resolution returned non-number value", property), + type = type(resolved), + value = tostring(resolved), + }) + resolved = 0 + end + + self[property] = resolved + + if property == "width" then + if self.autosizing and self.autosizing.width then + self._borderBoxWidth = resolved + self.padding.left + self.padding.right + else + self._borderBoxWidth = resolved + end + else + if self.autosizing and self.autosizing.height then + self._borderBoxHeight = resolved + self.padding.top + self.padding.bottom + else + self._borderBoxHeight = resolved + end + end + + return resolved +end + +--- Resolve a dimension (width/height) prop given a unit-string/Calc value. +--- Handles the unit-sameness short-circuit, transition-on-resolved-pixel-value +--- semantics, and layout invalidation. Exits setProperty (caller returns). +local function _setDimensionWithUnit(self, property, value, transitionConfig) + -- Check if the unit specification is the same (compare against stored units) + local currentUnits = self.units[property] + local newValue, newUnit = Element._Units.parse(value) + if currentUnits and currentUnits.value == newValue and currentUnits.unit == newUnit then + return + end + + if transitionConfig then + -- For transitions, resolve the target value and transition the pixel value + local currentPixelValue = self[property] + local resolvedTarget = self:_resolveDimensionProperty(property, value) + + if currentPixelValue ~= nil and currentPixelValue ~= resolvedTarget then + -- Reset to current value before animating + self[property] = currentPixelValue + local Animation = require("modules.Animation") + local anim = Animation.new({ + duration = transitionConfig.duration, + start = { [property] = currentPixelValue }, + final = { [property] = resolvedTarget }, + easing = transitionConfig.easing, + onComplete = transitionConfig.onComplete, + }) + anim:apply(self) + end + else + self:_resolveDimensionProperty(property, value) + end + + self:invalidateLayout() +end + +--- Apply a transition animation from the current value to `value` for `property`. +--- Falls back to a direct write when there is no current value to animate from. +local function _animatePropertyTo(self, property, value, transitionConfig) + local currentValue = self[property] + if currentValue ~= nil then + local Animation = require("modules.Animation") + local anim = Animation.new({ + duration = transitionConfig.duration, + start = { [property] = currentValue }, + final = { [property] = value }, + easing = transitionConfig.easing, + onComplete = transitionConfig.onComplete, + }) + anim:apply(self) + else + self[property] = value + end +end + +-- Explicit handler map for the few props with genuinely-different setProperty +-- semantics that cannot be expressed via schema flags alone. Adding a new prop +-- with ordinary behavior requires NO new entry here — it flows through the +-- generic flagged dispatch below. Handlers signal a full-handled early return. +local _specialSetHandlers = { + parent = function(self, value) + self:setParent(value) + return true + end, + themeComponent = function(self, value) + self.themeComponent = value + self:_syncThemeAndRenderer("themeComponent", value) + return true + end, + -- imagePath / image: setting these must re-run the Imageable load pipeline + -- (recompute `_loadedImage`, fire onImageLoad/onImageError, defer I/O). The + -- Imageable behavior installs `element._reloadImage` at construction; if it + -- is absent the element has no image concern (Imageable only attaches when + -- imagePath/image is declared at construction), so the field is set but no + -- load occurs — late image-concern acquisition requires re-attaching the + -- behavior, which is outside the attach-at-construction contract. + imagePath = function(self, value) + self.imagePath = value + if self._reloadImage then + self:_reloadImage() + end + return true + end, + image = function(self, value) + self.image = value + if self._reloadImage then + self:_reloadImage() + end + return true + end, +} + +--- Set property with automatic transition. +--- Dispatch is registry-driven: dimension/unit props route through +--- `_setDimensionWithUnit`, the genuinely-special props (parent, +--- themeComponent, imagePath, image) route through `_specialSetHandlers`, and +--- everything else is a single generic path that consults schema flags +--- (`affectsLayout` / `syncsTheme`) for layout invalidation and theme sync. No +--- inline hardcoded property-name branches and no per-call table allocation. +---@param property string Property name +---@param value any New value +function Element:setProperty(property, value) + local transitionConfig + if self.transitions then + transitionConfig = self.transitions[property] or self.transitions["all"] + end + + local schema = Element._PropertySchema + + -- 1. Dimension prop with a unit string / CalcObject: resolve to pixels. + if schema.isDimension(property) and (type(value) == "string" or (Element._Calc and Element._Calc.isCalc(value))) then + _setDimensionWithUnit(self, property, value, transitionConfig) + return + end + + -- 2. Genuinely-special props (parent reparenting, themeComponent sync). + local handler = _specialSetHandlers[property] + if handler then + handler(self, value) + return + end + + -- 3. Generic flagged dispatch. + -- Skip write/transition/layout work for unchanged values, but still sync + -- theme state: disabled/active must reach setThemeState even when the value is + -- unchanged (renderer/theme state may have been reset out-of-band). + if self[property] ~= value then + if transitionConfig then + _animatePropertyTo(self, property, value, transitionConfig) + else + self[property] = value + end + if schema.affectsLayout(property) then + self:invalidateLayout() + end + end + if schema.syncsTheme(property) then + self:_syncThemeAndRenderer(property, value) + end +end + +---Sync ThemeManager and Renderer when properties change that affect rendering +---@param property string The property name that changed +---@param value any The new value +function Element:_syncThemeAndRenderer(property, value) + -- Visual props (backgroundColor/borderColor/cornerRadius/opacity) and callbacks + -- (onEvent/onTouchEvent/onGesture) are intentionally NOT synced here: Renderer:draw + -- and EventHandler dispatch read them from the element as source of truth, so a + -- bare `element. = v` write is immediately consistent with setProperty(...). + -- Only stateful side effects (theme-state machine + themeManager component) remain. + if property == "disabled" then + if self._themeManager then + self._themeManager.disabled = value + end + if self._renderer then + self._renderer:setThemeState(value and "disabled" or "normal") + end + elseif property == "active" then + if self._themeManager then + self._themeManager.active = value + end + if self._renderer then + self._renderer:setThemeState(value and "active" or "normal") + end + elseif property == "themeComponent" then + if self._themeManager then + self._themeManager.themeComponent = value + end + end +end + +-- ==================== +-- State Persistence (behavior-mode-unification task 12) +-- ==================== + +--- Save all element state for immediate-mode persistence. +--- Each attached behavior owns its own state extraction (saveState hook) and +--- returns a snapshot (or nil) merged into the consolidated state table: +--- Clickable → `eventHandler`, Scrollable → `scrollManager`, TextEditable → +--- `textEditor` + drag tracking, Selectable → `select`, Themed → `blur`, +--- Persistable → `_props` (public scalar mutations). Element owns ZERO +--- per-subsystem extraction logic — this method is a pure dispatch loop. +---@return ElementStateData state Complete state snapshot +function Element:saveState() + local state = {} + for i = 1, #self.behaviors do + local bstate = self.behaviors[i].saveState(self) + if bstate ~= nil then + for k, v in pairs(bstate) do + state[k] = v + end + end + end + return state +end + +--- Restore all element state from StateManager. +--- Each attached behavior owns its own hydration (restoreState hook) and reads +--- only its own slice from the full state table. Registry order places +--- Persistable last so `_props` overrides subsystem-hydrated state, preserving +--- the legacy restore ordering. Element owns ZERO per-subsystem hydration. +---@param state ElementStateData State to restore +function Element:restoreState(state) + if not state then + return + end + for i = 1, #self.behaviors do + self.behaviors[i].restoreState(self, state) + end +end + +--- Cleanup method to break circular references (immediate-mode frame end). +--- Iterates each attached behavior's `onDetach` hook so every behavior tears +--- down what its `onAttach` created (Clickable releases the EventHandler, +--- TextEditable the TextEditor, Themed the Renderer, Selectable the select +--- fields, Imageable the image callbacks), then clears the behaviors list and +--- unregisters from StateManager. Does NOT clear onEvent / onTouchEvent / +--- onGesture — the Renderer/EventHandler read those directly from the element +--- (not the cache), so clearing them here would break retained mode. +function Element:_cleanup() + for i = 1, #self.behaviors do + self.behaviors[i].onDetach(self) + end + self.behaviors = {} + -- onCreate fires once at construction (already invoked by now); release it. + self.onCreate = nil + if self._stateId and self._stateId ~= "" then + Element._StateManager.unregisterStateful(self._stateId) + end +end + +-- ==================== +-- Keyboard Navigation +-- ==================== + +--- Check if this element can receive keyboard focus +---@return boolean +function Element:isFocusable() + if self.disabled then + return false + end + -- Capability query: an element is keyboard-focusable when it is editable, has + -- an event/text handler, participates in the Select subsystem, or is a + -- touch-interactive element with callbacks. Expressed as a single boolean + -- expression (not a dispatch branch) because focusability is a query, not a + -- per-frame behavior. + return not not ( + self.editable + or type(self.onEvent) == "function" + or self._selectState + or self.selectOption + or self.onTextInput + or (self.touchEnabled and (self.onTouchEvent or self.onGesture)) + ) +end + +--- Get all focusable children in DOM/document order (depth-first traversal) +--- Elements are collected in the order they appear in the children array, +--- with nested children collected after their parent. This matches standard +--- browser tab order behavior where elements are ordered by document position. +---@return Element[] +function Element:getFocusableChildren() + local focusable = {} + + local function collectFocusable(elem) + for _, child in ipairs(elem.children) do + -- Check self first + if child:isFocusable() then + table.insert(focusable, child) + end + + -- Then recurse (depth-first) + collectFocusable(child) + end + end + + collectFocusable(self) + return focusable +end + +--- Get next focusable element in sequence +---@param container Element The container element +---@param currentElement Element? Current focused element +---@param wrap boolean? Whether to wrap around +---@return Element? +function Element.getNextFocusable(container, currentElement, wrap) + local focusable = container:getFocusableChildren() + if #focusable == 0 then + return nil + end + + -- Find current index + local currentIndex = 0 + if currentElement then + for i, elem in ipairs(focusable) do + if elem == currentElement then + currentIndex = i + break + end + end + end + + -- Find next + local nextIndex = currentIndex + 1 + if nextIndex > #focusable then + if wrap then + nextIndex = 1 + else + return nil + end + end + + return focusable[nextIndex] +end + +--- Get previous focusable element in sequence +---@param container Element The container element +---@param currentElement Element? Current focused element +---@param wrap boolean? Whether to wrap around +---@return Element? +function Element.getPreviousFocusable(container, currentElement, wrap) + local focusable = container:getFocusableChildren() + if #focusable == 0 then + return nil + end + + -- Find current index + local currentIndex = #focusable + 1 + if currentElement then + for i, elem in ipairs(focusable) do + if elem == currentElement then + currentIndex = i + break + end + end + end + + -- Find previous + local prevIndex = currentIndex - 1 + if prevIndex < 1 then + if wrap then + prevIndex = #focusable + else + return nil + end + end + + return focusable[prevIndex] +end + +return Element diff --git a/libs/flexlove/modules/Enums.lua b/libs/flexlove/modules/Enums.lua new file mode 100644 index 00000000..9e94b4c1 --- /dev/null +++ b/libs/flexlove/modules/Enums.lua @@ -0,0 +1,171 @@ +-- Layout, flex, text, image, and ARIA enums used across FlexLove. +-- Extracted from utils so utils stays under its LOC budget; re-exported as +-- `utils.enums` for backward compatibility. + +local enums = { + ---@enum TextAlign + TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" }, + ---@enum TextAlignVertical + TextAlignVertical = { START = "start", CENTER = "center", END = "end" }, + ---@enum Positioning + Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" }, + ---@enum FlexDirection + FlexDirection = { + HORIZONTAL = "horizontal", + VERTICAL = "vertical", + ROW = "row", + COLUMN = "column", + HORIZONTAL_REVERSE = "horizontal-reverse", + VERTICAL_REVERSE = "vertical-reverse", + ROW_REVERSE = "row-reverse", + COLUMN_REVERSE = "column-reverse", + }, + ---@enum JustifyContent + JustifyContent = { + FLEX_START = "flex-start", + CENTER = "center", + SPACE_AROUND = "space-around", + FLEX_END = "flex-end", + SPACE_EVENLY = "space-evenly", + SPACE_BETWEEN = "space-between", + }, + ---@enum JustifySelf + JustifySelf = { + AUTO = "auto", + FLEX_START = "flex-start", + CENTER = "center", + FLEX_END = "flex-end", + SPACE_AROUND = "space-around", + SPACE_EVENLY = "space-evenly", + SPACE_BETWEEN = "space-between", + }, + ---@enum AlignItems + AlignItems = { + STRETCH = "stretch", + FLEX_START = "flex-start", + FLEX_END = "flex-end", + CENTER = "center", + BASELINE = "baseline", + }, + ---@enum AlignSelf + AlignSelf = { + AUTO = "auto", + STRETCH = "stretch", + FLEX_START = "flex-start", + FLEX_END = "flex-end", + CENTER = "center", + BASELINE = "baseline", + }, + ---@enum AlignContent + AlignContent = { + STRETCH = "stretch", + FLEX_START = "flex-start", + FLEX_END = "flex-end", + CENTER = "center", + SPACE_BETWEEN = "space-between", + SPACE_AROUND = "space-around", + }, + ---@enum FlexWrap + FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" }, + ---@enum TextSize + TextSize = { + XXS = "xxs", + XS = "xs", + SM = "sm", + MD = "md", + LG = "lg", + XL = "xl", + XXL = "xxl", + XL3 = "3xl", + XL4 = "4xl", + }, + ---@enum ImageRepeat + ImageRepeat = { + NO_REPEAT = "no-repeat", + REPEAT = "repeat", + REPEAT_X = "repeat-x", + REPEAT_Y = "repeat-y", + SPACE = "space", + ROUND = "round", + }, + + ---@enum ARIA Role (accessibility roles for screen readers) + ARIA = { + -- Widget roles + BUTTON = "button", + CHECKBOX = "checkbox", + LINK = "link", + MENUITEM = "menuitem", + MENUITEMCHECKBOX = "menuitemcheckbox", + MENUITEMRADIO = "menuitemradio", + PROGRESSBAR = "progressbar", + RADIO = "radio", + SCROLLBAR = "scrollbar", + SLIDER = "slider", + SPINBUTTON = "spinbutton", + SWITCH = "switch", + TAB = "tab", + TABLIST = "tablist", + TABPANEL = "tabpanel", + TEXTBOX = "textbox", + TOOLTIP = "tooltip", + TREEITEM = "treeitem", + COMBOBOX = "combobox", + GRID = "grid", + GRIDCELL = "gridcell", + LISTBOX = "listbox", + LISTITEM = "listitem", + MENU = "menu", + MENUBAR = "menubar", + TREE = "tree", + TREEGRID = "treegrid", + WINDOW = "window", + DIALOG = "dialog", + ALERTDIALOG = "alertdialog", + + -- Landmark roles + BANNER = "banner", + COMPLEMENTARY = "complementary", + CONTENTINFO = "contentinfo", + FORM = "form", + MAIN = "main", + NAVIGATION = "navigation", + REGION = "region", + SEARCH = "search", + + -- Live region roles + ALERT = "alert", + LOG = "log", + MARQUEE = "marquee", + STATUS = "status", + TIMERTIME = "timer", + + -- Document structure roles + ARTICLE = "article", + BLOCKQUOTEBLOCKQUOTE = "blockquote", + CAPTION = "caption", + CODE = "code", + DEFINITION = "definition", + DELETED = "deletion", + DIRECTORY = "directory", + DIVISION = "division", + EMphasis = "emphasis", + HEADING = "heading", + INSERTED = "insertion", + LIST = "list", + MARK = "mark", + MATH = "math", + NONE = "none", + PARAGRAPH = "paragraph", + PRESENTATION = "presentation", + SEPARATOR = "separator", + STRONG = "strong", + SUBSCRIPT = "subscript", + SUPERSCRIPT = "superscript", + TERM = "term", + TIME = "time", + VARIABLE = "variable", + }, +} + +return { enums = enums } diff --git a/libs/flexlove/modules/ErrorHandler.lua b/libs/flexlove/modules/ErrorHandler.lua new file mode 100644 index 00000000..2f930619 --- /dev/null +++ b/libs/flexlove/modules/ErrorHandler.lua @@ -0,0 +1,1042 @@ +---@class ErrorCodes +---@field categories table +---@field codes table +local ErrorCodes = { + categories = { + VAL = "Validation", + LAY = "Layout", + REN = "Render", + THM = "Theme", + EVT = "Event", + RES = "Resource", + SYS = "System", + }, + codes = { + -- Validation Errors (VAL_001 - VAL_099) + VAL_001 = { + code = "FLEXLOVE_VAL_001", + category = "VAL", + description = "Invalid property type", + suggestion = "Check the property type matches the expected type (e.g., number, string, table)", + }, + VAL_002 = { + code = "FLEXLOVE_VAL_002", + category = "VAL", + description = "Property value out of range", + suggestion = "Ensure the value is within the allowed min/max range", + }, + VAL_003 = { + code = "FLEXLOVE_VAL_003", + category = "VAL", + description = "Required property missing", + suggestion = "Provide the required property in your element definition", + }, + VAL_004 = { + code = "FLEXLOVE_VAL_004", + category = "VAL", + description = "Invalid color format", + suggestion = "Use valid color format: {r, g, b, a} with values 0-1, hex string, or Color object", + }, + VAL_005 = { + code = "FLEXLOVE_VAL_005", + category = "VAL", + description = "Invalid unit format", + suggestion = "Use valid unit format: number (px), '50%', '10vw', '5vh', etc.", + }, + VAL_006 = { + code = "FLEXLOVE_VAL_006", + category = "VAL", + description = "Invalid calc() expression or calculation error", + suggestion = "Check calc() syntax and ensure no division by zero. Format: calc('value1 operator value2') with operators: +, -, *, / and units: px, %, vw, vh", + }, + VAL_007 = { + code = "FLEXLOVE_VAL_007", + category = "VAL", + description = "Invalid enum value", + suggestion = "Use one of the allowed enum values for this property", + }, + VAL_008 = { + code = "FLEXLOVE_VAL_008", + category = "VAL", + description = "Invalid text input", + suggestion = "Ensure text meets validation requirements (length, pattern, allowed characters)", + }, + + -- Layout Errors (LAY_001 - LAY_099) + LAY_001 = { + code = "FLEXLOVE_LAY_001", + category = "LAY", + description = "Invalid flex direction", + suggestion = "Use 'horizontal' or 'vertical' for flexDirection", + }, + LAY_002 = { + code = "FLEXLOVE_LAY_002", + category = "LAY", + description = "Circular dependency detected", + suggestion = "Remove circular references in element hierarchy or layout constraints", + }, + LAY_003 = { + code = "FLEXLOVE_LAY_003", + category = "LAY", + description = "Invalid dimensions (negative or NaN)", + suggestion = "Ensure width and height are positive numbers", + }, + LAY_004 = { + code = "FLEXLOVE_LAY_004", + category = "LAY", + description = "Layout calculation overflow", + suggestion = "Reduce complexity of layout or increase recursion limit", + }, + LAY_005 = { + code = "FLEXLOVE_LAY_005", + category = "LAY", + description = "Invalid alignment value", + suggestion = "Use valid alignment values (flex-start, center, flex-end, etc.)", + }, + LAY_006 = { + code = "FLEXLOVE_LAY_006", + category = "LAY", + description = "Invalid positioning mode", + suggestion = "Use 'absolute', 'relative', 'flex', or 'grid' for positioning", + }, + LAY_007 = { + code = "FLEXLOVE_LAY_007", + category = "LAY", + description = "Grid layout error", + suggestion = "Check grid template columns/rows and item placement", + }, + LAY_008 = { + code = "FLEXLOVE_LAY_008", + category = "LAY", + description = "Explicit position will be ignored by flex layout", + suggestion = "Remove x/y properties (flex layout controls position), OR set positioning='absolute' with left/top/right/bottom properties. Additionally, you can use margin/padding for positional offsets in flex layouts.", + }, + LAY_009 = { + code = "FLEXLOVE_LAY_009", + category = "LAY", + description = "Flex layout properties ignored with grid positioning", + suggestion = "Remove flexDirection/justifyContent/alignItems properties, or change positioning to 'flex' or 'relative'", + }, + LAY_010 = { + code = "FLEXLOVE_LAY_010", + category = "LAY", + description = "Grid layout properties ignored without grid positioning", + suggestion = "Set positioning='grid' to use grid layout properties, or remove grid properties", + }, + LAY_011 = { + code = "FLEXLOVE_LAY_011", + category = "LAY", + description = "CSS positioning properties ignored", + suggestion = "Set positioning='absolute' to use top/bottom/left/right properties", + }, + + -- Rendering Errors (REN_001 - REN_099) + REN_001 = { + code = "FLEXLOVE_REN_001", + category = "REN", + description = "Invalid render state", + suggestion = "Ensure element is properly initialized before rendering", + }, + REN_002 = { + code = "FLEXLOVE_REN_002", + category = "REN", + description = "Texture loading failed", + suggestion = "Check image path and format, ensure file exists", + }, + REN_003 = { + code = "FLEXLOVE_REN_003", + category = "REN", + description = "Font loading failed", + suggestion = "Check font path and format, ensure file exists", + }, + REN_004 = { + code = "FLEXLOVE_REN_004", + category = "REN", + description = "Invalid color value", + suggestion = "Color components must be numbers between 0 and 1", + }, + REN_005 = { + code = "FLEXLOVE_REN_005", + category = "REN", + description = "Clipping stack overflow", + suggestion = "Reduce nesting depth or check for missing scissor pops", + }, + REN_006 = { + code = "FLEXLOVE_REN_006", + category = "REN", + description = "Shader compilation failed", + suggestion = "Check shader code for syntax errors", + }, + REN_007 = { + code = "FLEXLOVE_REN_007", + category = "REN", + description = "Invalid nine-patch configuration", + suggestion = "Check nine-patch slice values and image dimensions", + }, + + -- Theme Errors (THM_001 - THM_099) + THM_001 = { + code = "FLEXLOVE_THM_001", + category = "THM", + description = "Theme file not found", + suggestion = "Check theme file path and ensure file exists", + }, + THM_002 = { + code = "FLEXLOVE_THM_002", + category = "THM", + description = "Invalid theme structure", + suggestion = "Theme must return a table with 'name' and component styles", + }, + THM_003 = { + code = "FLEXLOVE_THM_003", + category = "THM", + description = "Required theme property missing", + suggestion = "Ensure theme has required properties (name, base styles, etc.)", + }, + THM_004 = { + code = "FLEXLOVE_THM_004", + category = "THM", + description = "Invalid component style", + suggestion = "Component styles must be tables with valid properties", + }, + THM_005 = { + code = "FLEXLOVE_THM_005", + category = "THM", + description = "Theme loading failed", + suggestion = "Check theme file for Lua syntax errors", + }, + THM_006 = { + code = "FLEXLOVE_THM_006", + category = "THM", + description = "Invalid theme color", + suggestion = "Theme colors must be valid color values (hex, rgba, Color object)", + }, + THM_007 = { + code = "FLEXLOVE_THM_007", + category = "THM", + description = "themeStateLock has no effect without a valid theme component", + suggestion = "Ensure themeComponent is set and valid when using themeStateLock", + }, + THM_008 = { + code = "FLEXLOVE_THM_008", + category = "THM", + description = "Theme component has no state variants", + suggestion = "themeStateLock has no effect on components without state variants", + }, + THM_009 = { + code = "FLEXLOVE_THM_009", + category = "THM", + description = "Requested theme state does not exist", + suggestion = "Use one of the available theme states or set themeStateLock to false", + }, + THM_010 = { + code = "FLEXLOVE_THM_010", + category = "THM", + description = "Invalid themeStateLock type", + suggestion = "themeStateLock must be boolean or string (state name)", + }, + + -- Event Errors (EVT_001 - EVT_099) + EVT_001 = { + code = "FLEXLOVE_EVT_001", + category = "EVT", + description = "Invalid event type", + suggestion = "Use valid event types (mousepressed, textinput, etc.)", + }, + EVT_002 = { + code = "FLEXLOVE_EVT_002", + category = "EVT", + description = "Event handler error", + suggestion = "Check event handler function for errors", + }, + EVT_003 = { + code = "FLEXLOVE_EVT_003", + category = "EVT", + description = "Event propagation error", + suggestion = "Check event bubbling/capturing logic", + }, + EVT_004 = { + code = "FLEXLOVE_EVT_004", + category = "EVT", + description = "Invalid event target", + suggestion = "Ensure event target element exists and is valid", + }, + EVT_005 = { + code = "FLEXLOVE_EVT_005", + category = "EVT", + description = "Event handler not a function", + suggestion = "Event handlers must be functions", + }, + + -- Resource Errors (RES_001 - RES_099) + RES_001 = { + code = "FLEXLOVE_RES_001", + category = "RES", + description = "File not found", + suggestion = "Check file path and ensure file exists in the filesystem", + }, + RES_002 = { + code = "FLEXLOVE_RES_002", + category = "RES", + description = "Permission denied", + suggestion = "Check file permissions and access rights", + }, + RES_003 = { + code = "FLEXLOVE_RES_003", + category = "RES", + description = "Invalid file format", + suggestion = "Ensure file format is supported (png, jpg, ttf, etc.)", + }, + RES_004 = { + code = "FLEXLOVE_RES_004", + category = "RES", + description = "Resource loading failed", + suggestion = "Check file integrity and format compatibility", + }, + RES_005 = { + code = "FLEXLOVE_RES_005", + category = "RES", + description = "Image cache error", + suggestion = "Clear image cache or check memory availability", + }, + + -- System Errors (SYS_001 - SYS_099) + SYS_001 = { + code = "FLEXLOVE_SYS_001", + category = "SYS", + description = "Memory allocation failed", + suggestion = "Reduce memory usage or check available memory", + }, + SYS_002 = { + code = "FLEXLOVE_SYS_002", + category = "SYS", + description = "Stack overflow", + suggestion = "Reduce recursion depth or check for infinite loops", + }, + SYS_003 = { + code = "FLEXLOVE_SYS_003", + category = "SYS", + description = "Invalid state", + suggestion = "Check initialization order and state management", + }, + SYS_004 = { + code = "FLEXLOVE_SYS_004", + category = "SYS", + description = "Module initialization failed", + suggestion = "Check module dependencies and initialization order", + }, + + -- Performance Warnings (PERF_001 - PERF_099) + PERF_001 = { + code = "FLEXLOVE_PERF_001", + category = "PERF", + description = "Performance threshold exceeded", + suggestion = "Operation took longer than recommended. Monitor for patterns.", + }, + PERF_002 = { + code = "FLEXLOVE_PERF_002", + category = "PERF", + description = "Critical performance threshold exceeded", + suggestion = "Operation is causing frame drops. Consider optimizing or reducing frequency.", + }, + PERF_003 = { + code = "FLEXLOVE_PERF_003", + category = "PERF", + description = "Large blur area in immediate mode", + suggestion = "Consider using retained mode for this component to avoid recreating blur effects every frame.", + }, + + -- Memory Warnings (MEM_001 - MEM_099) + MEM_001 = { + code = "FLEXLOVE_MEM_001", + category = "MEM", + description = "Memory leak detected", + suggestion = "Table is growing consistently. Review cache eviction policies and ensure objects are properly released.", + }, + + -- State Management Warnings (STATE_001 - STATE_099) + STATE_001 = { + code = "FLEXLOVE_STATE_001", + category = "STATE", + description = "CallSite counters accumulating", + suggestion = "This indicates incrementFrame() may not be called properly. Check immediate mode frame management.", + }, + + -- Animation Errors (ANIM_001 - ANIM_099) + ANIM_001 = { + code = "FLEXLOVE_ANIM_001", + category = "VAL", + description = "Invalid animation configuration", + suggestion = "Animation.new() requires a table argument with duration, start, and final properties", + }, + ANIM_002 = { + code = "FLEXLOVE_ANIM_002", + category = "VAL", + description = "Invalid animation duration", + suggestion = "Animation duration must be a positive number in seconds", + }, + ANIM_003 = { + code = "FLEXLOVE_ANIM_003", + category = "VAL", + description = "Invalid animation target", + suggestion = "Animation can only be applied to table elements", + }, + ANIM_004 = { + code = "FLEXLOVE_ANIM_004", + category = "VAL", + description = "Invalid animation chain", + suggestion = "chain() requires an Animation object or function", + }, + ANIM_005 = { + code = "FLEXLOVE_ANIM_005", + category = "VAL", + description = "Invalid animation delay", + suggestion = "delay() requires a non-negative number in seconds", + }, + ANIM_006 = { + code = "FLEXLOVE_ANIM_006", + category = "VAL", + description = "Invalid repeat count", + suggestion = "repeatCount() requires a non-negative number", + }, + ANIM_007 = { + code = "FLEXLOVE_ANIM_007", + category = "VAL", + description = "Invalid keyframes configuration", + suggestion = "Animation.keyframes() requires a table with duration and keyframes array", + }, + ANIM_008 = { + code = "FLEXLOVE_ANIM_008", + category = "VAL", + description = "Insufficient keyframes", + suggestion = "Keyframe animations require at least 2 keyframes", + }, + ANIM_009 = { + code = "FLEXLOVE_ANIM_009", + category = "VAL", + description = "Invalid animation group configuration", + suggestion = "AnimationGroup.new() requires a table with animations array", + }, + ANIM_010 = { + code = "FLEXLOVE_ANIM_010", + category = "VAL", + description = "Empty animation group", + suggestion = "AnimationGroup requires at least one animation", + }, + ANIM_011 = { + code = "FLEXLOVE_ANIM_011", + category = "VAL", + description = "Invalid animation group mode", + suggestion = "AnimationGroup mode must be 'parallel' or 'sequence'", + }, + + -- Blur Errors (BLUR_001 - BLUR_099) + BLUR_001 = { + code = "FLEXLOVE_BLUR_001", + category = "VAL", + description = "Missing draw function", + suggestion = "applyToRegion requires a draw function to render the content to be blurred", + }, + BLUR_002 = { + code = "FLEXLOVE_BLUR_002", + category = "VAL", + description = "Missing backdrop canvas", + suggestion = "applyBackdrop requires a backdrop canvas parameter", + }, + + -- FlexLove Core Errors (CORE_001 - CORE_099) + CORE_001 = { + code = "FLEXLOVE_CORE_001", + category = "VAL", + description = "Invalid callback function", + suggestion = "deferCallback expects a function argument", + }, + CORE_002 = { + code = "FLEXLOVE_CORE_002", + category = "SYS", + description = "Deferred callback execution failed", + suggestion = "Check the callback function for errors. Error details included in message.", + }, + CORE_003 = { + code = "FLEXLOVE_CORE_003", + category = "VAL", + description = "Invalid garbage collection strategy", + suggestion = "GC strategy must be one of: 'default', 'aggressive', 'conservative'", + }, + CORE_004 = { + code = "FLEXLOVE_CORE_004", + category = "SYS", + description = "Deferred method retry limit exceeded", + suggestion = "A deferred method has been retried too many times without succeeding. Check that preconditions are eventually met.", + }, + CORE_005 = { + code = "FLEXLOVE_CORE_005", + category = "VAL", + description = "Invalid deferred method", + suggestion = "The method name provided to _deferMethod does not exist on the element.", + }, + + -- Element Errors (ELEM_001 - ELEM_099) + ELEM_001 = { + code = "FLEXLOVE_ELEM_001", + category = "VAL", + description = "Invalid text size", + suggestion = "textSize must be greater than 0", + }, + ELEM_002 = { + code = "FLEXLOVE_ELEM_002", + category = "VAL", + description = "Invalid text size unit", + suggestion = "textSize unit must be one of: px, %, vw, vh, or presets: xs, sm, md, lg, xl, xxl, 2xl, 3xl, 4xl", + }, + ELEM_003 = { + code = "FLEXLOVE_ELEM_003", + category = "VAL", + description = "Invalid transition configuration", + suggestion = "setTransition() requires a table with transition properties", + }, + ELEM_004 = { + code = "FLEXLOVE_ELEM_004", + category = "VAL", + description = "Invalid transition duration", + suggestion = "Transition duration must be a non-negative number in seconds", + }, + ELEM_005 = { + code = "FLEXLOVE_ELEM_005", + category = "VAL", + description = "Invalid transition group", + suggestion = "setTransitionGroup() requires an array of property names", + }, + ELEM_006 = { + code = "FLEXLOVE_ELEM_006", + category = "VAL", + description = "Incompatible element configuration", + suggestion = "passwordMode and multiline cannot be used together. Multiline will be disabled.", + }, + ELEM_007 = { + code = "FLEXLOVE_ELEM_007", + category = "VAL", + description = "Invalid select frame configuration", + suggestion = "Pass a fully instantiated Element as selectParent.selectFrame. Create it unattached so the owning select can adopt it safely.", + }, + ELEM_008 = { + code = "FLEXLOVE_ELEM_008", + category = "VAL", + description = "Select frame was already parented before adoption", + suggestion = "Create the selectFrame without a parent, or explicitly accept that the select will reparent it during adoption.", + }, + ELEM_009 = { + code = "FLEXLOVE_ELEM_009", + category = "VAL", + description = "Managed select frame was reparented unexpectedly", + suggestion = "Avoid moving a managed selectFrame outside its owning select after adoption. Let the select own the frame lifecycle.", + }, + ELEM_010 = { + code = "FLEXLOVE_ELEM_010", + category = "VAL", + description = "Invalid display property value", + suggestion = "The display property accepts only boolean values (true/false). Pass `true` to show the element or `false` to hide it from layout, rendering, and hit testing.", + }, + + -- Module Loader Warnings (MOD_001 - MOD_099) + MOD_001 = { + code = "FLEXLOVE_MOD_001", + category = "RES", + description = "Optional module not found", + suggestion = "Using stub implementation for optional module. This is expected if the module is not required.", + }, + + -- Utility Errors (UTIL_001 - UTIL_099) + UTIL_001 = { + code = "FLEXLOVE_UTIL_001", + category = "VAL", + description = "Text truncation warning", + suggestion = "Text was truncated to fit within the maximum allowed length", + }, + + -- Image/Rendering Errors (IMG_001 - IMG_099) + IMG_001 = { + code = "FLEXLOVE_IMG_001", + category = "REN", + description = "Stencil buffer not available", + suggestion = "Cannot apply corner radius to image without stencil buffer support. Check graphics capabilities.", + }, + + -- Navigation Errors (NAV_001 - NAV_099) + NAV_001 = { + code = "FLEXLOVE_NAV_001", + category = "EVT", + description = "Element focus callback error", + suggestion = "Check the onFocus callback function for errors. Error details included in message.", + }, + NAV_002 = { + code = "FLEXLOVE_NAV_002", + category = "EVT", + description = "Element activation callback error", + suggestion = "Check the onEvent callback function for errors. Error details included in message.", + }, + NAV_003 = { + code = "FLEXLOVE_NAV_003", + category = "EVT", + description = "Element dismiss callback error", + suggestion = "Check the onDismiss callback function for errors. Error details included in message.", + }, + }, +} + +--- Get error information by code +--- @param code string Error code (e.g., "VAL_001" or "FLEXLOVE_VAL_001") +--- @return table? errorInfo Error information or nil if not found +function ErrorCodes.get(code) + -- Handle both short and full format + local shortCode = code:gsub("^FLEXLOVE_", "") + return ErrorCodes.codes[shortCode] +end + +--- Get human-readable description for error code +--- @param code string Error code +--- @return string description Error description +function ErrorCodes.describe(code) + local info = ErrorCodes.get(code) + if info then + return info.description + end + return "Unknown error code: " .. code +end + +--- Search error codes by keyword +--- @param keyword string Keyword to search for +--- @return table codes Matching error codes +function ErrorCodes.search(keyword) + keyword = keyword:lower() + local result = {} + for code, info in pairs(ErrorCodes.codes) do + local searchText = (code .. " " .. info.description .. " " .. info.suggestion):lower() + if searchText:find(keyword, 1, true) then + table.insert(result, { + code = code, + fullCode = info.code, + description = info.description, + suggestion = info.suggestion, + category = ErrorCodes.categories[info.category], + }) + end + end + return result +end + +--- Format error message with code +--- @param code string Error code +--- @param message string Error message +--- @return string formattedMessage Formatted error message with code +function ErrorCodes.formatMessage(code, message) + local info = ErrorCodes.get(code) + if info then + return string.format("[%s] %s", info.code, message) + end + return message +end + +--- Validate that all error codes are unique and properly formatted +--- @return boolean, string? Returns true if valid, or false with error message +function ErrorCodes.validate() + local seen = {} + local fullCodes = {} + + for code, info in pairs(ErrorCodes.codes) do + -- Check for duplicates + if seen[code] then + return false, "Duplicate error code: " .. code + end + seen[code] = true + + if fullCodes[info.code] then + return false, "Duplicate full error code: " .. info.code + end + fullCodes[info.code] = true + + -- Check format + if not code:match("^[A-Z]+_[0-9]+$") then + return false, "Invalid code format: " .. code .. " (expected CATEGORY_NUMBER)" + end + + -- Check full code format + local expectedFullCode = "FLEXLOVE_" .. code + if info.code ~= expectedFullCode then + return false, "Mismatched full code for " .. code .. ": expected " .. expectedFullCode .. ", got " .. info.code + end + + -- Check required fields + if not info.description or info.description == "" then + return false, "Missing description for " .. code + end + if not info.suggestion or info.suggestion == "" then + return false, "Missing suggestion for " .. code + end + if not info.category or info.category == "" then + return false, "Missing category for " .. code + end + end + + return true, nil +end + +---@enum LOG_LEVEL +local LOG_LEVEL = { + CRITICAL = 1, + ERROR = 2, + WARNING = 3, + INFO = 4, + DEBUG = 5, +} + +---@enum LOG_TARGET +local LOG_TARGET = { + CONSOLE = "console", + FILE = "file", + BOTH = "both", + NONE = "none", +} + +---@class ErrorHandler +---@field errorCodes ErrorCodes +---@field includeStackTrace boolean -- Default: false +---@field logLevel LOG_LEVEL --Default: LOG_LEVEL.WARNING +---@field logTarget "console" | "file" | "both" +---@field logFile string +---@field maxLogSize number in bytes +---@field maxLogFiles number files to rotate +---@field enableRotation boolean see maxLogFiles +---@field _currentLogSize number private +---@field _logFileHandle file* private +local ErrorHandler = { + errorCodes = ErrorCodes, +} +ErrorHandler.__index = ErrorHandler + +---@type ErrorHandler|nil +local instance = nil + +---@param config { includeStackTrace?: boolean, logLevel?: LOG_LEVEL, logTarget?: "console" | "file" | "both", logFile?: string, maxLogSize?: number, maxLogFiles?: number, enableRotation?: boolean }|nil +---@return ErrorHandler +function ErrorHandler.init(config) + if instance == nil then + local self = setmetatable({}, ErrorHandler) + self.includeStackTrace = config and config.includeStackTrace or false + self.logLevel = config and config.logLevel or LOG_LEVEL.WARNING + self.logTarget = config and config.logTarget or LOG_TARGET.CONSOLE + self.logFile = config and config.logFile or "flexlove-errors.log" + self.maxLogSize = config and config.maxLogSize or 10 * 1024 * 1024 + self.maxLogFiles = config and config.maxLogFiles or 5 + self.enableRotation = config and config.enableRotation or true + self._currentLogSize = 0 + self._logFileHandle = nil + instance = self + end + return instance +end + +--- Get the singleton instance (lazily initializes if needed) +---@return ErrorHandler +function ErrorHandler.getInstance() + if instance == nil then + ErrorHandler.init() + end + return instance +end + +--- Get current timestamp with milliseconds +---@return string|osdate Formatted timestamp +function ErrorHandler:_getTimestamp() + local time = os.time() + local date = os.date("%Y-%m-%d %H:%M:%S", time) + -- Note: Lua doesn't have millisecond precision by default, so we approximate + return date +end + +--- Rotate log file if needed +function ErrorHandler:_rotateLogIfNeeded() + if not self.enableRotation then + return + end + if self._currentLogSize < self.maxLogSize then + return + end + + -- Close current log + if self._logFileHandle then + self._logFileHandle:close() + self._logFileHandle = nil + end + + -- Rotate existing logs + for i = self.maxLogFiles - 1, 1, -1 do + local oldName = self.logFile .. "." .. i + local newName = self.logFile .. "." .. (i + 1) + os.rename(oldName, newName) -- Will fail silently if file doesn't exist + end + + -- Move current log to .1 + os.rename(self.logFile, self.logFile .. ".1") + + -- Create new log file + self._logFileHandle = io.open(self.logFile, "a") + self._currentLogSize = 0 +end + +--- Escape string for JSON +---@param str string String to escape +---@return string Escaped string +function ErrorHandler:_escapeJson(str) + str = tostring(str) + str = str:gsub("\\", "\\\\") + str = str:gsub('"', '\\"') + str = str:gsub("\n", "\\n") + str = str:gsub("\r", "\\r") + str = str:gsub("\t", "\\t") + return str +end + +--- Format details as JSON object +---@param details table|nil Details object +---@return string JSON string +function ErrorHandler:_formatDetailsJson(details) + if not details or type(details) ~= "table" then + return "{}" + end + + local parts = {} + for key, value in pairs(details) do + local jsonKey = self:_escapeJson(tostring(key)) + local jsonValue = self:_escapeJson(tostring(value)) + table.insert(parts, string.format('"%s":"%s"', jsonKey, jsonValue)) + end + + return "{" .. table.concat(parts, ",") .. "}" +end + +--- Format details object as readable key-value pairs +---@param details table|nil Details object +---@return string Formatted details +function ErrorHandler:_formatDetails(details) + if not details or type(details) ~= "table" then + return "" + end + + local lines = {} + for key, value in pairs(details) do + local formattedKey = tostring(key):gsub("^%l", string.upper) + local formattedValue = tostring(value) + -- Truncate very long values + if #formattedValue > 100 then + formattedValue = formattedValue:sub(1, 97) .. "..." + end + table.insert(lines, string.format(" %s: %s", formattedKey, formattedValue)) + end + + if #lines > 0 then + return "\n\nDetails:\n" .. table.concat(lines, "\n") + end + return "" +end + +--- Extract and format stack trace +---@param level number Stack level to start from +---@return string Formatted stack trace +function ErrorHandler:_formatStackTrace(level) + if not self.includeStackTrace then + return "" + end + + local lines = {} + local currentLevel = level or 3 + + while true do + local info = debug.getinfo(currentLevel, "Sl") + if not info then + break + end + + -- Skip internal Lua files + if info.source:match("^@") and not info.source:match("loveStub") then + local source = info.source:sub(2) -- Remove @ prefix + local location = string.format("%s:%d", source, info.currentline) + table.insert(lines, " " .. location) + end + + currentLevel = currentLevel + 1 + if currentLevel > level + 10 then + break + end -- Limit depth + end + + if #lines > 0 then + return "\n\nStack trace:\n" .. table.concat(lines, "\n") + end + return "" +end + +--- Format an error or warning message using error code lookup +---@param module string The module name (e.g., "Element", "Units", "Theme") +---@param level string "Error" or "Warning" +---@param code string Error code (e.g., "VAL_001") +---@param details table|nil Optional details object +---@return string Formatted message +function ErrorHandler:_formatMessage(module, level, code, details) + local codeInfo = ErrorCodes.get(code) + + if not codeInfo then + return string.format("[FlexLove - %s] %s: Unknown error code: %s", module, level, code) + end + + -- Build formatted message + local parts = {} + + -- Header: [FlexLove - Module] Level [CODE]: Description + table.insert(parts, string.format("[FlexLove - %s] %s [%s]: %s", module, level, codeInfo.code, codeInfo.description)) + + -- Details section + if details and type(details) == "table" then + table.insert(parts, self:_formatDetails(details)) + end + + -- Suggestion section + if codeInfo.suggestion and codeInfo.suggestion ~= "" then + table.insert(parts, string.format("\n\nSuggestion: %s", codeInfo.suggestion)) + end + + return table.concat(parts, "") +end + +--- Write log entry to file and/or console +---@param level string Log level +---@param levelNum number Log level number +---@param module string Module name +---@param code string|nil Error code +---@param message string Message +---@param details table|nil Details +---@param suggestion string|nil Suggestion +function ErrorHandler:_writeLog(level, levelNum, module, code, message, details, suggestion) + -- Check if we should log this level + if not levelNum or not self.logLevel or levelNum > self.logLevel then + return + end + + local timestamp = self:_getTimestamp() + local logEntry + + local jsonParts = { + string.format('"timestamp":"%s"', self:_escapeJson(timestamp)), + string.format('"level":"%s"', level), + string.format('"module":"%s"', self:_escapeJson(module)), + string.format('"message":"%s"', self:_escapeJson(message)), + } + + if code then + table.insert(jsonParts, string.format('"code":"%s"', self:_escapeJson(code))) + end + + if details then + table.insert(jsonParts, string.format('"details":%s', self:_formatDetailsJson(details))) + end + + if suggestion then + table.insert(jsonParts, string.format('"suggestion":"%s"', self:_escapeJson(suggestion))) + end + + logEntry = "{" .. table.concat(jsonParts, ",") .. "}\n" + + if self.logTarget == "console" or self.logTarget == "both" then + io.write(logEntry) + io.flush() + end + + -- Write to file + if self.logTarget == "file" or self.logTarget == "both" then + -- Lazy file opening: open on first write + if not self._logFileHandle then + self._logFileHandle = io.open(self.logFile, "a") + if self._logFileHandle then + -- Get current file size + local currentPos = self._logFileHandle:seek("end") + self._currentLogSize = currentPos or 0 + end + end + + if self._logFileHandle then + self:_rotateLogIfNeeded() + + -- Reopen if rotation closed it + if not self._logFileHandle then + self._logFileHandle = io.open(self.logFile, "a") + end + + if self._logFileHandle then + self._logFileHandle:write(logEntry) + self._logFileHandle:flush() + self._currentLogSize = self._currentLogSize + #logEntry + end + end + end +end + +--- Throw a critical error (stops execution) +---@param module string The module name +---@param code string Error code (e.g., "VAL_001") +---@param details table|nil Optional details object +function ErrorHandler:error(module, code, details) + local formattedMessage = self:_formatMessage(module, "Error", code, details) + + local codeInfo = ErrorCodes.get(code) + local message = codeInfo and codeInfo.description or code + local suggestion = codeInfo and codeInfo.suggestion or nil + + -- Log the error + self:_writeLog("ERROR", LOG_LEVEL.ERROR, module, code, message, details, suggestion) + + if self.includeStackTrace then + formattedMessage = formattedMessage .. self:_formatStackTrace(3) + end + + error(formattedMessage, 2) +end + +--- Print a warning (non-critical, continues execution) +---@param module string The module name +---@param code string Warning code (e.g., "VAL_001") +---@param details table|nil Optional details object +function ErrorHandler:warn(module, code, details) + local codeInfo = ErrorCodes.get(code) + local message = codeInfo and codeInfo.description or code + local suggestion = codeInfo and codeInfo.suggestion or nil + + -- Log the warning + self:_writeLog("WARNING", LOG_LEVEL.WARNING, module, code, message, details, suggestion) +end + +--- Validate that a value is not nil +---@param module string The module name +---@param value any The value to check +---@param paramName string The parameter name +---@return boolean True if valid +function ErrorHandler:assertNotNil(module, value, paramName) + if value == nil then + self:error(module, "VAL_003", "Required parameter missing", { + parameter = paramName, + }) + return false + end + return true +end + +--- Validate that a value is of the expected type +---@param module string The module name +--- Warn if a value is deprecated +---@param module string The module name +---@param oldName string The deprecated name +---@param newName string The new name to use +function ErrorHandler:warnDeprecated(module, oldName, newName) + self:warn(module, string.format("'%s' is deprecated. Use '%s' instead", oldName, newName)) +end + +return ErrorHandler diff --git a/libs/flexlove/modules/EventHandler.lua b/libs/flexlove/modules/EventHandler.lua new file mode 100644 index 00000000..2631fc47 --- /dev/null +++ b/libs/flexlove/modules/EventHandler.lua @@ -0,0 +1,843 @@ +---@class EventHandler +---@field onEvent fun(element:Element, event:InputEvent)? +---@field onEventDeferred boolean? +---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback +---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred +---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback +---@field onGestureDeferred boolean? -- Whether onGesture is deferred +---@field touchEnabled boolean -- Whether touch events are processed (default: true) +---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false) +---@field _pressed table +---@field _lastClickTime number? +---@field _lastClickButton number? +---@field _clickCount number +---@field _dragStartX table +---@field _dragStartY table +---@field _lastMouseX table +---@field _lastMouseY table +---@field _touches table -- Multi-touch state per touch ID +---@field _touchStartPositions table -- Touch start positions +---@field _lastTouchPositions table -- Last touch positions for delta +---@field _touchHistory table -- Touch position history for gestures (last 5) +---@field _hovered boolean +---@field _scrollbarPressHandled boolean +---@field _InputEvent table +---@field _utils table +---@field _Performance Performance? Performance module dependency +---@field _ErrorHandler ErrorHandler +local EventHandler = {} +EventHandler.__index = EventHandler + +--- Initialize module with shared dependencies +---@param deps table Dependencies {Performance, ErrorHandler, InputEvent, Context, utils} +function EventHandler.init(deps) + EventHandler._Performance = deps.Performance + EventHandler._ErrorHandler = deps.ErrorHandler + EventHandler._InputEvent = deps.InputEvent + EventHandler._utils = deps.utils + EventHandler._Context = deps.Context +end + +---@param config table Configuration options +---@return EventHandler +function EventHandler.new(config) + config = config or {} + local self = setmetatable({}, EventHandler) + + self.onEvent = config.onEvent + self.onEventDeferred = config.onEventDeferred + self.onTouchEvent = config.onTouchEvent + self.onTouchEventDeferred = config.onTouchEventDeferred or false + self.onGesture = config.onGesture + self.onGestureDeferred = config.onGestureDeferred or false + self.touchEnabled = config.touchEnabled ~= false -- Default true + self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false + + self._pressed = config._pressed or {} + + self._lastClickTime = config._lastClickTime + self._lastClickButton = config._lastClickButton + self._clickCount = config._clickCount or 0 + + -- FocusIndicator reference (set after initialization) + self._FocusIndicator = nil + + self._dragStartX = config._dragStartX or {} + self._dragStartY = config._dragStartY or {} + self._lastMouseX = config._lastMouseX or {} + self._lastMouseY = config._lastMouseY or {} + + -- Multi-touch tracking + self._touches = config._touches or {} + self._touchStartPositions = config._touchStartPositions or {} + self._lastTouchPositions = config._lastTouchPositions or {} + self._touchHistory = config._touchHistory or {} + + self._hovered = config._hovered or false + + self._scrollbarPressHandled = false + + return self +end + +--- Get state for persistence (for immediate mode) +---@return table State data +function EventHandler:getState() + return { + _pressed = self._pressed, + _lastClickTime = self._lastClickTime, + _lastClickButton = self._lastClickButton, + _clickCount = self._clickCount, + _dragStartX = self._dragStartX, + _dragStartY = self._dragStartY, + _lastMouseX = self._lastMouseX, + _lastMouseY = self._lastMouseY, + _touches = self._touches, + _touchStartPositions = self._touchStartPositions, + _lastTouchPositions = self._lastTouchPositions, + _touchHistory = self._touchHistory, + _hovered = self._hovered, + } +end + +--- Restore state from persistence (for immediate mode) +---@param state table State data +function EventHandler:setState(state) + if not state then + return + end + + self._pressed = state._pressed or {} + self._lastClickTime = state._lastClickTime + self._lastClickButton = state._lastClickButton + self._clickCount = state._clickCount or 0 + self._dragStartX = state._dragStartX or {} + self._dragStartY = state._dragStartY or {} + self._lastMouseX = state._lastMouseX or {} + self._lastMouseY = state._lastMouseY or {} + self._touches = state._touches or {} + self._touchStartPositions = state._touchStartPositions or {} + self._lastTouchPositions = state._lastTouchPositions or {} + self._touchHistory = state._touchHistory or {} + self._hovered = state._hovered or false +end + +--- Process mouse button events in the update cycle +---@param element Element The parent element +---@param mx number Mouse X position +---@param my number Mouse Y position +---@param isHovering boolean Whether mouse is over element +---@param isActiveElement boolean Whether this is the top element at mouse position +function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement) + -- Start performance timing + -- Performance accessed via EventHandler._Performance + if EventHandler._Performance and EventHandler._Performance.enabled then + EventHandler._Performance:startTimer("event_mouse") + end + + -- Check if currently dragging (allows drag continuation even if occluded) + local isDragging = false + for _, button in ipairs({ 1, 2, 3 }) do + if self._pressed[button] and love.mouse.isDown(button) then + isDragging = true + break + end + end + + -- Check if any button is currently pressed (tracked state) + local hasTrackedPress = false + for _, button in ipairs({ 1, 2, 3 }) do + if self._pressed[button] then + hasTrackedPress = true + break + end + end + + -- Can only process events if we have handler, element is enabled, and is active or dragging or has tracked press + -- Read onEvent from element (source of truth), fallback to handler cache for backwards compat + local canProcessEvents = ( + element.onEvent + or self.onEvent + or element.editable + or element._selectState + or element.selectOption + ) + and element.visibility ~= "hidden" + and not element.disabled + and (isActiveElement or isDragging or hasTrackedPress) + + if not canProcessEvents then + -- If not hovering and no buttons are physically pressed, reset all pressed states + -- This ensures the pressed state is cleared when mouse leaves without button held + if not isHovering and not isDragging then + for _, button in ipairs({ 1, 2, 3 }) do + if self._pressed[button] and not love.mouse.isDown(button) then + self._pressed[button] = false + self._dragStartX[button] = nil + self._dragStartY[button] = nil + end + end + end + + -- Track hover state changes even when events can't be processed + -- Fire synthetic unhover when element becomes disabled while hovered + if element.disabled and self._hovered then + self._hovered = false + if element.onEvent or self.onEvent then + local modifiers = EventHandler._utils.getModifiers() + local unhoverEvent = EventHandler._InputEvent.new({ + type = "unhover", + button = 0, + x = mx, + y = my, + modifiers = modifiers, + clickCount = 0, + }) + self:_invokeCallback(element, unhoverEvent) + end + elseif self._hovered and not isHovering then + self._hovered = false + if element.onEvent or self.onEvent then + local modifiers = EventHandler._utils.getModifiers() + local unhoverEvent = EventHandler._InputEvent.new({ + type = "unhover", + button = 0, + x = mx, + y = my, + modifiers = modifiers, + clickCount = 0, + }) + self:_invokeCallback(element, unhoverEvent) + end + end + + if EventHandler._Performance and EventHandler._Performance.enabled then + EventHandler._Performance:stopTimer("event_mouse") + end + return + end + + -- Track hover state changes and fire hover/unhover events BEFORE button processing + -- This ensures hover fires before press when mouse first enters element + local wasHovered = self._hovered + local isHoveringAndActive = isHovering and isActiveElement + + if isHoveringAndActive and not wasHovered then + -- Just started hovering - fire hover event + self._hovered = true + local modifiers = EventHandler._utils.getModifiers() + local hoverEvent = EventHandler._InputEvent.new({ + type = "hover", + button = 0, + x = mx, + y = my, + modifiers = modifiers, + clickCount = 0, + }) + self:_invokeCallback(element, hoverEvent) + elseif not isHoveringAndActive and wasHovered then + -- Just stopped hovering - fire unhover event + self._hovered = false + local modifiers = EventHandler._utils.getModifiers() + local unhoverEvent = EventHandler._InputEvent.new({ + type = "unhover", + button = 0, + x = mx, + y = my, + modifiers = modifiers, + clickCount = 0, + }) + self:_invokeCallback(element, unhoverEvent) + end + + -- Process all three mouse buttons + local buttons = { 1, 2, 3 } -- left, right, middle + + for _, button in ipairs(buttons) do + -- Check if this button was tracked as pressed + local wasPressed = self._pressed[button] + local isPhysicallyPressed = love.mouse.isDown(button) + + if isHovering or isDragging or wasPressed then + if isPhysicallyPressed then + -- Button is pressed down + if not wasPressed then + -- Just pressed - fire press event (only if hovering) + if isHovering then + self:_handleMousePress(element, mx, my, button) + end + else + -- Button is still pressed - check for drag + self:_handleMouseDrag(element, mx, my, button, isHovering) + end + elseif wasPressed then + -- Button was just released + -- Only fire click and release events if mouse is still hovering AND element is active + -- (not occluded by another element) + if isHovering and isActiveElement then + self:_handleMouseRelease(element, mx, my, button) + else + -- Mouse left before release OR element is occluded - just clear the pressed state without firing events + self._pressed[button] = false + self._dragStartX[button] = nil + self._dragStartY[button] = nil + end + end + end + end + + -- After processing events, reset pressed states for buttons that are no longer held + -- This handles the case where mouse leaves while button is held, then released + if not isHovering and not isDragging then + for _, button in ipairs({ 1, 2, 3 }) do + if self._pressed[button] and not love.mouse.isDown(button) then + self._pressed[button] = false + self._dragStartX[button] = nil + self._dragStartY[button] = nil + end + end + end + + -- Stop performance timing + if EventHandler._Performance and EventHandler._Performance.enabled then + EventHandler._Performance:stopTimer("event_mouse") + end +end + +--- Handle mouse button press +---@param element Element The parent element +---@param mx number Mouse X position +---@param my number Mouse Y position +---@param button number Mouse button (1=left, 2=right, 3=middle) +function EventHandler:_handleMousePress(element, mx, my, button) + -- Check if press is on scrollbar first (skip if already handled) + if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then + if element:_handleScrollbarPress(mx, my, button) then + -- Scrollbar consumed the event, mark as pressed to prevent onEvent + self._pressed[button] = true + self._scrollbarPressHandled = true + return + end + end + + -- Fire press event + local modifiers = EventHandler._utils.getModifiers() + local pressEvent = EventHandler._InputEvent.new({ + type = "press", + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = 1, + }) + self:_invokeCallback(element, pressEvent) + + self._pressed[button] = true + + -- On left click, set keyboard focus to any focusable element (not just editable). + -- Clear the focus indicator since mouse navigation doesn't use it. + local isFocusable + if type(element.isFocusable) == "function" then + isFocusable = element:isFocusable() + else + isFocusable = (element.editable == true) + or (type(element.onEvent) == "function") + or element._selectState ~= nil + or element.selectOption ~= nil + end + + if button == 1 and EventHandler._Context and isFocusable then + EventHandler._Context.setFocused(element) + -- Hide focus indicator - it's only for keyboard navigation + if EventHandler._FocusIndicator then + EventHandler._FocusIndicator.setFocused(nil) + end + end + -- Set mouse down position for text selection on left click + if button == 1 and element._textEditor then + element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my) + element._textDragOccurred = false -- Reset drag flag on press + end + + -- Record drag start position per button + self._dragStartX[button] = mx + self._dragStartY[button] = my + self._lastMouseX[button] = mx + self._lastMouseY[button] = my +end + +--- Handle mouse drag (while button is pressed and mouse moves) +---@param element Element The parent element +---@param mx number Mouse X position +---@param my number Mouse Y position +---@param button number Mouse button +---@param isHovering boolean Whether mouse is over element +function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering) + local lastX = self._lastMouseX[button] or mx + local lastY = self._lastMouseY[button] or my + + if lastX ~= mx or lastY ~= my then + -- Handle scrollbar drag if scrollbar was pressed + if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then + element:_handleScrollbarDrag(mx, my) + self._lastMouseX[button] = mx + self._lastMouseY[button] = my + return -- Don't process other drag events while dragging scrollbar + end + + -- Mouse has moved - fire drag event only if still hovering + if isHovering then + local modifiers = EventHandler._utils.getModifiers() + local dx = mx - self._dragStartX[button] + local dy = my - self._dragStartY[button] + + local dragEvent = EventHandler._InputEvent.new({ + type = "drag", + button = button, + x = mx, + y = my, + dx = dx, + dy = dy, + modifiers = modifiers, + clickCount = 1, + }) + self:_invokeCallback(element, dragEvent) + end + + -- Handle text selection drag for editable elements + if button == 1 and element.editable and element._focused and element._handleTextDrag then + element:_handleTextDrag(mx, my) + end + + -- Update last known position for this button + self._lastMouseX[button] = mx + self._lastMouseY[button] = my + end +end + +--- Handle mouse button release +---@param mx number Mouse X position +---@param my number Mouse Y position +---@param button number Mouse button +function EventHandler:_handleMouseRelease(element, mx, my, button) + local currentTime = love.timer.getTime() + local modifiers = EventHandler._utils.getModifiers() + + -- Handle scrollbar release if scrollbar was pressed + if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then + element:_handleScrollbarRelease(button) + self._scrollbarPressHandled = false -- Reset flag + self._pressed[button] = false + self._dragStartX[button] = nil + self._dragStartY[button] = nil + return -- Don't process click events for scrollbar release + end + + -- Determine click count (double-click detection) + local clickCount + local doubleClickThreshold = 0.3 -- 300ms for double-click + + if + self._lastClickTime + and self._lastClickButton == button + and (currentTime - self._lastClickTime) < doubleClickThreshold + then + clickCount = self._clickCount + 1 + else + clickCount = 1 + end + + self._clickCount = clickCount + self._lastClickTime = currentTime + self._lastClickButton = button + + -- Determine event type based on button + local eventType = "click" + if button == 2 then + eventType = "rightclick" + elseif button == 3 then + eventType = "middleclick" + end + + -- Fire click event + local clickEvent = EventHandler._InputEvent.new({ + type = eventType, + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = clickCount, + }) + self:_invokeCallback(element, clickEvent) + + self._pressed[button] = false + + -- Clean up drag tracking + self._dragStartX[button] = nil + self._dragStartY[button] = nil + + -- Clean up text selection drag tracking + if button == 1 then + element._mouseDownPosition = nil + end + + -- Focus editable elements on left click + if button == 1 and element.editable then + -- Only focus if not already focused (to avoid moving cursor to end) + local wasFocused = element:isFocused() + if not wasFocused then + element:focus() + end + + -- Handle text click for cursor positioning and word selection + -- Only process click if no text drag occurred (to preserve drag selection) + if element._handleTextClick and not element._textDragOccurred then + element:_handleTextClick(mx, my, clickCount) + end + + -- Reset drag flag after release + element._textDragOccurred = false + end + + -- Fire release event + local releaseEvent = EventHandler._InputEvent.new({ + type = "release", + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = clickCount, + }) + self:_invokeCallback(element, releaseEvent) + + if button == 1 and element._handleSelectRelease then + element:_handleSelectRelease() + end +end + +--- Process touch events in the update cycle +---@param element Element The parent element +function EventHandler:processTouchEvents(element) + -- Start performance timing + if EventHandler._Performance and EventHandler._Performance.enabled then + EventHandler._Performance:startTimer("event_touch") + end + + -- Check if element can process events + local canProcessEvents = ( + element.onEvent + or self.onEvent + or element.onTouchEvent + or self.onTouchEvent + or element.editable + ) + and not element.disabled + and self.touchEnabled + + if not canProcessEvents then + if EventHandler._Performance and EventHandler._Performance.enabled then + EventHandler._Performance:stopTimer("event_touch") + end + return + end + + local bx = element.x + local by = element.y + local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) + + -- Get current active touches from LÖVE + local activeTouches = {} + local touches = love.touch.getTouches() + for _, id in ipairs(touches) do + activeTouches[tostring(id)] = true + end + + -- Count active tracked touches for multi-touch filtering + local trackedTouchCount = 0 + for _ in pairs(self._touches) do + trackedTouchCount = trackedTouchCount + 1 + end + + -- Process active touches + for _, id in ipairs(touches) do + local touchId = tostring(id) + local tx, ty = love.touch.getPosition(id) + local pressure = 1.0 -- LÖVE doesn't provide pressure by default + + -- Check if touch is within element bounds + local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh + + if isInside then + if not self._touches[touchId] then + -- Multi-touch filtering: reject new touches when multiTouchEnabled=false + -- and we already have an active touch + if self.multiTouchEnabled or trackedTouchCount == 0 then + -- New touch began + self:_handleTouchBegan(element, touchId, tx, ty, pressure) + trackedTouchCount = trackedTouchCount + 1 + end + else + -- Touch moved + self:_handleTouchMoved(element, touchId, tx, ty, pressure) + end + elseif self._touches[touchId] then + -- Touch moved outside or ended + if activeTouches[touchId] then + -- Still active but outside - fire moved event + self:_handleTouchMoved(element, touchId, tx, ty, pressure) + else + -- Touch ended + self:_handleTouchEnded(element, touchId, tx, ty, pressure) + end + end + end + + -- Check for ended touches (touches that were tracked but are no longer active) + for touchId, _ in pairs(self._touches) do + if not activeTouches[touchId] then + -- Touch ended or cancelled + local lastPos = self._lastTouchPositions[touchId] + if lastPos then + self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0) + else + -- Cleanup orphaned touch + self:_cleanupTouch(touchId) + end + end + end + + -- Stop performance timing + if EventHandler._Performance and EventHandler._Performance.enabled then + EventHandler._Performance:stopTimer("event_touch") + end +end + +--- Handle touch began event +---@param element Element The parent element +---@param touchId string Touch identifier +---@param x number Touch X position +---@param y number Touch Y position +---@param pressure number Touch pressure (0-1) +function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure) + -- Create touch state + self._touches[touchId] = { + x = x, + y = y, + pressure = pressure, + timestamp = love.timer.getTime(), + phase = "began", + } + + -- Record start position + self._touchStartPositions[touchId] = { x = x, y = y } + self._lastTouchPositions[touchId] = { x = x, y = y } + + -- Initialize touch history + self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } } + + -- Create and fire touch press event + local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "began", pressure) + touchEvent.type = "touchpress" + touchEvent.dx = 0 + touchEvent.dy = 0 + self:_invokeCallback(element, touchEvent) + self:_invokeTouchCallback(element, touchEvent) +end + +--- Handle touch moved event +---@param element Element The parent element +---@param touchId string Touch identifier +---@param x number Touch X position +---@param y number Touch Y position +---@param pressure number Touch pressure (0-1) +function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure) + local touchState = self._touches[touchId] + + if not touchState then + -- Touch not tracked, ignore + return + end + + local lastPos = self._lastTouchPositions[touchId] + if not lastPos or lastPos.x ~= x or lastPos.y ~= y then + -- Touch position changed + local startPos = self._touchStartPositions[touchId] + local dx = x - startPos.x + local dy = y - startPos.y + + -- Update touch state + touchState.x = x + touchState.y = y + touchState.pressure = pressure + touchState.phase = "moved" + + -- Update last position + self._lastTouchPositions[touchId] = { x = x, y = y } + + -- Add to touch history (keep last 5 positions) + local history = self._touchHistory[touchId] or {} + table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() }) + if #history > 5 then + table.remove(history, 1) + end + self._touchHistory[touchId] = history + + -- Create and fire touch move event + local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "moved", pressure) + touchEvent.type = "touchmove" + touchEvent.dx = dx + touchEvent.dy = dy + self:_invokeCallback(element, touchEvent) + self:_invokeTouchCallback(element, touchEvent) + end +end + +--- Handle touch ended event +---@param element Element The parent element +---@param touchId string Touch identifier +---@param x number Touch X position +---@param y number Touch Y position +---@param pressure number Touch pressure (0-1) +function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure) + local touchState = self._touches[touchId] + + if not touchState then + -- Touch not tracked, ignore + return + end + + local startPos = self._touchStartPositions[touchId] + local dx = x - startPos.x + local dy = y - startPos.y + + -- Create and fire touch release event + local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "ended", pressure) + touchEvent.type = "touchrelease" + touchEvent.dx = dx + touchEvent.dy = dy + self:_invokeCallback(element, touchEvent) + self:_invokeTouchCallback(element, touchEvent) + + -- Cleanup touch state + self:_cleanupTouch(touchId) +end + +--- Cleanup touch state +---@param touchId string Touch ID +function EventHandler:_cleanupTouch(touchId) + self._touches[touchId] = nil + self._touchStartPositions[touchId] = nil + self._lastTouchPositions[touchId] = nil + self._touchHistory[touchId] = nil +end + +--- Get active touches on this element +---@return table Active touches +function EventHandler:getActiveTouches() + return self._touches +end + +--- Reset scrollbar press flag (called each frame) +function EventHandler:resetScrollbarPressFlag() + self._scrollbarPressHandled = false +end + +--- Check if any mouse button is pressed +---@return boolean True if any button is pressed +function EventHandler:isAnyButtonPressed() + for _, pressed in pairs(self._pressed) do + if pressed then + return true + end + end + return false +end + +--- Check if a specific button is pressed +---@param button number Mouse button (1=left, 2=right, 3=middle) +---@return boolean True if button is pressed +function EventHandler:isButtonPressed(button) + return self._pressed[button] == true +end + +--- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true +---@param element Element The element that triggered the event +---@param event InputEvent The event data +function EventHandler:_invokeCallback(element, event) + -- Read onEvent from element (source of truth), fallback to handler cache for backwards compat + local callback = element.onEvent or self.onEvent + if not callback then + return + end + + if self.onEventDeferred then + -- Get FlexLove module to defer the callback + local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] + if FlexLove and FlexLove.deferCallback then + FlexLove.deferCallback(function() + callback(element, event) + end) + else + EventHandler._ErrorHandler:error("EventHandler", "SYS_003", { + eventType = event.type, + }) + end + else + callback(element, event) + end +end + +--- Invoke the onTouchEvent callback, optionally deferring it +---@param element Element The element that triggered the event +---@param event InputEvent The touch event data +function EventHandler:_invokeTouchCallback(element, event) + -- Read onTouchEvent from element (source of truth), fallback to handler cache for backwards compat + local callback = element.onTouchEvent or self.onTouchEvent + if not callback then + return + end + + if self.onTouchEventDeferred then + local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] + if FlexLove and FlexLove.deferCallback then + FlexLove.deferCallback(function() + callback(element, event) + end) + else + EventHandler._ErrorHandler:error("EventHandler", "SYS_003", { + eventType = event.type, + }) + end + else + callback(element, event) + end +end + +--- Invoke the onGesture callback, optionally deferring it +---@param element Element The element that triggered the event +---@param gesture table The gesture data from GestureRecognizer +function EventHandler:_invokeGestureCallback(element, gesture) + -- Read onGesture from element (source of truth), fallback to handler cache for backwards compat + local callback = element.onGesture or self.onGesture + if not callback then + return + end + + if self.onGestureDeferred then + local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] + if FlexLove and FlexLove.deferCallback then + FlexLove.deferCallback(function() + callback(element, gesture) + end) + else + EventHandler._ErrorHandler:error("EventHandler", "SYS_003", { + gestureType = gesture.type, + }) + end + else + callback(element, gesture) + end +end + +return EventHandler diff --git a/libs/flexlove/modules/FocusIndicator.lua b/libs/flexlove/modules/FocusIndicator.lua new file mode 100644 index 00000000..ebbac8ad --- /dev/null +++ b/libs/flexlove/modules/FocusIndicator.lua @@ -0,0 +1,232 @@ +local packageName = ... or "FocusIndicator" +local modulePath = packageName:match("(.-)[^%.]+$") + +local function req(name) + return require(modulePath .. name) +end + +local FocusIndicator = {} + +--- Configuration +---@type KeyboardNavigationFocusIndicatorConfig +FocusIndicator.config = { + enabled = true, + + --- Custom draw function to override default rendering + ---@type function|nil + --- Called with: element, bounds, style - return true to skip default drawing + draw = nil, + + -- Appearance + color = { 0.2, 0.6, 1.0, 0.8 }, -- Blue with 80% opacity + lineWidth = 2, + inset = -3, -- Negative value extends beyond element + borderRadius = 4, + + -- Animation + animationDuration = 0.15, -- Seconds for focus animation + pulseEnabled = false, -- Enable pulsing animation + pulseDuration = 1.0, -- Seconds per pulse cycle + pulseScaleMin = 0.95, -- Minimum scale during pulse + pulseScaleMax = 1.05, -- Maximum scale during pulse +} + +--- State +FocusIndicator._focusedElement = nil +FocusIndicator._animationProgress = 0 +FocusIndicator._pulsePhase = 0 +FocusIndicator._hidden = true +FocusIndicator._deps = nil + +--- Initialize FocusIndicator module +---@param deps table Dependencies table containing Context and Color modules +---@field deps.Context table Context module for getting focused element +---@field deps.Color table Color module for color manipulation +function FocusIndicator.init(deps) + FocusIndicator._deps = deps + FocusIndicator._Context = deps.Context + FocusIndicator._Color = deps.Color +end + +--- Update animation state for entrance and pulse effects +---@param dt number Delta time in seconds since last frame +function FocusIndicator:update(dt) + if not FocusIndicator.config.enabled then + return + end + + -- Update focus entrance animation + if FocusIndicator._animationProgress < 1 then + FocusIndicator._animationProgress = + math.min(1, FocusIndicator._animationProgress + (dt / FocusIndicator.config.animationDuration)) + end + + -- Update pulse animation + if FocusIndicator.config.pulseEnabled then + FocusIndicator._pulsePhase = (FocusIndicator._pulsePhase + dt) % FocusIndicator.config.pulseDuration + end +end + +--- Set the focused element to render indicator around +---@param element Element? The element to show focus indicator around, or nil to hide +function FocusIndicator.setFocused(element) + FocusIndicator._focusedElement = element + FocusIndicator._hidden = element == nil + -- Reset animation when focus changes + if element then + FocusIndicator._animationProgress = 0 + end +end + +--- Get the current scale factor for animations +--- Combines entrance scale (0.8 to 1.0) with optional pulse scale +---@return number Scale factor (typically 0.8-1.05 range) +function FocusIndicator:getScale() + local scale = 1 + + -- Apply entrance animation (scale up from 0.8) + local entranceScale = 0.8 + (0.2 * FocusIndicator._animationProgress) + scale = scale * entranceScale + + -- Apply pulse animation + if FocusIndicator.config.pulseEnabled then + local pulseProgress = FocusIndicator._pulsePhase / FocusIndicator.config.pulseDuration + -- Smooth sine wave pulse + local pulseScale = FocusIndicator.config.pulseScaleMin + + (FocusIndicator.config.pulseScaleMax - FocusIndicator.config.pulseScaleMin) + * (0.5 + 0.5 * math.sin(2 * math.pi * pulseProgress)) + scale = scale * pulseScale + end + + return scale +end + +--- Get the current opacity for the indicator +--- Applies entrance animation fade-in to the configured alpha +---@return number Alpha value (0-1 range) +function FocusIndicator:getOpacity() + -- Fade in on focus + return FocusIndicator.config.color[4] * FocusIndicator._animationProgress +end + +--- Draw the focus indicator around the focused element +--- Renders a rounded rectangle border, or calls custom draw function if configured +--- Should be called from within love.draw() after all elements are drawn +function FocusIndicator:draw() + if not FocusIndicator.config.enabled then + return + end + + if FocusIndicator._hidden then + return + end + + -- In immediate mode the stored element reference is stale (recreated every frame). + -- Always resolve through Context so we get the live object with up-to-date positions. + local element + if FocusIndicator._Context then + element = FocusIndicator._Context.getFocused() + else + element = FocusIndicator._focusedElement + end + + if not element then + return + end + + -- Get element dimensions (use border-box size which includes padding) + local x = element.x or 0 + local y = element.y or 0 + local w = element._borderBoxWidth + or (element.width + (element.padding and (element.padding.left + element.padding.right) or 0)) + local h = element._borderBoxHeight + or (element.height + (element.padding and (element.padding.top + element.padding.bottom) or 0)) + + if w == 0 or h == 0 then + return + end + + -- Calculate indicator dimensions with inset and scale + local inset = FocusIndicator.config.inset + local scale = self:getScale() + + local indicatorX = x + inset + local indicatorY = y + inset + local indicatorW = w - 2 * inset + local indicatorH = h - 2 * inset + + -- Center the scale around the element + local offsetX = (indicatorW * (1 - scale)) / 2 + local offsetY = (indicatorH * (1 - scale)) / 2 + + indicatorX = indicatorX + offsetX + indicatorY = indicatorY + offsetY + indicatorW = indicatorW * scale + indicatorH = indicatorH * scale + + -- Get color with animated opacity + local r, g, b = FocusIndicator.config.color[1], FocusIndicator.config.color[2], FocusIndicator.config.color[3] + local a = self:getOpacity() + + -- Build style table for custom draw callback + local bounds = { + x = indicatorX, + y = indicatorY, + width = indicatorW, + height = indicatorH, + } + + local style = { + color = { r = r, g = g, b = b, a = a }, + lineWidth = FocusIndicator.config.lineWidth, + borderRadius = FocusIndicator.config.borderRadius, + scale = scale, + opacity = a, + } + + -- Check for custom draw callback + if FocusIndicator.config.draw then + local skipDefault = FocusIndicator.config.draw(element, bounds, style) + if skipDefault then + return + end + end + + -- Save current love.graphics state + local prevBlend, prevAlphaMode = love.graphics.getBlendMode() + local prevR, prevG, prevB, prevA = love.graphics.getColor() + local prevLineWidth = love.graphics.getLineWidth() + + -- Set blend mode for transparency + love.graphics.setBlendMode("alpha") + + -- Draw rounded rectangle border + love.graphics.setColor(r, g, b, a) + love.graphics.setLineWidth(FocusIndicator.config.lineWidth) + + -- Draw the rounded rectangle border + local borderRadius = FocusIndicator.config.borderRadius + love.graphics.rectangle("line", indicatorX, indicatorY, indicatorW, indicatorH, borderRadius) + + -- Restore love.graphics state + love.graphics.setBlendMode(prevBlend, prevAlphaMode) + love.graphics.setColor(prevR, prevG, prevB, prevA) + love.graphics.setLineWidth(prevLineWidth) +end + +--- Set the indicator color +---@param r number Red component (0-1 range) +---@param g number Green component (0-1 range) +---@param b number Blue component (0-1 range) +---@param a number|nil Alpha component (0-1 range), defaults to current alpha if omitted +function FocusIndicator.setColor(r, g, b, a) + FocusIndicator.config.color = { r, g, b, a or FocusIndicator.config.color[4] } +end + +--- Set the stroke width for the indicator border +---@param width number Line width in pixels +function FocusIndicator.setLineWidth(width) + FocusIndicator.config.lineWidth = width +end + +return FocusIndicator diff --git a/libs/flexlove/modules/FontCache.lua b/libs/flexlove/modules/FontCache.lua new file mode 100644 index 00000000..7aef441c --- /dev/null +++ b/libs/flexlove/modules/FontCache.lua @@ -0,0 +1,269 @@ +local modulePath = (...):match("(.-)[^%.]+$") +local function req(name) + return require(modulePath .. name) +end + +-- Font cache with LRU eviction, font resolution, and cache management. +-- `ErrorHandler` and `resolveImagePath` are injected via init() to avoid +-- a cross-import into utils (utils re-exports the cache via aliases). + +-- Font cache with LRU eviction +local FONT_CACHE = {} +local FONT_CACHE_MAX_SIZE = 50 +local FONT_CACHE_STATS = { + hits = 0, + misses = 0, + evictions = 0, + size = 0, +} + +local ErrorHandler = nil +local resolveImagePath = nil + +--- Initialize dependencies +---@param deps table Dependencies: { ErrorHandler = ErrorHandler, resolveImagePath = function } +local function init(deps) + if type(deps) == "table" then + ErrorHandler = deps.ErrorHandler + resolveImagePath = deps.resolveImagePath + end +end + +-- LRU tracking: each entry has {font, lastUsed, accessCount} +local function updateCacheAccess(cacheKey) + local entry = FONT_CACHE[cacheKey] + if entry then + entry.lastUsed = love.timer.getTime() + entry.accessCount = entry.accessCount + 1 + end +end + +local function evictLRU() + local oldestKey = nil + local oldestTime = math.huge + + for key, entry in pairs(FONT_CACHE) do + -- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed) + if type(entry) == "table" and entry.lastUsed then + if entry.lastUsed < oldestTime then + oldestTime = entry.lastUsed + oldestKey = key + end + end + end + + if oldestKey then + FONT_CACHE[oldestKey] = nil + FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1 + FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1 + end +end + +--- Create or get a font from cache +---@param size number +---@param fontPath string? +---@return love.Font +function FONT_CACHE.get(size, fontPath) + -- Bucket font sizes for better cache reuse (reduces unique cache entries) + -- Small sizes (< 20): round to nearest 2 + -- Medium sizes (20-40): round to nearest 4 + -- Large sizes (> 40): round to nearest 8 + if size < 20 then + size = math.floor((size + 1) / 2) * 2 + elseif size < 40 then + size = math.floor((size + 2) / 4) * 4 + else + size = math.floor((size + 4) / 8) * 8 + end + + local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size)) + + if FONT_CACHE[cacheKey] then + -- Cache hit + FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1 + updateCacheAccess(cacheKey) + return FONT_CACHE[cacheKey].font + end + + -- Cache miss + FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1 + + local font + if fontPath then + local resolvedPath = resolveImagePath(fontPath) + local success, result = pcall(love.graphics.newFont, resolvedPath, size) + if success then + font = result + else + if ErrorHandler then + ErrorHandler:warn("utils", "RES_004", { + resourceType = "font", + path = fontPath, + }) + end + font = love.graphics.newFont(size) + end + else + font = love.graphics.newFont(size) + end + + -- Add to cache with LRU metadata + FONT_CACHE[cacheKey] = { + font = font, + lastUsed = love.timer.getTime(), + accessCount = 1, + } + FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1 + + -- Evict if cache is full + if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then + evictLRU() + end + + return font +end + +--- Get font for text size (cached) +---@param textSize number? +---@param fontPath string? +---@return love.Font +function FONT_CACHE.getFont(textSize, fontPath) + if textSize then + return FONT_CACHE.get(textSize, fontPath) + else + return love.graphics.getFont() + end +end + +-- Font resolution utilities + +--- Resolve font path from fontFamily and theme +---@param fontFamily string? Font family name or direct path +---@param themeComponent string? Theme component name +---@param themeManager table? ThemeManager instance +---@return string? Resolved font path or nil +local function resolveFontPath(fontFamily, themeComponent, themeManager) + if fontFamily then + -- Check if fontFamily is a theme font name + local themeToUse = themeManager and themeManager:getTheme() + if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then + return themeToUse.fonts[fontFamily] + else + -- Treat as direct path to font file + return fontFamily + end + elseif themeComponent and themeManager then + -- If using themeComponent but no fontFamily specified, check for default font in theme + return themeManager:getDefaultFontFamily() + end + return nil +end + +--- Get font for element (resolves from theme or fontFamily) +---@param textSize number? Text size in pixels +---@param fontFamily string? Font family name or direct path +---@param themeComponent string? Theme component name +---@param themeManager table? ThemeManager instance +---@return love.Font +local function getFont(textSize, fontFamily, themeComponent, themeManager) + local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager) + return FONT_CACHE.getFont(textSize, fontPath) +end + +-- Font cache management + +--- Get font cache statistics +---@return table stats {hits, misses, evictions, size, hitRate} +local function getFontCacheStats() + local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses + local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0 + return { + hits = FONT_CACHE_STATS.hits, + misses = FONT_CACHE_STATS.misses, + evictions = FONT_CACHE_STATS.evictions, + size = FONT_CACHE_STATS.size, + hitRate = hitRate, + } +end + +--- Set maximum font cache size +---@param maxSize number Maximum number of fonts to cache +local function setFontCacheSize(maxSize) + FONT_CACHE_MAX_SIZE = math.max(1, maxSize) + + -- Evict entries if cache is now over limit + while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do + evictLRU() + end +end + +--- Clear font cache +local function clearFontCache() + -- Clear cache entries but preserve methods (get, getFont) + for key, entry in pairs(FONT_CACHE) do + if type(entry) == "table" and entry.lastUsed then + FONT_CACHE[key] = nil + end + end + FONT_CACHE_STATS.size = 0 + FONT_CACHE_STATS.evictions = 0 +end + +--- Preload font at multiple sizes +---@param fontPath string? Path to font file (nil for default font) +---@param sizes table Array of font sizes to preload +local function preloadFont(fontPath, sizes) + for _, size in ipairs(sizes) do + -- Round size to reduce cache entries + size = math.floor(size + 0.5) + + local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size)) + + if not FONT_CACHE[cacheKey] then + local font + if fontPath then + local resolvedPath = resolveImagePath(fontPath) + local success, result = pcall(love.graphics.newFont, resolvedPath, size) + if success then + font = result + else + font = love.graphics.newFont(size) + end + else + font = love.graphics.newFont(size) + end + + FONT_CACHE[cacheKey] = { + font = font, + lastUsed = love.timer.getTime(), + accessCount = 1, + } + FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1 + FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1 + + -- Evict if cache is full + if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then + evictLRU() + end + end + end +end + +--- Reset font cache statistics +local function resetFontCacheStats() + FONT_CACHE_STATS.hits = 0 + FONT_CACHE_STATS.misses = 0 + FONT_CACHE_STATS.evictions = 0 +end + +return { + FONT_CACHE = FONT_CACHE, + init = init, + resolveFontPath = resolveFontPath, + getFont = getFont, + getFontCacheStats = getFontCacheStats, + setFontCacheSize = setFontCacheSize, + clearFontCache = clearFontCache, + preloadFont = preloadFont, + resetFontCacheStats = resetFontCacheStats, +} diff --git a/libs/flexlove/modules/GestureRecognizer.lua b/libs/flexlove/modules/GestureRecognizer.lua new file mode 100644 index 00000000..5e324f09 --- /dev/null +++ b/libs/flexlove/modules/GestureRecognizer.lua @@ -0,0 +1,583 @@ +---@class GestureRecognizer +---@field _touches table -- Current touch states +---@field _gestureStates table -- Active gesture states +---@field _config table -- Gesture configuration (thresholds, etc.) +---@field _InputEvent table +---@field _utils table +local GestureRecognizer = {} +GestureRecognizer.__index = GestureRecognizer + +-- Gesture types enum +local GestureType = { + TAP = "tap", + DOUBLE_TAP = "double_tap", + LONG_PRESS = "long_press", + SWIPE = "swipe", + PAN = "pan", + PINCH = "pinch", + ROTATE = "rotate", +} + +-- Gesture states +local GestureState = { + POSSIBLE = "possible", + BEGAN = "began", + CHANGED = "changed", + ENDED = "ended", + CANCELLED = "cancelled", + FAILED = "failed", +} + +-- Default configuration +local defaultConfig = { + -- Tap gesture + tapMaxDuration = 0.3, -- seconds + tapMaxMovement = 10, -- pixels + + -- Double-tap gesture + doubleTapInterval = 0.3, -- seconds between taps + + -- Long-press gesture + longPressMinDuration = 0.5, -- seconds + longPressMaxMovement = 10, -- pixels + + -- Swipe gesture + swipeMinDistance = 50, -- pixels + swipeMaxDuration = 0.2, -- seconds + swipeMinVelocity = 200, -- pixels per second + + -- Pan gesture + panMinMovement = 5, -- pixels to start pan + + -- Pinch gesture + pinchMinScaleChange = 0.1, -- 10% scale change + + -- Rotate gesture + rotateMinAngleChange = 5, -- degrees +} + +--- Create a new GestureRecognizer instance +---@param config table? Optional configuration options +---@param deps table Dependencies {InputEvent, utils} +---@return GestureRecognizer +function GestureRecognizer.new(config, deps) + config = config or {} + + local self = setmetatable({}, GestureRecognizer) + + self._InputEvent = deps.InputEvent + self._utils = deps.utils + + -- Merge configuration with defaults + self._config = {} + for key, value in pairs(defaultConfig) do + self._config[key] = config[key] or value + end + + self._touches = {} + self._gestureStates = { + tap = nil, + doubleTap = { lastTapTime = 0, tapCount = 0 }, + longPress = {}, + swipe = {}, + pan = {}, + pinch = {}, + rotate = {}, + } + + return self +end + +--- Update gesture recognizer with touch event +---@param event InputEvent Touch event +function GestureRecognizer:processTouchEvent(event) + if not event.touchId then + return nil + end + + local touchId = event.touchId + local gestures = {} + + -- Update touch state + if event.type == "touchpress" then + self._touches[touchId] = { + startX = event.x, + startY = event.y, + x = event.x, + y = event.y, + startTime = event.timestamp, + lastTime = event.timestamp, + phase = "began", + } + + -- Initialize gesture detection + self:_detectTapBegan(touchId, event) + self:_detectLongPressBegan(touchId, event) + elseif event.type == "touchmove" then + local touch = self._touches[touchId] + if touch then + touch.x = event.x + touch.y = event.y + touch.lastTime = event.timestamp + touch.phase = "moved" + + -- Update gesture detection + local panGesture = self:_detectPan(touchId, event) + if panGesture then + table.insert(gestures, panGesture) + end + local swipeGesture = self:_detectSwipe(touchId, event) + if swipeGesture then + table.insert(gestures, swipeGesture) + end + + -- Multi-touch gestures + if self:_getTouchCount() >= 2 then + local pinchGesture = self:_detectPinch(event) + if pinchGesture then + table.insert(gestures, pinchGesture) + end + local rotateGesture = self:_detectRotate(event) + if rotateGesture then + table.insert(gestures, rotateGesture) + end + end + end + elseif event.type == "touchrelease" then + local touch = self._touches[touchId] + if touch then + touch.phase = "ended" + + -- Finalize gesture detection + local tapGesture = self:_detectTapEnded(touchId, event) + if tapGesture then + table.insert(gestures, tapGesture) + end + local swipeGesture = self:_detectSwipeEnded(touchId, event) + if swipeGesture then + table.insert(gestures, swipeGesture) + end + local panGesture = self:_detectPanEnded(touchId, event) + if panGesture then + table.insert(gestures, panGesture) + end + + -- Cleanup touch + self._touches[touchId] = nil + end + elseif event.type == "touchcancel" then + -- Cancel all active gestures for this touch + self._touches[touchId] = nil + self:_cancelAllGestures() + end + + return #gestures > 0 and gestures or nil +end + +--- Get number of active touches +---@return number +function GestureRecognizer:_getTouchCount() + local count = 0 + for _ in pairs(self._touches) do + count = count + 1 + end + return count +end + +--- Detect tap gesture began +---@param touchId string +---@param event InputEvent +function GestureRecognizer:_detectTapBegan(touchId, event) + -- Tap detection happens on touch end + -- Just record the touch for now +end + +--- Detect tap gesture ended +---@param touchId string +---@param event InputEvent +function GestureRecognizer:_detectTapEnded(touchId, event) + local touch = self._touches[touchId] + if not touch then + return + end + + local duration = event.timestamp - touch.startTime + local dx = event.x - touch.startX + local dy = event.y - touch.startY + local distance = math.sqrt(dx * dx + dy * dy) + + -- Check if it's a valid tap + if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then + local currentTime = event.timestamp + local doubleTapState = self._gestureStates.doubleTap + + -- Check for double-tap + if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then + doubleTapState.tapCount = doubleTapState.tapCount + 1 + + if doubleTapState.tapCount >= 2 then + -- Fire double-tap gesture + return { + type = GestureType.DOUBLE_TAP, + state = GestureState.ENDED, + x = event.x, + y = event.y, + timestamp = event.timestamp, + } + end + else + doubleTapState.tapCount = 1 + end + + doubleTapState.lastTapTime = currentTime + + -- Fire tap gesture + return { + type = GestureType.TAP, + state = GestureState.ENDED, + x = event.x, + y = event.y, + timestamp = event.timestamp, + } + end +end + +--- Detect long-press gesture began +---@param touchId string +---@param event InputEvent +function GestureRecognizer:_detectLongPressBegan(touchId, event) + -- Long-press detection happens continuously during touch + self._gestureStates.longPress[touchId] = { + startX = event.x, + startY = event.y, + startTime = event.timestamp, + triggered = false, + } +end + +--- Detect pan gesture +---@param touchId string +---@param event InputEvent +---@return table? Gesture event +function GestureRecognizer:_detectPan(touchId, event) + local touch = self._touches[touchId] + if not touch then + return nil + end + + local dx = event.x - touch.startX + local dy = event.y - touch.startY + local distance = math.sqrt(dx * dx + dy * dy) + + local panState = self._gestureStates.pan[touchId] + + if not panState then + -- Check if pan should begin + if distance >= self._config.panMinMovement then + self._gestureStates.pan[touchId] = { + active = true, + lastX = touch.startX, + lastY = touch.startY, + } + panState = self._gestureStates.pan[touchId] + + return { + type = GestureType.PAN, + state = GestureState.BEGAN, + x = event.x, + y = event.y, + dx = dx, + dy = dy, + timestamp = event.timestamp, + } + end + else + -- Pan is active, fire changed event + local panDx = event.x - panState.lastX + local panDy = event.y - panState.lastY + + panState.lastX = event.x + panState.lastY = event.y + + return { + type = GestureType.PAN, + state = GestureState.CHANGED, + x = event.x, + y = event.y, + dx = panDx, + dy = panDy, + totalDx = dx, + totalDy = dy, + timestamp = event.timestamp, + } + end + + return nil +end + +--- Detect pan ended +---@param touchId string +---@param event InputEvent +---@return table? Gesture event +function GestureRecognizer:_detectPanEnded(touchId, event) + local panState = self._gestureStates.pan[touchId] + if panState and panState.active then + self._gestureStates.pan[touchId] = nil + + local touch = self._touches[touchId] + local dx = event.x - touch.startX + local dy = event.y - touch.startY + + return { + type = GestureType.PAN, + state = GestureState.ENDED, + x = event.x, + y = event.y, + dx = dx, + dy = dy, + timestamp = event.timestamp, + } + end + + return nil +end + +--- Detect swipe gesture +---@param touchId string +---@param event InputEvent +function GestureRecognizer:_detectSwipe(touchId, event) + -- Swipe detection happens on touch end +end + +--- Detect swipe ended +---@param touchId string +---@param event InputEvent +---@return table? Gesture event +function GestureRecognizer:_detectSwipeEnded(touchId, event) + local touch = self._touches[touchId] + if not touch then + return nil + end + + local duration = event.timestamp - touch.startTime + local dx = event.x - touch.startX + local dy = event.y - touch.startY + local distance = math.sqrt(dx * dx + dy * dy) + + -- Check if it's a valid swipe + if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then + local velocity = distance / duration + + if velocity >= self._config.swipeMinVelocity then + -- Determine swipe direction + local angle = math.atan2(dy, dx) + local direction = "right" + + if angle >= -math.pi / 4 and angle < math.pi / 4 then + direction = "right" + elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then + direction = "down" + elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then + direction = "up" + else + direction = "left" + end + + return { + type = GestureType.SWIPE, + state = GestureState.ENDED, + x = event.x, + y = event.y, + dx = dx, + dy = dy, + direction = direction, + velocity = velocity, + timestamp = event.timestamp, + } + end + end + + return nil +end + +--- Detect pinch gesture +---@param event InputEvent +---@return table? Gesture event +function GestureRecognizer:_detectPinch(event) + -- Get two touches for pinch + local touches = {} + for touchId, touch in pairs(self._touches) do + table.insert(touches, { id = touchId, touch = touch }) + if #touches >= 2 then + break + end + end + + if #touches < 2 then + return nil + end + + local t1 = touches[1].touch + local t2 = touches[2].touch + + -- Calculate current distance + local currentDx = t2.x - t1.x + local currentDy = t2.y - t1.y + local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy) + + -- Calculate initial distance + local initialDx = t2.startX - t1.startX + local initialDy = t2.startY - t1.startY + local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy) + + if initialDistance == 0 then + return nil + end + + -- Calculate scale + local scale = currentDistance / initialDistance + local pinchState = self._gestureStates.pinch + + if not pinchState.active then + -- Check if pinch should begin + if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then + pinchState.active = true + pinchState.initialScale = scale + pinchState.lastScale = scale + + -- Calculate center point + local centerX = (t1.x + t2.x) / 2 + local centerY = (t1.y + t2.y) / 2 + + return { + type = GestureType.PINCH, + state = GestureState.BEGAN, + scale = scale, + centerX = centerX, + centerY = centerY, + timestamp = event.timestamp, + } + end + else + -- Pinch is active, fire changed event + local centerX = (t1.x + t2.x) / 2 + local centerY = (t1.y + t2.y) / 2 + + local scaleChange = scale - pinchState.lastScale + pinchState.lastScale = scale + + return { + type = GestureType.PINCH, + state = GestureState.CHANGED, + scale = scale, + scaleChange = scaleChange, + centerX = centerX, + centerY = centerY, + timestamp = event.timestamp, + } + end + + return nil +end + +--- Detect rotate gesture +---@param event InputEvent +---@return table? Gesture event +function GestureRecognizer:_detectRotate(event) + -- Get two touches for rotation + local touches = {} + for touchId, touch in pairs(self._touches) do + table.insert(touches, { id = touchId, touch = touch }) + if #touches >= 2 then + break + end + end + + if #touches < 2 then + return nil + end + + local t1 = touches[1].touch + local t2 = touches[2].touch + + -- Calculate current angle + local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x) + + -- Calculate initial angle + local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX) + + -- Calculate rotation (in degrees) + local rotation = (currentAngle - initialAngle) * 180 / math.pi + + local rotateState = self._gestureStates.rotate + + if not rotateState.active then + -- Check if rotation should begin + if math.abs(rotation) >= self._config.rotateMinAngleChange then + rotateState.active = true + rotateState.initialRotation = rotation + rotateState.lastRotation = rotation + + -- Calculate center point + local centerX = (t1.x + t2.x) / 2 + local centerY = (t1.y + t2.y) / 2 + + return { + type = GestureType.ROTATE, + state = GestureState.BEGAN, + rotation = rotation, + centerX = centerX, + centerY = centerY, + timestamp = event.timestamp, + } + end + else + -- Rotation is active, fire changed event + local centerX = (t1.x + t2.x) / 2 + local centerY = (t1.y + t2.y) / 2 + + local rotationChange = rotation - rotateState.lastRotation + rotateState.lastRotation = rotation + + return { + type = GestureType.ROTATE, + state = GestureState.CHANGED, + rotation = rotation, + rotationChange = rotationChange, + centerX = centerX, + centerY = centerY, + timestamp = event.timestamp, + } + end + + return nil +end + +--- Cancel all active gestures +function GestureRecognizer:_cancelAllGestures() + for gestureType, state in pairs(self._gestureStates) do + if type(state) == "table" and state.active then + state.active = false + end + end +end + +--- Reset gesture recognizer state +function GestureRecognizer:reset() + self._touches = {} + self._gestureStates = { + tap = nil, + doubleTap = { lastTapTime = 0, tapCount = 0 }, + longPress = {}, + swipe = {}, + pan = {}, + pinch = { active = false }, + rotate = { active = false }, + } +end + +-- Export gesture types and states +GestureRecognizer.GestureType = GestureType +GestureRecognizer.GestureState = GestureState + +return GestureRecognizer diff --git a/libs/flexlove/modules/Grid.lua b/libs/flexlove/modules/Grid.lua new file mode 100644 index 00000000..d37600c7 --- /dev/null +++ b/libs/flexlove/modules/Grid.lua @@ -0,0 +1,336 @@ +local modulePath = (...):match("(.-)[^%.]+$") +local utils = require(modulePath .. "utils") +local enums = utils.enums +local Units = require(modulePath .. "Units") + +local Positioning = enums.Positioning +local AlignItems = enums.AlignItems + +--- Grid layout with variable column widths / row heights +--- Supports px, %, fr, auto, vw, vh, and calc track sizes +local Grid = {} + +--- Parse a single track spec into {type, value} +--- Uses the Units pipeline for standard CSS units (px, %, vw, vh, calc). +--- Grid-specific types (fr, auto) are handled directly. +---@param spec number|string Track specification: number (px), string ("100px", "50%", "10vw", "1fr", "auto") +---@param availableSize number Container size for % resolution +---@param viewportWidth number Viewport width for vw resolution +---@param viewportHeight number Viewport height for vh resolution +---@return table {type: "px"|"fr"|"auto", value: number} +function Grid._parseTrack(spec, availableSize, viewportWidth, viewportHeight) + -- Handle calc objects (tables with _isCalc flag from FlexLove.calc()) + if type(spec) == "table" then + local resolved = Units.resolve(spec, "calc", viewportWidth, viewportHeight, availableSize) + return { type = "px", value = resolved } + end + + if type(spec) == "number" then + return { type = "px", value = spec } + end + + if type(spec) == "string" then + if spec == "auto" then + return { type = "auto", value = 0 } + end + + -- Check for fr unit (grid-specific, not in Units pipeline) + local numStr, unit = spec:match("^([%-]?[%d%.]+)(.*)$") + if numStr and unit == "fr" then + local num = tonumber(numStr) + if num then + return { type = "fr", value = num } + end + end + + -- Delegate all other units to the Units pipeline (px, %, vw, vh, calc) + local parsedVal, parsedUnit = Units.parse(spec) + local resolved = Units.resolve(parsedVal, parsedUnit, viewportWidth, viewportHeight, availableSize) + return { type = "px", value = resolved } + end + + -- Default: 1fr + return { type = "fr", value = 1 } +end + +--- Build track list from gridColumns/gridRows or fall back to equal 1fr tracks +---@param spec number|table? Track count (number = equal 1fr tracks) or array of track specs (e.g., {"1fr", "2fr", "100px"}) +---@param availableSize number Container size for % resolution +---@param viewportWidth number Viewport width for vw resolution +---@param viewportHeight number Viewport height for vh resolution +---@return table Array of {type, value} track descriptors +function Grid._buildTracks(spec, availableSize, viewportWidth, viewportHeight) + if type(spec) == "table" and #spec > 0 then + local tracks = {} + for i, s in ipairs(spec) do + tracks[i] = Grid._parseTrack(s, availableSize, viewportWidth, viewportHeight) + end + return tracks + end + -- Fallback: equal 1fr tracks + local count = (type(spec) == "number" and spec > 0) and spec or 1 + local tracks = {} + for i = 1, count do + tracks[i] = { type = "fr", value = 1 } + end + return tracks +end + +--- Measure intrinsic content sizes for auto tracks +--- Maps children to their tracks and computes each child's max-content contribution. +--- For children with explicit dimensions (units unit ~= "auto"), uses the original +--- explicit size. For auto-sized children, uses calculated content size. +--- Stores the max per auto track. Matches CSS Grid auto sizing where tracks size +--- to the max-content contribution of their grid items. +---@param tracks table Array of {type, value} track descriptors +---@param children table Array of grid child elements +---@param axis "width"|"height" Dimension axis to measure +function Grid._measureAutoTracks(tracks, children, axis) + local trackSizes = {} + local numTracks = #tracks + + for i, child in ipairs(children) do + local index = i - 1 + local trackIdx = (index % numTracks) + 1 + + local intrinsicSize + if axis == "width" then + local unit = child.units and child.units.width and child.units.width.unit + if unit and unit ~= "auto" then + -- Explicit width: use original value + padding (not stretched border-box) + intrinsicSize = (child.units.width.value or 0) + child.padding.left + child.padding.right + else + -- Auto-sized: use calculated content size + intrinsicSize = child:calculateAutoWidth() + end + else + local unit = child.units and child.units.height and child.units.height.unit + if unit and unit ~= "auto" then + intrinsicSize = (child.units.height.value or 0) + child.padding.top + child.padding.bottom + else + intrinsicSize = child:calculateAutoHeight() + end + end + + if intrinsicSize > 0 then + trackSizes[trackIdx] = math.max(trackSizes[trackIdx] or 0, intrinsicSize) + end + end + + -- Apply measured sizes to auto tracks + for i, track in ipairs(tracks) do + if track.type == "auto" and trackSizes[i] then + track.value = trackSizes[i] + end + end +end + +--- Resolve track sizes: auto (content) first, then px (fixed), then fr (remaining) +--- CSS Grid algorithm: +--- 1. auto tracks size to their content (max-content) — measured by _measureAutoTracks +--- 2. px tracks consume their fixed size +--- 3. fr tracks consume remaining free space proportionally +--- 4. If no fr tracks exist, auto tracks share remaining space equally +--- Mutates tracks in-place, converting all to {type="px", value=number} +---@param tracks table Array of {type, value} track descriptors +---@param availableSize number Total space available for tracks +---@param gap number Gap between tracks +function Grid._resolveTracks(tracks, availableSize, gap) + local count = #tracks + local totalGaps = (count > 1 and (count - 1) * gap) or 0 + local remaining = math.max(0, availableSize - totalGaps) + + -- Pass 1: Treat auto tracks as fixed (content-measured) and subtract + for _, track in ipairs(tracks) do + if track.type == "px" then + remaining = remaining - track.value + elseif track.type == "auto" then + remaining = remaining - math.max(0, track.value) + end + end + + remaining = math.max(0, remaining) + + -- Pass 2: Count fr shares + local totalFr = 0 + local autoCount = 0 + for _, track in ipairs(tracks) do + if track.type == "fr" then + totalFr = totalFr + track.value + elseif track.type == "auto" then + autoCount = autoCount + 1 + end + end + + -- Pass 3: Distribute remaining space + if totalFr > 0 then + -- fr tracks consume all remaining free space + local frUnit = remaining / totalFr + for _, track in ipairs(tracks) do + if track.type == "fr" then + track.value = frUnit * track.value + track.type = "px" + end + end + elseif autoCount > 0 then + -- No fr tracks: auto tracks share remaining space equally (grow beyond content) + local extraPerAuto = math.max(0, remaining) / autoCount + for _, track in ipairs(tracks) do + if track.type == "auto" then + track.value = track.value + extraPerAuto + track.type = "px" + end + end + end +end + +--- Layout grid items within a grid container +--- Supports variable column widths and row heights via gridColumns/gridRows (number or track specs) +--- Falls back to equal-sized 1fr tracks when nil +---@param element Element -- Grid container element +function Grid.layoutGridItems(element) + -- Calculate space reserved by absolutely positioned siblings + local reservedLeft = 0 + local reservedRight = 0 + local reservedTop = 0 + local reservedBottom = 0 + + for _, child in ipairs(element.children) do + -- Only consider absolutely positioned children with explicit positioning and display != false + if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then + -- BORDER-BOX MODEL: Use border-box dimensions for space calculations + local childBorderBoxWidth = child:getBorderBoxWidth() + local childBorderBoxHeight = child:getBorderBoxHeight() + + if child.left then + reservedLeft = math.max(reservedLeft, child.left + childBorderBoxWidth) + end + if child.right then + reservedRight = math.max(reservedRight, child.right + childBorderBoxWidth) + end + if child.top then + reservedTop = math.max(reservedTop, child.top + childBorderBoxHeight) + end + if child.bottom then + reservedBottom = math.max(reservedBottom, child.bottom + childBorderBoxHeight) + end + end + end + + -- Calculate available space (accounting for padding and reserved space) + -- BORDER-BOX MODEL: element.width and element.height are already content dimensions + local availableWidth = math.max(0, element.width - reservedLeft - reservedRight) + local availableHeight = math.max(0, element.height - reservedTop - reservedBottom) + + -- Get gaps + local columnGap = element.columnGap or 0 + local rowGap = element.rowGap or 0 + + -- Collect grid children (exclude explicitly absolute and display=false) + local gridChildren = {} + for _, child in ipairs(element.children) do + if not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) and child.display ~= false then + table.insert(gridChildren, child) + end + end + + -- Get viewport dimensions for unit resolution (vw, vh, %) + local vpw, vph = Units.getViewport() + + -- Build tracks, measure auto tracks by content, then resolve sizes + local colTracks = Grid._buildTracks(element.gridColumns, availableWidth, vpw, vph) + local rowTracks = Grid._buildTracks(element.gridRows, availableHeight, vpw, vph) + + Grid._measureAutoTracks(colTracks, gridChildren, "width") + Grid._measureAutoTracks(rowTracks, gridChildren, "height") + + Grid._resolveTracks(colTracks, availableWidth, columnGap) + Grid._resolveTracks(rowTracks, availableHeight, rowGap) + + -- Compute column start positions (for positioning) + local colStarts = {} + local currentX = element.x + element.padding.left + reservedLeft + for col = 1, #colTracks do + colStarts[col] = currentX + currentX = currentX + colTracks[col].value + columnGap + end + + local rowStarts = {} + local currentY = element.y + element.padding.top + reservedTop + for row = 1, #rowTracks do + rowStarts[row] = currentY + currentY = currentY + rowTracks[row].value + rowGap + end + + local effectiveAlignItems = element.alignItems or AlignItems.STRETCH + + for i, child in ipairs(gridChildren) do + -- Calculate row and column (0-indexed for calculation) + local index = i - 1 + local col = index % #colTracks + local row = math.floor(index / #colTracks) + + if row >= #rowTracks then + break + end + + -- Get resolved cell position and size + local colIdx = col + 1 + local rowIdx = row + 1 + local cellX = colStarts[colIdx] + local cellY = rowStarts[rowIdx] + local cellWidth = colTracks[colIdx].value + local cellHeight = rowTracks[rowIdx].value + + -- Apply alignment within grid cell (default to stretch) + -- BORDER-BOX MODEL: Set border-box dimensions, content area adjusts automatically + if effectiveAlignItems == AlignItems.STRETCH or effectiveAlignItems == "stretch" then + child.x = cellX + child.y = cellY + child._borderBoxWidth = cellWidth + child._borderBoxHeight = cellHeight + child.width = math.max(0, cellWidth - child.padding.left - child.padding.right) + child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom) + -- Disable auto-sizing when stretched by grid + child.autosizing.width = false + child.autosizing.height = false + elseif effectiveAlignItems == AlignItems.CENTER or effectiveAlignItems == "center" then + local childBorderBoxWidth = child:getBorderBoxWidth() + local childBorderBoxHeight = child:getBorderBoxHeight() + child.x = cellX + (cellWidth - childBorderBoxWidth) / 2 + child.y = cellY + (cellHeight - childBorderBoxHeight) / 2 + elseif + effectiveAlignItems == AlignItems.FLEX_START + or effectiveAlignItems == "flex-start" + or effectiveAlignItems == "start" + then + child.x = cellX + child.y = cellY + elseif + effectiveAlignItems == AlignItems.FLEX_END + or effectiveAlignItems == "flex-end" + or effectiveAlignItems == "end" + then + local childBorderBoxWidth = child:getBorderBoxWidth() + local childBorderBoxHeight = child:getBorderBoxHeight() + child.x = cellX + cellWidth - childBorderBoxWidth + child.y = cellY + cellHeight - childBorderBoxHeight + else + child.x = cellX + child.y = cellY + child._borderBoxWidth = cellWidth + child._borderBoxHeight = cellHeight + child.width = math.max(0, cellWidth - child.padding.left - child.padding.right) + child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom) + -- Disable auto-sizing when stretched by grid + child.autosizing.width = false + child.autosizing.height = false + end + + if #child.children > 0 then + child:layoutChildren() + end + end +end + +return Grid diff --git a/libs/flexlove/modules/ImageCache.lua b/libs/flexlove/modules/ImageCache.lua new file mode 100644 index 00000000..206fa59c --- /dev/null +++ b/libs/flexlove/modules/ImageCache.lua @@ -0,0 +1,160 @@ +local modulePath = (...):match("(.-)[^%.]+$") +local function req(name) + return require(modulePath .. name) +end + +local utils = req("utils") + +-- ErrorHandler will be injected via init +local ErrorHandler = nil + +---@class ImageCache +---@field _cache table +local ImageCache = {} +ImageCache._cache = {} + +--- Initialize ImageCache with dependencies +---@param deps table Dependencies table with ErrorHandler +function ImageCache.init(deps) + if deps and deps.ErrorHandler then + ErrorHandler = deps.ErrorHandler + end +end + +--- Load an image from file path with caching +--- Returns cached image if already loaded, otherwise loads and caches it +---@param imagePath string -- Path to image file +---@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false) +---@return love.Image|nil -- Image object or nil on error +---@return string|nil -- Error message if loading failed +function ImageCache.load(imagePath, loadImageData) + if not imagePath or type(imagePath) ~= "string" or imagePath == "" then + return nil, "Invalid image path: path must be a non-empty string" + end + + local normalizedPath = utils.normalizePath(imagePath) + + if ImageCache._cache[normalizedPath] then + return ImageCache._cache[normalizedPath].image, nil + end + + local success, imageOrError = pcall(love.graphics.newImage, normalizedPath) + if not success then + if ErrorHandler then + ErrorHandler:warn("ImageCache", "RES_004", { + resourceType = "image", + path = imagePath, + error = tostring(imageOrError), + }) + end + return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError)) + end + + local image = imageOrError + local imgData = nil + + if loadImageData then + local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath) + if dataSuccess then + imgData = dataOrError + elseif ErrorHandler then + ErrorHandler:warn("ImageCache", "RES_004", { + resourceType = "image data", + path = imagePath, + error = tostring(dataOrError), + }) + end + end + + ImageCache._cache[normalizedPath] = { + image = image, + imageData = imgData, + } + + return image, nil +end + +--- Get a cached image without loading +---@param imagePath string -- Path to image file +---@return love.Image|nil -- Cached image or nil if not found +function ImageCache.get(imagePath) + if not imagePath or type(imagePath) ~= "string" then + return nil + end + + local normalizedPath = utils.normalizePath(imagePath) + local cached = ImageCache._cache[normalizedPath] + return cached and cached.image or nil +end + +--- Get cached ImageData for an image +---@param imagePath string -- Path to image file +---@return love.ImageData|nil -- Cached ImageData or nil if not found +function ImageCache.getImageData(imagePath) + if not imagePath or type(imagePath) ~= "string" then + return nil + end + + local normalizedPath = utils.normalizePath(imagePath) + local cached = ImageCache._cache[normalizedPath] + return cached and cached.imageData or nil +end + +--- Remove a specific image from cache +---@param imagePath string -- Path to image file to remove +---@return boolean -- True if image was removed, false if not found +function ImageCache.remove(imagePath) + if not imagePath or type(imagePath) ~= "string" then + return false + end + + local normalizedPath = utils.normalizePath(imagePath) + if ImageCache._cache[normalizedPath] then + local cached = ImageCache._cache[normalizedPath] + if cached.image then + cached.image:release() + end + if cached.imageData then + cached.imageData:release() + end + ImageCache._cache[normalizedPath] = nil + return true + end + return false +end + +--- Clear all cached images +function ImageCache.clear() + for path, cached in pairs(ImageCache._cache) do + if cached.image then + cached.image:release() + end + if cached.imageData then + cached.imageData:release() + end + end + ImageCache._cache = {} +end + +--- Get cache statistics +---@return {count: number, memoryEstimate: number} -- Cache stats +function ImageCache.getStats() + local count = 0 + local memoryEstimate = 0 + + for path, cached in pairs(ImageCache._cache) do + count = count + 1 + if cached.image then + local w, h = cached.image:getDimensions() + -- Estimate: 4 bytes per pixel (RGBA) + memoryEstimate = memoryEstimate + (w * h * 4) + end + end + + return { + count = count, + memoryEstimate = memoryEstimate, + } +end + +return ImageCache diff --git a/libs/flexlove/modules/ImageRenderer.lua b/libs/flexlove/modules/ImageRenderer.lua new file mode 100644 index 00000000..886c30a2 --- /dev/null +++ b/libs/flexlove/modules/ImageRenderer.lua @@ -0,0 +1,380 @@ +---@class ImageRenderer +local ImageRenderer = {} + +-- ErrorHandler and utils will be injected via init +local ErrorHandler = nil +local utils = nil + +--- Initialize ImageRenderer with dependencies +---@param deps table Dependencies table with ErrorHandler and utils +function ImageRenderer.init(deps) + if deps and deps.ErrorHandler then + ErrorHandler = deps.ErrorHandler + end + if deps and deps.utils then + utils = deps.utils + end +end + +--- Calculate rendering parameters for object-fit modes +--- Returns source and destination rectangles for rendering +---@param imageWidth number -- Natural width of the image +---@param imageHeight number -- Natural height of the image +---@param boundsWidth number -- Width of the bounds to fit within +---@param boundsHeight number -- Height of the bounds to fit within +---@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill") +---@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center") +---@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number} +function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition) + fitMode = fitMode or "fill" + objectPosition = objectPosition or "center center" + + if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then + ErrorHandler:error("ImageRenderer", "VAL_002", { + imageWidth = imageWidth, + imageHeight = imageHeight, + boundsWidth = boundsWidth, + boundsHeight = boundsHeight, + }) + end + + local result = { + sx = 0, -- Source X + sy = 0, -- Source Y + sw = imageWidth, -- Source width + sh = imageHeight, -- Source height + dx = 0, -- Destination X + dy = 0, -- Destination Y + dw = boundsWidth, -- Destination width + dh = boundsHeight, -- Destination height + scaleX = 1, -- Scale factor X + scaleY = 1, -- Scale factor Y + } + + if fitMode == "fill" then + -- Stretch to fill bounds (may distort) + result.scaleX = boundsWidth / imageWidth + result.scaleY = boundsHeight / imageHeight + result.dw = boundsWidth + result.dh = boundsHeight + elseif fitMode == "contain" then + -- Scale to fit within bounds (preserves aspect ratio) + local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight) + result.scaleX = scale + result.scaleY = scale + result.dw = imageWidth * scale + result.dh = imageHeight * scale + + -- Apply object-position for letterbox alignment + local posX, posY = ImageRenderer._parsePosition(objectPosition) + result.dx = (boundsWidth - result.dw) * posX + result.dy = (boundsHeight - result.dh) * posY + elseif fitMode == "cover" then + -- Scale to cover bounds (preserves aspect ratio, may crop) + local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight) + result.scaleX = scale + result.scaleY = scale + + local scaledWidth = imageWidth * scale + local scaledHeight = imageHeight * scale + + -- Apply object-position for crop alignment + local posX, posY = ImageRenderer._parsePosition(objectPosition) + + -- Calculate which part of the scaled image to show + local cropX = (scaledWidth - boundsWidth) * posX + local cropY = (scaledHeight - boundsHeight) * posY + + -- Convert back to source coordinates + result.sx = cropX / scale + result.sy = cropY / scale + result.sw = boundsWidth / scale + result.sh = boundsHeight / scale + + result.dx = 0 + result.dy = 0 + result.dw = boundsWidth + result.dh = boundsHeight + elseif fitMode == "none" then + -- Use natural size (no scaling) + result.scaleX = 1 + result.scaleY = 1 + result.dw = imageWidth + result.dh = imageHeight + + -- Apply object-position + local posX, posY = ImageRenderer._parsePosition(objectPosition) + result.dx = (boundsWidth - imageWidth) * posX + result.dy = (boundsHeight - imageHeight) * posY + elseif fitMode == "scale-down" then + -- Use none or contain, whichever is smaller + if imageWidth <= boundsWidth and imageHeight <= boundsHeight then + -- Image fits naturally, use "none" + return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition) + else + -- Image too large, use "contain" + return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition) + end + else + ErrorHandler:warn("ImageRenderer", "VAL_007", { + fitMode = fitMode, + fallback = "fill", + }) + -- Use 'fill' as fallback + return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "fill", objectPosition) + end + + return result +end + +--- Parse object-position string into normalized coordinates (0-1) +--- Supports keywords (center, top, bottom, left, right) and percentages +---@param position string -- Position string like "center center", "top left", "50% 50%" +---@return number, number -- Normalized X and Y positions (0-1) +function ImageRenderer._parsePosition(position) + if not position or type(position) ~= "string" then + return 0.5, 0.5 -- Default to center + end + + -- Split into X and Y components + local parts = {} + for part in position:gmatch("%S+") do + table.insert(parts, part:lower()) + end + + -- If only one value, use it for both axes (with special handling) + if #parts == 1 then + local val = parts[1] + if val == "left" or val == "right" then + parts = { val, "center" } + elseif val == "top" or val == "bottom" then + parts = { "center", val } + else + parts = { val, val } + end + elseif #parts == 0 then + return 0.5, 0.5 -- Default to center + end + + local function parseValue(val) + -- Handle keywords + if val == "center" then + return 0.5 + elseif val == "left" or val == "top" then + return 0 + elseif val == "right" or val == "bottom" then + return 1 + end + + -- Handle percentages + local percent = val:match("^([%d%.]+)%%$") + if percent then + return tonumber(percent) / 100 + end + + -- Handle plain numbers (treat as percentage) + local num = tonumber(val) + if num then + return num / 100 + end + + -- Invalid value, default to center + return 0.5 + end + + local x = parseValue(parts[1]) + local y = parseValue(parts[2] or parts[1]) + + -- Clamp to 0-1 range + x = math.max(0, math.min(1, x)) + y = math.max(0, math.min(1, y)) + + return x, y +end + +--- Draw an image with specified object-fit mode +---@param image love.Image -- Image to draw +---@param x number -- X position of bounds +---@param y number -- Y position of bounds +---@param width number -- Width of bounds +---@param height number -- Height of bounds +---@param fitMode string? -- Object-fit mode (default: "fill") +---@param objectPosition string? -- Object-position (default: "center center") +---@param opacity number? -- Opacity 0-1 (default: 1) +---@param tintColor Color? -- Color to tint the image (default: white/no tint) +function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor) + if not image then + return -- Nothing to draw + end + + opacity = opacity or 1 + fitMode = fitMode or "fill" + objectPosition = objectPosition or "center center" + + local imgWidth, imgHeight = image:getDimensions() + local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition) + + -- Save current color + local r, g, b, a = love.graphics.getColor() + + -- Apply opacity and tint + if tintColor then + love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity) + else + love.graphics.setColor(1, 1, 1, opacity) + end + + -- Draw image + if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then + -- Need to use a quad for cropping + local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight) + love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh) + else + -- Simple draw with scaling + love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY) + end + + -- Restore color + love.graphics.setColor(r, g, b, a) +end + +--- Draw an image with tiling/repeat mode +---@param image love.Image -- Image to draw +---@param x number -- X position of bounds +---@param y number -- Y position of bounds +---@param width number -- Width of bounds +---@param height number -- Height of bounds +---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat") +---@param opacity number? -- Opacity 0-1 (default: 1) +---@param tintColor Color? -- Color to tint the image (default: white/no tint) +function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor) + if not image then + return -- Nothing to draw + end + + opacity = opacity or 1 + repeatMode = repeatMode or "no-repeat" + + local imgWidth, imgHeight = image:getDimensions() + + -- Save current color + local r, g, b, a = love.graphics.getColor() + + -- Apply opacity and tint + if tintColor then + love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity) + else + love.graphics.setColor(1, 1, 1, opacity) + end + + if repeatMode == "no-repeat" then + -- Just draw once, no tiling + love.graphics.draw(image, x, y) + elseif repeatMode == "repeat" then + -- Tile in both directions + local tilesX = math.ceil(width / imgWidth) + local tilesY = math.ceil(height / imgHeight) + + for tileY = 0, tilesY - 1 do + for tileX = 0, tilesX - 1 do + local drawX = x + (tileX * imgWidth) + local drawY = y + (tileY * imgHeight) + + -- Calculate how much of the tile to draw (for partial tiles at edges) + local drawWidth = math.min(imgWidth, width - (tileX * imgWidth)) + local drawHeight = math.min(imgHeight, height - (tileY * imgHeight)) + + if drawWidth < imgWidth or drawHeight < imgHeight then + -- Use quad for partial tile + local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight) + love.graphics.draw(image, quad, drawX, drawY) + else + -- Draw full tile + love.graphics.draw(image, drawX, drawY) + end + end + end + elseif repeatMode == "repeat-x" then + -- Tile horizontally only + local tilesX = math.ceil(width / imgWidth) + + for tileX = 0, tilesX - 1 do + local drawX = x + (tileX * imgWidth) + local drawWidth = math.min(imgWidth, width - (tileX * imgWidth)) + + if drawWidth < imgWidth then + -- Use quad for partial tile + local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight) + love.graphics.draw(image, quad, drawX, y) + else + -- Draw full tile + love.graphics.draw(image, drawX, y) + end + end + elseif repeatMode == "repeat-y" then + -- Tile vertically only + local tilesY = math.ceil(height / imgHeight) + + for tileY = 0, tilesY - 1 do + local drawY = y + (tileY * imgHeight) + local drawHeight = math.min(imgHeight, height - (tileY * imgHeight)) + + if drawHeight < imgHeight then + -- Use quad for partial tile + local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight) + love.graphics.draw(image, quad, x, drawY) + else + -- Draw full tile + love.graphics.draw(image, x, drawY) + end + end + elseif repeatMode == "space" then + -- Distribute tiles with even spacing + local tilesX = math.floor(width / imgWidth) + local tilesY = math.floor(height / imgHeight) + + if tilesX < 1 then + tilesX = 1 + end + if tilesY < 1 then + tilesY = 1 + end + + local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0 + local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0 + + for tileY = 0, tilesY - 1 do + for tileX = 0, tilesX - 1 do + local drawX = x + (tileX * (imgWidth + spaceX)) + local drawY = y + (tileY * (imgHeight + spaceY)) + love.graphics.draw(image, drawX, drawY) + end + end + elseif repeatMode == "round" then + -- Scale tiles to fit bounds exactly + local tilesX = math.max(1, utils.round(width / imgWidth)) + local tilesY = math.max(1, utils.round(height / imgHeight)) + + local scaleX = width / (tilesX * imgWidth) + local scaleY = height / (tilesY * imgHeight) + + for tileY = 0, tilesY - 1 do + for tileX = 0, tilesX - 1 do + local drawX = x + (tileX * imgWidth * scaleX) + local drawY = y + (tileY * imgHeight * scaleY) + love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY) + end + end + else + ErrorHandler:warn("ImageRenderer", "VAL_007", { + repeatMode = repeatMode, + fallback = "no-repeat", + }) + love.graphics.draw(image, x, y) + end + + -- Restore color + love.graphics.setColor(r, g, b, a) +end + +return ImageRenderer diff --git a/libs/flexlove/modules/ImageScaler.lua b/libs/flexlove/modules/ImageScaler.lua new file mode 100644 index 00000000..cf510bf3 --- /dev/null +++ b/libs/flexlove/modules/ImageScaler.lua @@ -0,0 +1,174 @@ +-- ==================== +-- ImageScaler +-- ==================== + +local ImageScaler = {} + +-- ErrorHandler will be injected via init +local ErrorHandler = nil + +--- Initialize ImageScaler with dependencies +---@param deps table Dependencies table with ErrorHandler +function ImageScaler.init(deps) + if deps and deps.ErrorHandler then + ErrorHandler = deps.ErrorHandler + end +end + +--- Scale an ImageData region using nearest-neighbor sampling +--- Produces sharp, pixelated scaling - ideal for pixel art +---@param sourceImageData love.ImageData -- Source image data +---@param srcX number -- Source region X (0-based) +---@param srcY number -- Source region Y (0-based) +---@param srcW number -- Source region width +---@param srcH number -- Source region height +---@param destW number -- Destination width +---@param destH number -- Destination height +---@return love.ImageData -- Scaled image data +function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH) + if not sourceImageData then + ErrorHandler:error("ImageScaler", "VAL_001", { + parameter = "sourceImageData", + }) + end + + if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then + ErrorHandler:warn("ImageScaler", "VAL_002", { + srcW = srcW, + srcH = srcH, + destW = destW, + destH = destH, + fallback = "1x1 transparent image", + }) + -- Return a minimal 1x1 transparent image as fallback + local fallbackImageData = love.image.newImageData(1, 1) + fallbackImageData:setPixel(0, 0, 0, 0, 0, 0) + return fallbackImageData + end + + -- Create destination ImageData + local destImageData = love.image.newImageData(destW, destH) + + -- Calculate scale ratios (cached outside loops for performance) + local scaleX = srcW / destW + local scaleY = srcH / destH + + -- Nearest-neighbor sampling + for destY = 0, destH - 1 do + for destX = 0, destW - 1 do + -- Calculate source pixel coordinates using floor (nearest-neighbor) + local srcPixelX = math.floor(destX * scaleX) + srcX + local srcPixelY = math.floor(destY * scaleY) + srcY + + -- Clamp to source bounds (safety check) + srcPixelX = math.min(srcPixelX, srcX + srcW - 1) + srcPixelY = math.min(srcPixelY, srcY + srcH - 1) + + -- Sample source pixel + local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY) + + -- Write to destination + destImageData:setPixel(destX, destY, r, g, b, a) + end + end + + return destImageData +end + +--- Linear interpolation helper +--- Blends between two values based on interpolation factor +---@param a number -- Start value +---@param b number -- End value +---@param t number -- Interpolation factor [0, 1] +---@return number -- Interpolated value +local function lerp(a, b, t) + return a + (b - a) * t +end + +--- Scale an ImageData region using bilinear interpolation +--- Produces smooth, filtered scaling - ideal for high-quality upscaling +---@param sourceImageData love.ImageData -- Source image data +---@param srcX number -- Source region X (0-based) +---@param srcY number -- Source region Y (0-based) +---@param srcW number -- Source region width +---@param srcH number -- Source region height +---@param destW number -- Destination width +---@param destH number -- Destination height +---@return love.ImageData -- Scaled image data +function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH) + if not sourceImageData then + ErrorHandler:error("ImageScaler", "VAL_001", { + parameter = "sourceImageData", + }) + end + + if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then + ErrorHandler:warn("ImageScaler", "VAL_002", { + srcW = srcW, + srcH = srcH, + destW = destW, + destH = destH, + fallback = "1x1 transparent image", + }) + -- Return a minimal 1x1 transparent image as fallback + local fallbackImageData = love.image.newImageData(1, 1) + fallbackImageData:setPixel(0, 0, 0, 0, 0, 0) + return fallbackImageData + end + + -- Create destination ImageData + local destImageData = love.image.newImageData(destW, destH) + + -- Calculate scale ratios + local scaleX = srcW / destW + local scaleY = srcH / destH + + -- Bilinear interpolation + for destY = 0, destH - 1 do + for destX = 0, destW - 1 do + -- Calculate fractional source position + local srcXf = destX * scaleX + local srcYf = destY * scaleY + + -- Get integer coordinates for 2x2 sampling grid + local x0 = math.floor(srcXf) + local y0 = math.floor(srcYf) + local x1 = math.min(x0 + 1, srcW - 1) + local y1 = math.min(y0 + 1, srcH - 1) + + -- Get fractional parts for interpolation + local fx = srcXf - x0 + local fy = srcYf - y0 + + -- Sample 4 neighboring pixels (with source offset) + local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0) + local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0) + local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1) + local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1) + + -- Interpolate horizontally (top and bottom rows) + local rTop = lerp(r00, r10, fx) + local gTop = lerp(g00, g10, fx) + local bTop = lerp(b00, b10, fx) + local aTop = lerp(a00, a10, fx) + + local rBottom = lerp(r01, r11, fx) + local gBottom = lerp(g01, g11, fx) + local bBottom = lerp(b01, b11, fx) + local aBottom = lerp(a01, a11, fx) + + -- Interpolate vertically (final result) + local r = lerp(rTop, rBottom, fy) + local g = lerp(gTop, gBottom, fy) + local b = lerp(bTop, bBottom, fy) + local a = lerp(aTop, aBottom, fy) + + -- Write to destination + destImageData:setPixel(destX, destY, r, g, b, a) + end + end + + return destImageData +end + +return ImageScaler diff --git a/libs/flexlove/modules/InputEvent.lua b/libs/flexlove/modules/InputEvent.lua new file mode 100644 index 00000000..8f1be533 --- /dev/null +++ b/libs/flexlove/modules/InputEvent.lua @@ -0,0 +1,88 @@ +---@class InputEvent +---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel" +---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle) +---@field x number -- Mouse/Touch X position +---@field y number -- Mouse/Touch Y position +---@field dx number? -- Delta X from drag/touch start (only for drag/touch events) +---@field dy number? -- Delta Y from drag/touch start (only for drag/touch events) +---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} +---@field clickCount number -- Number of clicks (for double/triple click detection) +---@field timestamp number -- Time when event occurred +---@field touchId string? -- Touch identifier (for multi-touch) +---@field pressure number? -- Touch pressure (0-1, defaults to 1.0) +---@field phase string? -- Touch phase: "began", "moved", "ended", "cancelled" +local InputEvent = {} +InputEvent.__index = InputEvent + +---@class InputEventProps +---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel" +---@field button number +---@field x number +---@field y number +---@field dx number? +---@field dy number? +---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} +---@field clickCount number? +---@field timestamp number? +---@field touchId string? +---@field pressure number? +---@field phase string? + +--- Create a new input event +---@param props InputEventProps +---@return InputEvent +function InputEvent.new(props) + local self = setmetatable({}, InputEvent) + self.type = props.type + self.button = props.button + self.x = props.x + self.y = props.y + self.dx = props.dx + self.dy = props.dy + self.modifiers = props.modifiers + self.clickCount = props.clickCount or 1 + self.timestamp = props.timestamp or love.timer.getTime() + + -- Touch-specific properties + self.touchId = props.touchId + self.pressure = props.pressure or 1.0 + self.phase = props.phase + + return self +end + +--- Create an InputEvent from LÖVE touch data +---@param id userdata Touch ID from LÖVE +---@param x number Touch X position +---@param y number Touch Y position +---@param phase string Touch phase: "began", "moved", "ended", "cancelled" +---@param pressure number? Touch pressure (0-1, defaults to 1.0) +---@return InputEvent +function InputEvent.fromTouch(id, x, y, phase, pressure) + local touchIdStr = tostring(id) + local eventType = "touchpress" + if phase == "moved" then + eventType = "touchmove" + elseif phase == "ended" then + eventType = "touchrelease" + elseif phase == "cancelled" then + eventType = "touchcancel" + end + + return InputEvent.new({ + type = eventType, + button = 1, -- Treat touch as left button + x = x, + y = y, + dx = 0, + dy = 0, + modifiers = { shift = false, ctrl = false, alt = false, super = false }, + clickCount = 1, + timestamp = love.timer.getTime(), + touchId = touchIdStr, + pressure = pressure or 1.0, + phase = phase, + }) +end + +return InputEvent diff --git a/libs/flexlove/modules/KeyboardNavigation.lua b/libs/flexlove/modules/KeyboardNavigation.lua new file mode 100644 index 00000000..7122cd40 --- /dev/null +++ b/libs/flexlove/modules/KeyboardNavigation.lua @@ -0,0 +1,748 @@ +local packageName = ... or "KeyboardNavigation" +local modulePath = packageName:match("(.-)[^%.]+$") + +local function req(name) + return require(modulePath .. name) +end + +---@class KeyboardNavigation +---@field config KeyboardNavigationConfig +local KeyboardNavigation = { + + config = { + -- Global settings + enabled = true, + debugMode = false, + + -- Key bindings + keys = { + next = "tab", + previous = "shifttab", + up = "up", + down = "down", + left = "left", + right = "right", + activate = { "return", "space" }, + dismiss = "escape", + toggleDebug = "f12", + inspect = "i", + }, + + -- Navigation behavior + wrapAround = true, + directionalNavigation = true, + focusVisible = true, + autofocusOnCreate = false, + + --- Drop focus after pressing Enter/Space to activate an element + --- When false, focus remains on the element after activation + dropFocusOnSelection = true, + + -- Developer tools + developerTools = { + enabled = true, + showProperties = true, + highlightColor = { 1, 0.8, 0, 0.5 }, + }, + + -- Focus indicator style + focusIndicator = { + color = { 0.2, 0.6, 1.0, 0.8 }, + lineWidth = 2, + inset = -3, + borderRadius = 4, + animationDuration = 0.15, + }, + }, + + -- State + _navigationStack = {}, + _lastNavigationTime = 0, + _inspectMode = false, + _deps = nil, + + -- Spatial index for directional navigation (performance optimization) + _spatialIndex = { + enabled = false, + cellSize = 100, -- Grid cell size in pixels + grid = {}, -- Grid storing element references + elementPositions = {}, -- Cache of element positions {element = {x, y, w, h}} + lastUpdateFrame = 0, + }, +} + +--- Initialize KeyboardNavigation module +---@param deps table {Context, Element, ErrorHandler, utils, InputEvent} +function KeyboardNavigation.init(deps) + -- Validate required dependencies + local required = { Context = true, Element = true, ErrorHandler = true, utils = true, InputEvent = true } + for depName, _ in pairs(required) do + if not deps[depName] then + error(string.format("KeyboardNavigation.init: Missing required dependency: %s", depName)) + end + end + + KeyboardNavigation._deps = deps + KeyboardNavigation._ErrorHandler = deps.ErrorHandler + KeyboardNavigation._InputEvent = deps.InputEvent + KeyboardNavigation._Context = deps.Context + KeyboardNavigation._Element = deps.Element + KeyboardNavigation._utils = deps.utils +end + +--- Handle keyboard press for navigation +---@param key string +---@param scancode string +---@param isrepeat boolean +---@return boolean handled +function KeyboardNavigation:handleKeyPress(key, scancode, isrepeat) + if not KeyboardNavigation._Context then + return false + end + + -- Debug logging + if KeyboardNavigation.config.debugMode then + print( + string.format( + "[KeyboardNavigation] Key pressed: %s (scancode: %s, repeat: %s)", + key, + scancode, + tostring(isrepeat) + ) + ) + print(string.format("[KeyboardNavigation] Enabled: %s", tostring(KeyboardNavigation.config.enabled))) + end + + local config = KeyboardNavigation.config + local keys = config.keys + + -- Check for activation keys + for _, activateKey in ipairs(keys.activate) do + if key == activateKey then + return self:activateElement() + end + end + + -- Check for dismiss key + if key == keys.dismiss then + return self:dismissElement() + end + + -- Check for next/previous navigation + -- Tab with shift held = previous; Tab without shift = next + if key == keys.next then + if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then + return self:previousFocusable() + end + return self:nextFocusable() + end + + if key == keys.previous then + return self:previousFocusable() + end + + -- Check for directional navigation + if config.directionalNavigation then + if key == keys.up then + return self:navigateDirectional("up") + elseif key == keys.down then + return self:navigateDirectional("down") + elseif key == keys.left then + return self:navigateDirectional("left") + elseif key == keys.right then + return self:navigateDirectional("right") + end + end + + return false +end + +--- Find next focusable element in the focusable list +---@param focusableList table List of focusable elements in tab order +---@param current Element? Currently focused element +---@return Element? +function KeyboardNavigation:_findNextInList(focusableList, current) + local currentIndex = 0 + if current then + for i, elem in ipairs(focusableList) do + if elem.id == current.id then + currentIndex = i + break + end + end + end + + -- Search forward + if currentIndex < #focusableList then + return focusableList[currentIndex + 1] + end + + -- Wrap around if enabled + if KeyboardNavigation.config.wrapAround and #focusableList > 0 then + return focusableList[1] + end + + return nil +end + +--- Get the focusable element list scoped to the navigation container +---@return Element[] +function KeyboardNavigation:_getScopedFocusableList() + local Context = KeyboardNavigation._Context + local container = Context.getNavigationContainer() + if container then + return container:getFocusableChildren() + end + return Context.getFocusableElements() +end + +--- Navigate to next focusable element (Tab) +---@return boolean success +function KeyboardNavigation:nextFocusable() + local Context = KeyboardNavigation._Context + + local current = Context.getFocused() + if KeyboardNavigation.config.debugMode then + print( + string.format("[KeyboardNavigation] Tab pressed - Current focus: %s", tostring(current and current.id or "nil")) + ) + end + + local focusableList = self:_getScopedFocusableList() + local nextElem = self:_findNextInList(focusableList, current) + + if nextElem then + self:_focusElement(nextElem) + return true + end + + return false +end + +--- Find previous focusable element in the focusable list +---@param focusableList table List of focusable elements in tab order +---@param current Element? Currently focused element +---@return Element? +function KeyboardNavigation:_findPreviousInList(focusableList, current) + local currentIndex = #focusableList + 1 + if current then + for i, elem in ipairs(focusableList) do + if elem.id == current.id then + currentIndex = i + break + end + end + end + + -- Search backward + if currentIndex - 1 >= 1 then + return focusableList[currentIndex - 1] + end + + -- Wrap around if enabled + if KeyboardNavigation.config.wrapAround and #focusableList > 0 then + return focusableList[#focusableList] + end + + return nil +end + +--- Navigate to previous focusable element (Shift+Tab) +---@return boolean success +function KeyboardNavigation:previousFocusable() + local Context = KeyboardNavigation._Context + + local current = Context.getFocused() + + local focusableList = self:_getScopedFocusableList() + local prevElem = self:_findPreviousInList(focusableList, current) + + if prevElem then + self:_focusElement(prevElem) + return true + end + + return false +end + +--- Navigate using arrow keys +---@param direction "up"|"down"|"left"|"right" +---@return boolean success +function KeyboardNavigation:navigateDirectional(direction) + local Context = KeyboardNavigation._Context + local current = Context.getFocused() + + if not current then + return false + end + + local nextElem = KeyboardNavigation:_findDirectionalNeighbor(current, direction) + + if nextElem then + self:_focusElement(nextElem) + return true + end + + return false +end + +--- Find closest focusable element in the given direction +---@param current Element +---@param direction "up"|"down"|"left"|"right" +---@return Element? +function KeyboardNavigation:_findDirectionalNeighbor(current, direction) + -- Try spatial index first if enabled + if KeyboardNavigation._spatialIndex.enabled then + local spatialResult = self:_findDirectionalNeighborSpatial(current, direction) + if spatialResult then + return spatialResult + end + end + + -- Collect all focusable elements visible this frame + local Context = KeyboardNavigation._Context + local focusable = {} + + local function collectFocusable(elem) + if elem:isFocusable() and elem ~= current then + table.insert(focusable, elem) + end + for _, child in ipairs(elem.children) do + collectFocusable(child) + end + end + + -- Mode-agnostic: collect from Context's focusable list + local allFocusable = Context.getFocusableElements() + for _, elem in ipairs(allFocusable) do + if elem ~= current then + table.insert(focusable, elem) + end + end + + if #focusable == 0 then + return nil + end + + local currentRect = { + x = current.x, + y = current.y, + width = current.width or 0, + height = current.height or 0, + } + + local closest = nil + local closestDistance = math.huge + + for _, elem in ipairs(focusable) do + local elemRect = { + x = elem.x, + y = elem.y, + width = elem.width or 0, + height = elem.height or 0, + } + + local distance, isInDirection = self:_calculateDirectionalDistance(currentRect, elemRect, direction) + + if isInDirection and distance < closestDistance then + closest = elem + closestDistance = distance + end + end + + -- If no element found in exact direction, try with looser criteria + if not closest then + closest = self:_findClosestInDirection(current, focusable, direction) + end + + return closest +end + +--- Calculate distance and direction between elements +---@param from table {x, y, width, height} +---@param to table {x, y, width, height} +---@param direction string +---@return number distance, boolean isInDirection +function KeyboardNavigation:_calculateDirectionalDistance(from, to, direction) + -- Calculate bounding box edges + local fromLeft = from.x + local fromRight = from.x + from.width + local fromTop = from.y + local fromBottom = from.y + from.height + + local toLeft = to.x + local toRight = to.x + to.width + local toTop = to.y + local toBottom = to.y + to.height + + local distance = math.huge + local isInDirection = false + + if direction == "up" then + if toBottom < fromTop then + isInDirection = true + distance = fromTop - toBottom + end + elseif direction == "down" then + if toTop > fromBottom then + isInDirection = true + distance = toTop - fromBottom + end + elseif direction == "left" then + if toRight < fromLeft then + isInDirection = true + distance = fromLeft - toRight + end + elseif direction == "right" then + if toLeft > fromRight then + isInDirection = true + distance = toLeft - fromRight + end + end + + return distance, isInDirection +end + +--- Find closest element in direction using center-to-center distance +---@param current Element +---@param focusable Element[] +---@param direction string +---@return Element? +function KeyboardNavigation:_findClosestInDirection(current, focusable, direction) + local currentCenterX = current.x + (current.width or 0) / 2 + local currentCenterY = current.y + (current.height or 0) / 2 + + local closest = nil + local closestDistance = math.huge + + for _, elem in ipairs(focusable) do + if elem ~= current then + local elemCenterX = elem.x + (elem.width or 0) / 2 + local elemCenterY = elem.y + (elem.height or 0) / 2 + + local dx = elemCenterX - currentCenterX + local dy = elemCenterY - currentCenterY + + -- Check if element is generally in the right direction + local isInDirection = false + + if direction == "up" and dy < 0 then + isInDirection = true + elseif direction == "down" and dy > 0 then + isInDirection = true + elseif direction == "left" and dx < 0 then + isInDirection = true + elseif direction == "right" and dx > 0 then + isInDirection = true + end + + if isInDirection then + local distance = math.sqrt(dx * dx + dy * dy) + if distance < closestDistance then + closest = elem + closestDistance = distance + end + end + end + end + + return closest +end + +--- Focus an element +---@param element Element +function KeyboardNavigation:_focusElement(element) + local Context = KeyboardNavigation._Context + + if element and element:isFocusable() then + if KeyboardNavigation.config.debugMode then + print( + string.format( + "[KeyboardNavigation] Focusing element: %s (id: %s)", + element.themeComponent or "unknown", + tostring(element.id) + ) + ) + end + Context.setFocused(element) + + -- Update focus indicator + if KeyboardNavigation.FocusIndicator then + KeyboardNavigation.FocusIndicator.setFocused(element) + end + + -- Call onFocus callback if it exists + if element.onFocus then + local success, err = pcall(function() + if element.onFocusDeferred then + table.insert(Context._deferredCallbacks or {}, function() + element:onFocus(element) + end) + else + element:onFocus(element) + end + end) + + if not success then + KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_001", { + elementId = element.id or "unknown", + error = tostring(err), + }) + end + end + end +end + +---@param element Element +---@return boolean +function KeyboardNavigation:_shouldDropFocusOnSelection(element) + if element and element.dropFocusOnSelection ~= nil then + return element.dropFocusOnSelection == true + end + + return KeyboardNavigation.config.dropFocusOnSelection == true +end + +--- Activate currently focused element +---@return boolean success +function KeyboardNavigation:activateElement() + local Context = KeyboardNavigation._Context + local focused = Context.getFocused() + + if not focused then + return false + end + + if focused.disabled then + return false + end + + -- Fire press and release events + if focused.onEvent then + local modifiers = KeyboardNavigation._utils.getModifiers() + local pressEvent = KeyboardNavigation._InputEvent.new({ + type = "press", + button = 1, + x = focused.x, + y = focused.y, + modifiers = modifiers, + clickCount = 1, + }) + + local releaseEvent = KeyboardNavigation._InputEvent.new({ + type = "release", + button = 1, + x = focused.x, + y = focused.y, + modifiers = modifiers, + clickCount = 1, + }) + + local success, err = pcall(function() + focused.onEvent(focused, pressEvent) + focused.onEvent(focused, releaseEvent) + end) + + if not success then + KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_002", { + elementId = focused.id or "unknown", + error = tostring(err), + }) + end + + -- Drop focus after selection based on per-element override or global config. + if KeyboardNavigation:_shouldDropFocusOnSelection(focused) then + Context.clearFocus() + if KeyboardNavigation.FocusIndicator then + KeyboardNavigation.FocusIndicator.setFocused(nil) + end + end + + return true + end + + return false +end + +--- Dismiss currently focused element +---@return boolean success +function KeyboardNavigation:dismissElement() + local Context = KeyboardNavigation._Context + local focused = Context.getFocused() + + if not focused then + return false + end + + -- Check if element has a dismiss handler + if focused.onDismiss then + local success, err = pcall(function() + if focused.onDismissDeferred then + table.insert(Context._deferredCallbacks or {}, function() + focused:onDismiss(focused) + end) + else + focused:onDismiss(focused) + end + end) + + if not success then + KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_003", { + elementId = focused.id or "unknown", + error = tostring(err), + }) + end + + return true -- Handler took care of dismissal + end + + -- Default behavior: blur the element (only if no onDismiss handler) + Context.clearFocus() + return true +end + +--- Update keyboard navigation (for animations, etc.) +---@param dt number +function KeyboardNavigation:update(dt) + -- Update focus indicator if it exists + if KeyboardNavigation.FocusIndicator then + KeyboardNavigation.FocusIndicator:update(dt) + end +end + +--- Push current focus onto stack (for modals/dialogs) +--- Saves current focus and sets new focus to the given element +---@param element Element? The element to focus (e.g., modal dialog) +function KeyboardNavigation:pushFocus(element) + local Context = KeyboardNavigation._Context + + table.insert(KeyboardNavigation._navigationStack, Context.getFocused()) + Context.pushFocusStack(element) +end + +--- Pop focus from stack (return from modal) +--- Restores previously focused element from the stack +---@return Element? The previously focused element, or nil if stack was empty +function KeyboardNavigation:popFocus() + local Context = KeyboardNavigation._Context + + local previous = Context.popFocusStack() + if #KeyboardNavigation._navigationStack > 0 then + previous = table.remove(KeyboardNavigation._navigationStack) + end + + return previous +end + +-- ==================== +-- Spatial Index (Performance Optimization) +-- ==================== + +--- Enable spatial index for faster directional navigation +---@param enabled boolean +function KeyboardNavigation.enableSpatialIndex(enabled) + KeyboardNavigation._spatialIndex.enabled = enabled + if not enabled then + KeyboardNavigation:_clearSpatialIndex() + end +end + +--- Clear spatial index +function KeyboardNavigation:_clearSpatialIndex() + KeyboardNavigation._spatialIndex.grid = {} + KeyboardNavigation._spatialIndex.elementPositions = {} +end + +--- Find directional neighbor using spatial index +---@param current Element +---@param direction "up"|"down"|"left"|"right" +---@return Element? +function KeyboardNavigation:_findDirectionalNeighborSpatial(current, direction) + local index = KeyboardNavigation._spatialIndex + local cellSize = index.cellSize + + -- Get current element's grid position + local currentPos = index.elementPositions[current] + if not currentPos then + return nil + end + + local centerX = currentPos.x + currentPos.w / 2 + local centerY = currentPos.y + currentPos.h / 2 + local currentCellX = math.floor(centerX / cellSize) + local currentCellY = math.floor(centerY / cellSize) + + -- Search in direction, expanding outward + local maxSearchRadius = 20 -- Maximum cells to search + local visited = {} + + for radius = 1, maxSearchRadius do + local candidates = {} + + -- Get cells in the search ring + if direction == "up" then + table.insert(candidates, { currentCellX, currentCellY - radius }) + if radius > 1 then + table.insert(candidates, { currentCellX - 1, currentCellY - radius }) + table.insert(candidates, { currentCellX + 1, currentCellY - radius }) + end + elseif direction == "down" then + table.insert(candidates, { currentCellX, currentCellY + radius }) + if radius > 1 then + table.insert(candidates, { currentCellX - 1, currentCellY + radius }) + table.insert(candidates, { currentCellX + 1, currentCellY + radius }) + end + elseif direction == "left" then + table.insert(candidates, { currentCellX - radius, currentCellY }) + if radius > 1 then + table.insert(candidates, { currentCellX - radius, currentCellY - 1 }) + table.insert(candidates, { currentCellX - radius, currentCellY + 1 }) + end + elseif direction == "right" then + table.insert(candidates, { currentCellX + radius, currentCellY }) + if radius > 1 then + table.insert(candidates, { currentCellX + radius, currentCellY - 1 }) + table.insert(candidates, { currentCellX + radius, currentCellY + 1 }) + end + end + + -- Check each candidate cell + for _, cell in ipairs(candidates) do + local cellKey = string.format("%d,%d", cell[1], cell[2]) + local cellElements = index.grid[cellKey] + + if cellElements then + for _, elem in ipairs(cellElements) do + if elem ~= current and not visited[elem] then + visited[elem] = true + local elemPos = index.elementPositions[elem] + if elemPos then + local elemCenterX = elemPos.x + elemPos.w / 2 + local elemCenterY = elemPos.y + elemPos.h / 2 + + -- Check if element is in the correct direction + local isInDirection = false + if direction == "up" and elemCenterY < centerY then + isInDirection = true + elseif direction == "down" and elemCenterY > centerY then + isInDirection = true + elseif direction == "left" and elemCenterX < centerX then + isInDirection = true + elseif direction == "right" and elemCenterX > centerX then + isInDirection = true + end + + if isInDirection then + return elem + end + end + end + end + end + end + end + + return nil +end + +return KeyboardNavigation diff --git a/libs/flexlove/modules/LayoutEngine.lua b/libs/flexlove/modules/LayoutEngine.lua new file mode 100644 index 00000000..cf4dec4d --- /dev/null +++ b/libs/flexlove/modules/LayoutEngine.lua @@ -0,0 +1,1714 @@ +---@class LayoutEngine +---@field element Element? Reference to the parent element +---@field positioning Positioning Layout positioning mode +---@field flexDirection FlexDirection Direction of flex layout +---@field justifyContent JustifyContent Alignment of items along main axis +---@field alignItems AlignItems Alignment of items along cross axis +---@field alignContent AlignContent Alignment of lines in multi-line flex containers +---@field flexWrap FlexWrap Whether children wrap to multiple lines +---@field gap number Space between children elements +---@field gridRows number? Number of rows in the grid +---@field gridColumns number? Number of columns in the grid +---@field columnGap number? Gap between grid columns +---@field rowGap number? Gap between grid rows +---@field _Grid table +---@field _Units table +---@field _Context table +---@field _Positioning table +---@field _FlexDirection table +---@field _JustifyContent table +---@field _AlignContent table +---@field _AlignItems table +---@field _AlignSelf table +---@field _FlexWrap table +---@field _layoutCount number Track layout recalculations per frame +---@field _lastFrameCount number Last frame number for resetting counters +---@field _ErrorHandler ErrorHandler? ErrorHandler module dependency +---@field _Performance Performance? Performance module dependency +local LayoutEngine = {} +LayoutEngine.__index = LayoutEngine + +--- Recursively shift an element and all its descendants by (dx, dy). +--- Used by the row-reverse mirror pass and the `position: relative` offset +--- pass: both run after the rest of layout has placed the subtree, so a single +--- delta walk keeps descendants visually anchored to the parent. +---@param elem Element +---@param dx number +---@param dy number +local function shiftSubtree(elem, dx, dy) + elem.x = elem.x + dx + elem.y = elem.y + dy + for _, c in ipairs(elem.children) do + shiftSubtree(c, dx, dy) + end +end + +--- Initialize module with shared dependencies +---@param deps table Dependencies {ErrorHandler, Performance, utils} +function LayoutEngine.init(deps) + LayoutEngine._ErrorHandler = deps.ErrorHandler + LayoutEngine._Performance = deps.Performance + LayoutEngine._Utils = deps.utils +end + +---@class LayoutEngineProps +---@field positioning Positioning? Layout positioning mode (default: RELATIVE) +---@field flexDirection FlexDirection? Direction of flex layout (default: HORIZONTAL) +---@field justifyContent JustifyContent? Alignment of items along main axis (default: FLEX_START) +---@field alignItems AlignItems? Alignment of items along cross axis (default: STRETCH) +---@field alignContent AlignContent? Alignment of lines in multi-line flex containers (default: STRETCH) +---@field flexWrap FlexWrap? Whether children wrap to multiple lines (default: NOWRAP) +---@field gap number? Space between children elements (default: 10) +---@field gridRows number? Number of rows in the grid +---@field gridColumns number? Number of columns in the grid +---@field columnGap number? Gap between grid columns +---@field rowGap number? Gap between grid rows + +--- Create a new LayoutEngine instance +---@param props LayoutEngineProps +---@param deps table Dependencies {utils, Grid, Units, Context} +---@return LayoutEngine +function LayoutEngine.new(props, deps) + local enums = deps.utils.enums + local Positioning = enums.Positioning + local FlexDirection = enums.FlexDirection + local JustifyContent = enums.JustifyContent + local AlignContent = enums.AlignContent + local AlignItems = enums.AlignItems + local AlignSelf = enums.AlignSelf + local FlexWrap = enums.FlexWrap + + local self = setmetatable({}, LayoutEngine) + + -- Store dependencies for instance methods + self._Grid = deps.Grid + self._Units = deps.Units + self._Context = deps.Context + self._ErrorHandler = deps.ErrorHandler + self._Positioning = Positioning + self._FlexDirection = FlexDirection + self._JustifyContent = JustifyContent + self._AlignContent = AlignContent + self._AlignItems = AlignItems + self._AlignSelf = AlignSelf + self._FlexWrap = FlexWrap + + -- Layout configuration + self.positioning = props.positioning or Positioning.FLEX + self.flexDirection = props.flexDirection or FlexDirection.HORIZONTAL + self.justifyContent = props.justifyContent or JustifyContent.FLEX_START + self.alignItems = props.alignItems or AlignItems.STRETCH + self.alignContent = props.alignContent or AlignContent.STRETCH + self.flexWrap = props.flexWrap or FlexWrap.NOWRAP + self.gap = props.gap or 10 + + -- Grid layout configuration + self.gridRows = props.gridRows + self.gridColumns = props.gridColumns + + self.columnGap = props.columnGap + self.rowGap = props.rowGap + + -- Element reference (will be set via initialize) + self.element = nil + + -- Performance tracking + self._layoutCount = 0 + self._lastFrameCount = 0 + + -- Layout memoization cache + self._layoutCache = { + childrenCount = 0, + containerWidth = 0, + containerHeight = 0, + containerX = 0, + containerY = 0, + childrenHash = "", + } + + return self +end + +--- Initialize the LayoutEngine with its parent element +---@param element Element The parent element +function LayoutEngine:initialize(element) + self.element = element +end + +--- True for flex-direction `horizontal` or `horizontal-reverse` (and their +--- `row`/`row-reverse` aliases, which normalize to those at construction). +--- Routes every main-axis orientation check so reverse directions are +--- correctly classified as horizontal. +---@return boolean +function LayoutEngine:_isHorizontal() + return self.flexDirection == self._FlexDirection.HORIZONTAL + or self.flexDirection == self._FlexDirection.HORIZONTAL_REVERSE +end + +--- True for flex-direction `horizontal-reverse` or `vertical-reverse` +--- (and their `row-reverse`/`column-reverse` aliases). +---@return boolean +function LayoutEngine:_isReverse() + return self.flexDirection == self._FlexDirection.HORIZONTAL_REVERSE + or self.flexDirection == self._FlexDirection.VERTICAL_REVERSE +end + +--- Apply CSS positioning offsets (top, right, bottom, left) to a child element +---@param child Element The element to apply offsets to +function LayoutEngine:applyPositioningOffsets(child) + if not child then + return + end + + -- For CSS-style positioning, we need the parent's bounds + local parent = child.parent + if not parent then + return + end + + -- Only apply offsets to explicitly absolute children or children in relative/absolute containers + -- Flex/grid children ignore positioning offsets as they participate in layout + local isFlexChild = child.positioning == self._Positioning.FLEX + or child.positioning == self._Positioning.GRID + or (child.positioning == self._Positioning.ABSOLUTE and not child._explicitlyAbsolute) + + if not isFlexChild and child._explicitlyAbsolute then + -- Apply absolute positioning for explicitly absolute children + -- Apply top offset (distance from parent's content box top edge) + if child.top then + child.y = parent.y + parent.padding.top + child.top + end + + -- Apply bottom offset (distance from parent's content box bottom edge) + -- BORDER-BOX MODEL: Use border-box dimensions for positioning + if child.bottom then + local elementBorderBoxHeight = child:getBorderBoxHeight() + child.y = parent.y + parent.padding.top + parent.height - child.bottom - elementBorderBoxHeight + end + + -- Apply left offset (distance from parent's content box left edge) + if child.left then + child.x = parent.x + parent.padding.left + child.left + end + + -- Apply right offset (distance from parent's content box right edge) + -- BORDER-BOX MODEL: Use border-box dimensions for positioning + if child.right then + local elementBorderBoxWidth = child:getBorderBoxWidth() + child.x = parent.x + parent.padding.left + parent.width - child.right - elementBorderBoxWidth + end + end +end + +--- Calculate flex item sizes based on flexGrow, flexShrink, flexBasis +--- Implements CSS flexbox sizing algorithm +---@param children table Array of child elements in the flex line +---@param availableMainSize number Available space in main axis +---@param gap number Gap between items +---@param isHorizontal boolean Whether main axis is horizontal +---@param defaultFlexShrink number? Default flex-shrink to use when child.flexShrink is nil +---@return table mainSizes Array of calculated main sizes for each child +function LayoutEngine:_calculateFlexSizes(children, availableMainSize, gap, isHorizontal, defaultFlexShrink) + local implicitFlexShrink = defaultFlexShrink + if implicitFlexShrink == nil then + implicitFlexShrink = 1 + end + + local function getResolvedFlexShrink(child) + if child._hasExplicitFlexShrink then + return child.flexShrink + end + return implicitFlexShrink + end + + local childCount = #children + local totalGaps = math.max(0, childCount - 1) * gap + local availableForContent = availableMainSize - totalGaps + local viewportWidth, viewportHeight = self._Units.getViewport() + + -- Step 1: Calculate hypothetical main sizes (flex basis resolution) + local hypotheticalSizes = {} + local flexBases = {} + local totalFlexBasis = 0 + + local function resolveDeclaredMainSize(child) + local axisUnits = nil + if child.units then + axisUnits = isHorizontal and child.units.width or child.units.height + end + + if not axisUnits or axisUnits.unit == "auto" or axisUnits.value == nil then + return nil + end + + local resolved = + self._Units.resolve(axisUnits.value, axisUnits.unit, viewportWidth, viewportHeight, availableMainSize) + + if type(resolved) == "number" then + return math.max(0, resolved) + end + + return nil + end + + for i, child in ipairs(children) do + local flexBasis = child.flexBasis + local hypotheticalSize + + -- Resolve flex-basis + if flexBasis == "auto" then + -- Use declared main size to avoid reusing a previously flexed runtime size + hypotheticalSize = resolveDeclaredMainSize(child) + if hypotheticalSize == nil then + if isHorizontal then + hypotheticalSize = child:getBorderBoxWidth() + else + hypotheticalSize = child:getBorderBoxHeight() + end + end + elseif type(flexBasis) == "number" then + hypotheticalSize = flexBasis + elseif type(flexBasis) == "string" and child.units.flexBasis then + -- Parse and resolve flex-basis with units + local value, unit = child.units.flexBasis.value, child.units.flexBasis.unit + hypotheticalSize = self._Units.resolve(value, unit, viewportWidth, viewportHeight, availableMainSize) + else + -- Fallback to element's natural size + if isHorizontal then + hypotheticalSize = child:getBorderBoxWidth() + else + hypotheticalSize = child:getBorderBoxHeight() + end + end + + -- Add margins to hypothetical size + local childMargin = child.margin + if isHorizontal then + hypotheticalSize = hypotheticalSize + childMargin.left + childMargin.right + else + hypotheticalSize = hypotheticalSize + childMargin.top + childMargin.bottom + end + + flexBases[i] = hypotheticalSize + hypotheticalSizes[i] = hypotheticalSize + totalFlexBasis = totalFlexBasis + hypotheticalSize + end + + -- Step 2: Determine if we need to grow or shrink + local freeSpace = availableForContent - totalFlexBasis + + -- Step 3a: Handle positive free space (GROW) + if freeSpace > 0 then + local totalFlexGrow = 0 + for _, child in ipairs(children) do + totalFlexGrow = totalFlexGrow + (child.flexGrow or 0) + end + + if totalFlexGrow > 0 then + -- Distribute free space proportionally to flex-grow values + for i, child in ipairs(children) do + local flexGrow = child.flexGrow or 0 + if flexGrow > 0 then + local growAmount = (flexGrow / totalFlexGrow) * freeSpace + hypotheticalSizes[i] = hypotheticalSizes[i] + growAmount + end + end + end + -- Step 3b: Handle negative free space (SHRINK) + elseif freeSpace < 0 then + local totalFlexShrink = 0 + local totalScaledShrinkFactor = 0 + + for i, child in ipairs(children) do + local flexShrink = getResolvedFlexShrink(child) + totalFlexShrink = totalFlexShrink + flexShrink + -- Scaled shrink factor = flex-shrink × flex-basis + totalScaledShrinkFactor = totalScaledShrinkFactor + (flexShrink * flexBases[i]) + end + + if totalScaledShrinkFactor > 0 then + -- Distribute shrinkage proportionally to (flex-shrink × flex-basis) + for i, child in ipairs(children) do + local flexShrink = getResolvedFlexShrink(child) + if flexShrink > 0 then + local scaledShrinkFactor = flexShrink * flexBases[i] + local shrinkAmount = (scaledShrinkFactor / totalScaledShrinkFactor) * math.abs(freeSpace) + hypotheticalSizes[i] = math.max(0, hypotheticalSizes[i] - shrinkAmount) + end + end + end + end + + -- Step 4: Return final main sizes (excluding margins), clamped to per-child min/max + local mainSizes = {} + for i, child in ipairs(children) do + local childMargin = child.margin + local marginSum = isHorizontal and (childMargin.left + childMargin.right) or (childMargin.top + childMargin.bottom) + local minBound = isHorizontal and child.minWidth or child.minHeight + local maxBound = isHorizontal and child.maxWidth or child.maxHeight + mainSizes[i] = LayoutEngine._Utils.clamp(math.max(0, hypotheticalSizes[i] - marginSum), minBound, maxBound) + end + + return mainSizes +end + +--- Layout children within this element according to positioning mode +function LayoutEngine:layoutChildren() + -- Start performance timing first (before any early returns) + local timerName = nil + if LayoutEngine._Performance and LayoutEngine._Performance.enabled and self.element then + -- Use memory address to make timer name unique per element instance + timerName = "layout_" .. (self.element.id or tostring(self.element):match("0x%x+") or "unknown") + LayoutEngine._Performance:startTimer(timerName) + end + + if self.element == nil then + return + end + + -- Check if layout can be skipped (memoization optimization) + if self:_canSkipLayout() then + if timerName and LayoutEngine._Performance then + LayoutEngine._Performance:stopTimer(timerName) + end + return + end + + -- Track layout recalculations for performance warnings + self:_trackLayoutRecalculation() + + -- Handle grid layout + if self.positioning == self._Positioning.GRID then + self._Grid.layoutGridItems(self.element) + + -- Stop performance timing + if timerName and LayoutEngine._Performance then + LayoutEngine._Performance:stopTimer(timerName) + end + return + end + + local childCount = #self.element.children + + if childCount == 0 then + -- Stop performance timing + if timerName and LayoutEngine._Performance then + LayoutEngine._Performance:stopTimer(timerName) + end + return + end + + -- Get flex children (children that participate in flex layout) + -- Exclude display=false (CSS display:none) and explicitly absolute children + local flexChildren = {} + for _, child in ipairs(self.element.children) do + local isFlexChild = not (child.positioning == self._Positioning.ABSOLUTE and child._explicitlyAbsolute) + and child.display ~= false + if isFlexChild then + table.insert(flexChildren, child) + + -- Warn if child uses percentage sizing but parent has autosizing + if child.units and child.units.width then + if child.units.width.unit == "%" and self.element.autosizing and self.element.autosizing.width then + self.element:_warnIfPercentageWithAutoSizing(child, "width") + end + end + if child.units and child.units.height then + if child.units.height.unit == "%" and self.element.autosizing and self.element.autosizing.height then + self.element:_warnIfPercentageWithAutoSizing(child, "height") + end + end + end + end + + -- CSS-compliant behavior: absolutely positioned elements are completely removed from normal flow + -- They do NOT reserve space or affect flex layout calculations at all + + -- If no flex children, skip flex layout but still position absolute children + if #flexChildren == 0 then + -- Position absolutely positioned children even when there are no flex children + for i, child in ipairs(self.element.children) do + if child.positioning == self._Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then + self:applyPositioningOffsets(child) + + -- If child has children, layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + end + end + + -- Detect overflow after children positioning + if self.element._detectOverflow then + self.element:_detectOverflow() + end + + -- Stop performance timing + if timerName and LayoutEngine._Performance then + LayoutEngine._Performance:stopTimer(timerName) + end + return + end + + -- Calculate available space (accounting for padding only, NOT absolute children) + -- BORDER-BOX MODEL: element.width and element.height are already content dimensions (padding subtracted) + local availableMainSize = 0 + local availableCrossSize = 0 + + -- Reserve space for scrollbars if needed (reserve-space mode) + local scrollbarReservedWidth = 0 + local scrollbarReservedHeight = 0 + if self.element._scrollManager and self.element._scrollManager.scrollbarPlacement == "reserve-space" then + scrollbarReservedWidth, scrollbarReservedHeight = self.element._scrollManager:getReservedSpace(self.element) + end + + if self:_isHorizontal() then + availableMainSize = self.element.width - scrollbarReservedWidth + availableCrossSize = self.element.height - scrollbarReservedHeight + else + availableMainSize = self.element.height - scrollbarReservedHeight + availableCrossSize = self.element.width - scrollbarReservedWidth + end + + -- Keep percentage-sized children in sync when container dimensions change. + -- Managed select frames rely on this so `width = "100%"` options expand with the dropdown. + if scrollbarReservedWidth > 0 or scrollbarReservedHeight > 0 or self.element:_shouldSyncPercentageDimensions() then + local isHorizontal = self:_isHorizontal() + for _, child in ipairs(flexChildren) do + if isHorizontal then + -- Horizontal flex: main-axis is width, cross-axis is height + -- Adjust main-axis width if percentage-based + if child.units and child.units.width and child.units.width.unit == "%" then + local newBorderBoxWidth = LayoutEngine._Utils.clamp( + (child.units.width.value / 100) * availableMainSize, + child.minWidth, + child.maxWidth + ) + child._borderBoxWidth = newBorderBoxWidth + child.width = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right) + end + -- Adjust cross-axis height if percentage-based + if child.units and child.units.height and child.units.height.unit == "%" then + local newBorderBoxHeight = LayoutEngine._Utils.clamp( + (child.units.height.value / 100) * availableCrossSize, + child.minHeight, + child.maxHeight + ) + child._borderBoxHeight = newBorderBoxHeight + child.height = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom) + end + else + -- Vertical flex: main-axis is height, cross-axis is width + -- Adjust main-axis height if percentage-based + if child.units and child.units.height and child.units.height.unit == "%" then + local newBorderBoxHeight = LayoutEngine._Utils.clamp( + (child.units.height.value / 100) * availableMainSize, + child.minHeight, + child.maxHeight + ) + child._borderBoxHeight = newBorderBoxHeight + child.height = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom) + end + -- Adjust cross-axis width if percentage-based + if child.units and child.units.width and child.units.width.unit == "%" then + local rawBorderBoxWidth = (child.units.width.value / 100) * availableCrossSize + local newBorderBoxWidth = LayoutEngine._Utils.clamp( + self.element:_adjustCrossAxisPercentageWidth(child, rawBorderBoxWidth), + child.minWidth, + child.maxWidth + ) + child._borderBoxWidth = newBorderBoxWidth + child.width = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right) + end + end + end + end + + -- Handle flex wrap: create lines of children + local lines = {} + + if self.flexWrap == self._FlexWrap.NOWRAP then + -- All children go on one line + lines[1] = flexChildren + else + -- Wrap children into multiple lines + local currentLine = {} + local currentLineSize = 0 + + -- Performance optimization: hoist enum comparisons outside loop + local isHorizontal = self:_isHorizontal() + local gapSize = self.gap + local viewportWidth, viewportHeight = self._Units.getViewport() + + local function resolveDeclaredMainSizeForWrap(child) + local axisUnits = nil + if child.units then + axisUnits = isHorizontal and child.units.width or child.units.height + end + + if not axisUnits or axisUnits.unit == "auto" or axisUnits.value == nil then + return nil + end + + local resolved = + self._Units.resolve(axisUnits.value, axisUnits.unit, viewportWidth, viewportHeight, availableMainSize) + + if type(resolved) == "number" then + return math.max(0, resolved) + end + + return nil + end + + for _, child in ipairs(flexChildren) do + -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations + -- Include margins in size calculations + -- Performance optimization: hoist margin table access + local childMargin = child.margin + local childMainSize = 0 + local childMainMargin = 0 + local declaredMainSize = resolveDeclaredMainSizeForWrap(child) + if isHorizontal then + childMainSize = declaredMainSize or child:getBorderBoxWidth() + childMainMargin = childMargin.left + childMargin.right + else + childMainSize = declaredMainSize or child:getBorderBoxHeight() + childMainMargin = childMargin.top + childMargin.bottom + end + local childTotalMainSize = childMainSize + childMainMargin + + -- Check if adding this child would exceed the available space + local lineSpacing = #currentLine > 0 and gapSize or 0 + if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableMainSize then + -- Start a new line + if #currentLine > 0 then + table.insert(lines, currentLine) + end + currentLine = { child } + currentLineSize = childTotalMainSize + else + -- Add to current line + table.insert(currentLine, child) + currentLineSize = currentLineSize + lineSpacing + childTotalMainSize + end + end + + -- Add the last line if it has children + if #currentLine > 0 then + table.insert(lines, currentLine) + end + + -- Handle wrap-reverse: reverse the order of lines + if self.flexWrap == self._FlexWrap.WRAP_REVERSE then + local reversedLines = {} + for i = #lines, 1, -1 do + table.insert(reversedLines, lines[i]) + end + lines = reversedLines + end + end + + -- Apply flex sizing to each line BEFORE calculating line heights + -- Performance optimization: hoist enum comparison outside loop + local isHorizontal = self:_isHorizontal() + local mainAxisOverflow = nil + if self:_isHorizontal() then + mainAxisOverflow = self.element.overflowX or self.element.overflow + else + mainAxisOverflow = self.element.overflowY or self.element.overflow + end + local preserveMainAxisOverflow = (mainAxisOverflow == "scroll" or mainAxisOverflow == "auto") + local defaultFlexShrink = preserveMainAxisOverflow and 0 or 1 + + for lineIndex, line in ipairs(lines) do + -- Check if any child in this line needs flex sizing. + -- For scroll/auto in the main axis, keep implicit shrink at 0 so overflow can scroll. + local needsFlexSizing = false + for _, child in ipairs(line) do + local flexGrow = child.flexGrow or 0 + local flexBasis = child.flexBasis + local resolvedFlexShrink = defaultFlexShrink + if child._hasExplicitFlexShrink then + resolvedFlexShrink = child.flexShrink + end + + if flexGrow > 0 or (flexBasis and flexBasis ~= "auto") or resolvedFlexShrink > 0 then + needsFlexSizing = true + break + end + end + + -- Only apply flex sizing if needed + if needsFlexSizing then + -- Calculate flex sizes for this line + local mainSizes = self:_calculateFlexSizes(line, availableMainSize, self.gap, isHorizontal, defaultFlexShrink) + + -- Apply calculated sizes to children + for i, child in ipairs(line) do + local mainSize = mainSizes[i] + + if isHorizontal then + -- Update width for horizontal flex + child._borderBoxWidth = mainSize + child.width = math.max(0, mainSize - child.padding.left - child.padding.right) + -- Invalidate width cache + child._borderBoxWidthCache = nil + else + -- Update height for vertical flex + child._borderBoxHeight = mainSize + child.height = math.max(0, mainSize - child.padding.top - child.padding.bottom) + -- Invalidate height cache + child._borderBoxHeightCache = nil + end + + -- Trigger layout for child's children if any + if #child.children > 0 then + child:layoutChildren() + end + end + end + end + + -- Calculate line positions and heights (including child padding) + -- Performance optimization: preallocate array if possible + local lineHeights = table.create and table.create(#lines) or {} + local totalLinesHeight = 0 + + -- Performance optimization: hoist enum comparison outside loop (already hoisted above) + -- local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL + + for lineIndex, line in ipairs(lines) do + local maxCrossSize = 0 + for _, child in ipairs(line) do + -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations + -- Include margins in cross-axis size calculations + -- Performance optimization: hoist margin table access + local childMargin = child.margin + local childCrossSize = 0 + local childCrossMargin = 0 + if isHorizontal then + childCrossSize = child:getBorderBoxHeight() + childCrossMargin = childMargin.top + childMargin.bottom + else + childCrossSize = child:getBorderBoxWidth() + childCrossMargin = childMargin.left + childMargin.right + end + local childTotalCrossSize = childCrossSize + childCrossMargin + maxCrossSize = math.max(maxCrossSize, childTotalCrossSize) + end + lineHeights[lineIndex] = maxCrossSize + totalLinesHeight = totalLinesHeight + maxCrossSize + end + + -- Account for gaps between lines + local lineGaps = math.max(0, #lines - 1) * self.gap + totalLinesHeight = totalLinesHeight + lineGaps + + -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size + if #lines == 1 then + if + self.alignItems == self._AlignItems.STRETCH + or self.alignItems == self._AlignItems.CENTER + or self.alignItems == self._AlignItems.FLEX_END + then + -- STRETCH, CENTER, and FLEX_END should use full available cross size + lineHeights[1] = availableCrossSize + totalLinesHeight = availableCrossSize + end + -- CENTER and FLEX_END should preserve natural child dimensions + -- and only affect positioning within the available space + end + + -- Calculate starting position for lines based on alignContent + local lineStartPos = 0 + local lineSpacing = self.gap + local freeLineSpace = availableCrossSize - totalLinesHeight + + -- Apply AlignContent logic for both single and multiple lines + if self.alignContent == self._AlignContent.FLEX_START then + lineStartPos = 0 + elseif self.alignContent == self._AlignContent.CENTER then + lineStartPos = freeLineSpace / 2 + elseif self.alignContent == self._AlignContent.FLEX_END then + lineStartPos = freeLineSpace + elseif self.alignContent == self._AlignContent.SPACE_BETWEEN then + lineStartPos = 0 + if #lines > 1 then + lineSpacing = self.gap + (freeLineSpace / (#lines - 1)) + end + elseif self.alignContent == self._AlignContent.SPACE_AROUND then + local spaceAroundEach = freeLineSpace / #lines + lineStartPos = spaceAroundEach / 2 + lineSpacing = self.gap + spaceAroundEach + elseif self.alignContent == self._AlignContent.STRETCH then + lineStartPos = 0 + if #lines > 1 and freeLineSpace > 0 then + lineSpacing = self.gap + (freeLineSpace / #lines) + -- Distribute extra space to line heights (only if positive) + local extraPerLine = freeLineSpace / #lines + for i = 1, #lineHeights do + lineHeights[i] = lineHeights[i] + extraPerLine + end + end + end + + -- Position children within each line + local currentCrossPos = lineStartPos + + for lineIndex, line in ipairs(lines) do + local lineHeight = lineHeights[lineIndex] + + -- Calculate total size of children in this line (including padding and margins) + -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations + -- Performance optimization: hoist flexDirection check outside loop + local isHorizontal = self:_isHorizontal() + local totalChildrenSize = 0 + for _, child in ipairs(line) do + local childMargin = child.margin + if isHorizontal then + totalChildrenSize = totalChildrenSize + child:getBorderBoxWidth() + childMargin.left + childMargin.right + else + totalChildrenSize = totalChildrenSize + child:getBorderBoxHeight() + childMargin.top + childMargin.bottom + end + end + + local totalGapSize = math.max(0, #line - 1) * self.gap + local totalContentSize = totalChildrenSize + totalGapSize + local freeSpace = availableMainSize - totalContentSize + + -- Calculate initial position and spacing based on justifyContent + local startPos = 0 + local itemSpacing = self.gap + + if self.justifyContent == self._JustifyContent.FLEX_START then + startPos = 0 + elseif self.justifyContent == self._JustifyContent.CENTER then + startPos = math.max(0, freeSpace / 2) + elseif self.justifyContent == self._JustifyContent.FLEX_END then + startPos = math.max(0, freeSpace) + elseif self.justifyContent == self._JustifyContent.SPACE_BETWEEN then + startPos = 0 + if #line > 1 and freeSpace > 0 then + itemSpacing = self.gap + (freeSpace / (#line - 1)) + end + elseif self.justifyContent == self._JustifyContent.SPACE_AROUND then + if freeSpace > 0 then + local spaceAroundEach = freeSpace / #line + startPos = spaceAroundEach / 2 + itemSpacing = self.gap + spaceAroundEach + end + elseif self.justifyContent == self._JustifyContent.SPACE_EVENLY then + if freeSpace > 0 then + local spaceBetween = freeSpace / (#line + 1) + startPos = spaceBetween + itemSpacing = self.gap + spaceBetween + end + end + + -- Position children in this line + local currentMainPos = startPos + + -- Performance optimization: hoist frequently accessed element properties + local elementX = self.element.x + local elementY = self.element.y + local elementPadding = self.element.padding + local elementPaddingLeft = elementPadding.left + local elementPaddingTop = elementPadding.top + local alignItems = self.alignItems + local alignSelf_AUTO = self._AlignSelf.AUTO + local alignItems_FLEX_START = self._AlignItems.FLEX_START + local alignItems_CENTER = self._AlignItems.CENTER + local alignItems_FLEX_END = self._AlignItems.FLEX_END + local alignItems_STRETCH = self._AlignItems.STRETCH + + for _, child in ipairs(line) do + -- Performance optimization: hoist child table accesses + local childMargin = child.margin + local childPadding = child.padding + local childAutosizing = child.autosizing + + -- Determine effective cross-axis alignment + local effectiveAlign = child.alignSelf + if effectiveAlign == nil or effectiveAlign == alignSelf_AUTO then + effectiveAlign = alignItems + end + + if self:_isHorizontal() then + -- Horizontal layout: main axis is X, cross axis is Y + -- Position child at border box (x, y represents top-left including padding) + -- CSS-compliant: absolute children don't affect flex positioning, so no reserved space offset + local childMarginLeft = childMargin.left + child.x = elementX + elementPaddingLeft + currentMainPos + childMarginLeft + + -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations + local childBorderBoxHeight = child:getBorderBoxHeight() + local childMarginTop = childMargin.top + local childMarginBottom = childMargin.bottom + local childTotalCrossSize = childBorderBoxHeight + childMarginTop + childMarginBottom + + if effectiveAlign == alignItems_FLEX_START then + child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop + elseif effectiveAlign == alignItems_CENTER then + child.y = elementY + + elementPaddingTop + + currentCrossPos + + ((lineHeight - childTotalCrossSize) / 2) + + childMarginTop + elseif effectiveAlign == alignItems_FLEX_END then + child.y = elementY + elementPaddingTop + currentCrossPos + lineHeight - childTotalCrossSize + childMarginTop + elseif effectiveAlign == alignItems_STRETCH then + -- STRETCH: Only apply if height was not explicitly set + if childAutosizing and childAutosizing.height then + -- STRETCH: Set border-box height to lineHeight minus margins, content area shrinks to fit + local availableHeight = LayoutEngine._Utils.clamp( + lineHeight - childMarginTop - childMarginBottom, + child.minHeight, + child.maxHeight + ) + child._borderBoxHeight = availableHeight + child.height = math.max(0, availableHeight - childPadding.top - childPadding.bottom) + end + child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop + end + + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + + -- If child has children, re-layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + + -- Advance position by child's border-box width plus margins + currentMainPos = currentMainPos + child:getBorderBoxWidth() + childMarginLeft + childMargin.right + itemSpacing + else + -- Vertical layout: main axis is Y, cross axis is X + -- Position child at border box (x, y represents top-left including padding) + -- CSS-compliant: absolute children don't affect flex positioning, so no reserved space offset + local childMarginTop = childMargin.top + child.y = elementY + elementPaddingTop + currentMainPos + childMarginTop + + -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations + local childBorderBoxWidth = child:getBorderBoxWidth() + local childMarginLeft = childMargin.left + local childMarginRight = childMargin.right + local childTotalCrossSize = childBorderBoxWidth + childMarginLeft + childMarginRight + local elementPaddingLeft = elementPadding.left + + if effectiveAlign == alignItems_FLEX_START then + child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft + elseif effectiveAlign == alignItems_CENTER then + child.x = elementX + + elementPaddingLeft + + currentCrossPos + + ((lineHeight - childTotalCrossSize) / 2) + + childMarginLeft + elseif effectiveAlign == alignItems_FLEX_END then + child.x = elementX + elementPaddingLeft + currentCrossPos + lineHeight - childTotalCrossSize + childMarginLeft + elseif effectiveAlign == alignItems_STRETCH then + -- STRETCH: Only apply if width was not explicitly set + if childAutosizing and childAutosizing.width then + -- STRETCH: Set border-box width to lineHeight minus margins, content area shrinks to fit + local availableWidth = + LayoutEngine._Utils.clamp(lineHeight - childMarginLeft - childMarginRight, child.minWidth, child.maxWidth) + child._borderBoxWidth = availableWidth + child.width = math.max(0, availableWidth - childPadding.left - childPadding.right) + end + child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft + end + + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + + -- If child has children, re-layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + + -- Advance position by child's border-box height plus margins + currentMainPos = currentMainPos + + child:getBorderBoxHeight() + + child.margin.top + + child.margin.bottom + + itemSpacing + end + end + + -- Move to next line position + currentCrossPos = currentCrossPos + lineHeight + lineSpacing + end + + -- Position explicitly absolute children after flex layout + for i, child in ipairs(self.element.children) do + if child.positioning == self._Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + + -- If child has children, layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + end + end + + -- flex-direction: row-reverse / column-reverse — mirror the main-axis + -- position of each flex child relative to the container content area, and + -- shift the child's subtree by the same delta so descendants follow. + -- Cross-axis positions and absolute children are not affected. + if self:_isReverse() then + local parent = self.element + local padLeft = parent.padding.left + local padTop = parent.padding.top + local contentW = parent.width + local contentH = parent.height + local mirrorHorizontal = self:_isHorizontal() + + for _, child in ipairs(flexChildren) do + if mirrorHorizontal then + local distFromLeft = child.x - parent.x - padLeft + local childW = child:getBorderBoxWidth() + local newDistFromLeft = contentW - distFromLeft - childW + local dx = newDistFromLeft - distFromLeft + if dx ~= 0 then + shiftSubtree(child, dx, 0) + end + else + local distFromTop = child.y - parent.y - padTop + local childH = child:getBorderBoxHeight() + local newDistFromTop = contentH - distFromTop - childH + local dy = newDistFromTop - distFromTop + if dy ~= 0 then + shiftSubtree(child, 0, dy) + end + end + end + end + + -- position: relative — shift each in-flow child by (left or -right, + -- top or -bottom) after the flex flow (and row-reverse mirroring) has + -- placed it, so the offset is a pure visual delta that doesn't influence + -- siblings' flow positions. Per CSS, `top` wins over `bottom` and `left` + -- over `right` when both are set. Static/absolute children are unaffected + -- (absolute uses applyPositioningOffsets; flex-participating children + -- dropped the offsets and emitted LAY_011 at construction). Runs for every + -- container type so relative children in relative containers also honor offsets. + for _, child in ipairs(self.element.children) do + if child.positioning == self._Positioning.RELATIVE and child.display ~= false then + local dx, dy = 0, 0 + if child.top then + dy = child.top + elseif child.bottom then + dy = -child.bottom + end + if child.left then + dx = child.left + elseif child.right then + dx = -child.right + end + if dx ~= 0 or dy ~= 0 then + shiftSubtree(child, dx, dy) + end + end + end + + -- Detect overflow after children are laid out + if self.element._detectOverflow then + self.element:_detectOverflow() + end + + -- Stop performance timing + if timerName and LayoutEngine._Performance then + LayoutEngine._Performance:stopTimer(timerName) + end +end + +--- Simulate wrapping children into lines for auto-sizing calculations +---@param children table Array of child elements +---@param availableSize number Available space in main axis +---@param isHorizontal boolean True if flex direction is horizontal +---@return table Array of lines, where each line is an array of children +function LayoutEngine:_simulateWrap(children, availableSize, isHorizontal) + local lines = {} + local currentLine = {} + local currentLineSize = 0 + + for _, child in ipairs(children) do + -- Calculate child size in main axis (including margins) + local childMainSize = 0 + local childMainMargin = 0 + if isHorizontal then + childMainSize = child:getBorderBoxWidth() + if child.margin then + childMainMargin = child.margin.left + child.margin.right + end + else + childMainSize = child:getBorderBoxHeight() + if child.margin then + childMainMargin = child.margin.top + child.margin.bottom + end + end + local childTotalMainSize = childMainSize + childMainMargin + + -- Check if adding this child would exceed the available space + local lineSpacing = #currentLine > 0 and self.gap or 0 + if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableSize then + -- Start a new line + table.insert(lines, currentLine) + currentLine = { child } + currentLineSize = childTotalMainSize + else + -- Add to current line + table.insert(currentLine, child) + currentLineSize = currentLineSize + lineSpacing + childTotalMainSize + end + end + + -- Add the last line if it has children + if #currentLine > 0 then + table.insert(lines, currentLine) + end + + return lines +end + +--- Calculate auto width based on children +---@return number +function LayoutEngine:calculateAutoWidth() + if self.element == nil then + return 0 + end + + -- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box + local contentWidth = self.element:calculateTextWidth() + if not self.element.children or #self.element.children == 0 then + return contentWidth + end + + -- Get flex children (children that participate in flex layout) + -- Exclude display=false (CSS display:none) and explicitly absolute children + local flexChildren = {} + for _, child in ipairs(self.element.children) do + if not child._explicitlyAbsolute and child.display ~= false then + table.insert(flexChildren, child) + end + end + + if #flexChildren == 0 then + return contentWidth + end + + local isHorizontal = self:_isHorizontal() + + if isHorizontal then + -- HORIZONTAL flex with potential wrapping + if self.flexWrap ~= self._FlexWrap.NOWRAP and self.element.width and self.element.width > 0 then + -- Container has explicit width and wrapping enabled - calculate based on wrapped lines + local availableWidth = self.element.width + local lines = self:_simulateWrap(flexChildren, availableWidth, true) + + -- Find the widest line + local maxLineWidth = contentWidth + for _, line in ipairs(lines) do + local lineWidth = 0 + for i, child in ipairs(line) do + local childBorderBoxWidth = child:getBorderBoxWidth() + local childMarginH = 0 + if child.margin then + childMarginH = child.margin.left + child.margin.right + end + lineWidth = lineWidth + childBorderBoxWidth + childMarginH + if i < #line then + lineWidth = lineWidth + self.gap + end + end + maxLineWidth = math.max(maxLineWidth, lineWidth) + end + return maxLineWidth + else + -- No wrapping or no explicit width - sum all children on one line + local totalWidth = contentWidth + for i, child in ipairs(flexChildren) do + local childBorderBoxWidth = child:getBorderBoxWidth() + local childMarginH = 0 + if child.margin then + childMarginH = child.margin.left + child.margin.right + end + totalWidth = totalWidth + childBorderBoxWidth + childMarginH + if i < #flexChildren then + totalWidth = totalWidth + self.gap + end + end + return totalWidth + end + else + -- VERTICAL flex - return max child width (including margins) + local maxWidth = contentWidth + for _, child in ipairs(flexChildren) do + local childBorderBoxWidth = child:getBorderBoxWidth() + childBorderBoxWidth = self.element:_adjustAutoWidthChildBorderBoxForManagedSelect(child, childBorderBoxWidth) + local childMarginH = 0 + if child.margin then + childMarginH = child.margin.left + child.margin.right + end + maxWidth = math.max(maxWidth, childBorderBoxWidth + childMarginH) + end + return maxWidth + end +end + +---@return number +function LayoutEngine:calculateAutoHeight() + if self.element == nil then + return 0 + end + + local height = self.element:calculateTextHeight() + if not self.element.children or #self.element.children == 0 then + return height + end + + -- Get flex children (children that participate in flex layout) + -- Exclude display=false (CSS display:none) and explicitly absolute children + local flexChildren = {} + for _, child in ipairs(self.element.children) do + if not child._explicitlyAbsolute and child.display ~= false then + table.insert(flexChildren, child) + end + end + + if #flexChildren == 0 then + return height + end + + local isVertical = not self:_isHorizontal() + + if isVertical then + -- VERTICAL flex with potential wrapping + if self.flexWrap ~= self._FlexWrap.NOWRAP and self.element.height and self.element.height > 0 then + -- Container has explicit height and wrapping enabled - calculate based on wrapped lines + local availableHeight = self.element.height + local lines = self:_simulateWrap(flexChildren, availableHeight, false) + + -- Sum all line heights + local totalLinesHeight = height + for i, line in ipairs(lines) do + local lineHeight = 0 + for _, child in ipairs(line) do + local childBorderBoxHeight = child:getBorderBoxHeight() + local childMarginV = 0 + if child.margin then + childMarginV = child.margin.top + child.margin.bottom + end + lineHeight = math.max(lineHeight, childBorderBoxHeight + childMarginV) + end + totalLinesHeight = totalLinesHeight + lineHeight + if i < #lines then + totalLinesHeight = totalLinesHeight + self.gap + end + end + return totalLinesHeight + else + -- No wrapping or no explicit height - sum all children on one line + local totalHeight = height + for i, child in ipairs(flexChildren) do + local childBorderBoxHeight = child:getBorderBoxHeight() + local childMarginV = 0 + if child.margin then + childMarginV = child.margin.top + child.margin.bottom + end + totalHeight = totalHeight + childBorderBoxHeight + childMarginV + if i < #flexChildren then + totalHeight = totalHeight + self.gap + end + end + return totalHeight + end + else + -- HORIZONTAL flex with potential wrapping + if self.flexWrap ~= self._FlexWrap.NOWRAP and self.element.width and self.element.width > 0 then + -- Container has explicit width and wrapping enabled - calculate based on wrapped lines + local availableWidth = self.element.width + local lines = self:_simulateWrap(flexChildren, availableWidth, true) + + -- Sum all line heights (cross-axis for horizontal flex) + local totalLinesHeight = height + for i, line in ipairs(lines) do + local lineHeight = 0 + for _, child in ipairs(line) do + local childBorderBoxHeight = child:getBorderBoxHeight() + local childMarginV = 0 + if child.margin then + childMarginV = child.margin.top + child.margin.bottom + end + lineHeight = math.max(lineHeight, childBorderBoxHeight + childMarginV) + end + totalLinesHeight = totalLinesHeight + lineHeight + if i < #lines then + totalLinesHeight = totalLinesHeight + self.gap + end + end + return totalLinesHeight + else + -- No wrapping or no explicit width - return max child height (including margins) + local maxHeight = height + for _, child in ipairs(flexChildren) do + local childBorderBoxHeight = child:getBorderBoxHeight() + local childMarginV = 0 + if child.margin then + childMarginV = child.margin.top + child.margin.bottom + end + maxHeight = math.max(maxHeight, childBorderBoxHeight + childMarginV) + end + return maxHeight + end + end +end + +--- Recalculate units based on new viewport dimensions (for vw, vh, % units) +---@param newViewportWidth number +---@param newViewportHeight number +function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) + if self.element == nil then + return + end + local Units = self._Units + + -- Get updated scale factors + local scaleX, scaleY = self._Context.getScaleFactors() + + -- Recalculate border-box width if using viewport or percentage units (skip auto-sized) + -- Store in _borderBoxWidth temporarily, will calculate content width after padding is resolved + if self.element.units.width.unit ~= "px" and self.element.units.width.unit ~= "auto" then + local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth + self.element._borderBoxWidth = Units.resolve( + self.element.units.width.value, + self.element.units.width.unit, + newViewportWidth, + newViewportHeight, + parentWidth + ) + elseif self.element.units.width.unit == "px" and self.element.units.width.value and self._Context.baseScale then + -- Reapply base scaling to pixel widths (border-box) + self.element._borderBoxWidth = self.element.units.width.value * scaleX + end + + -- Recalculate border-box height if using viewport or percentage units (skip auto-sized) + -- Store in _borderBoxHeight temporarily, will calculate content height after padding is resolved + if self.element.units.height.unit ~= "px" and self.element.units.height.unit ~= "auto" then + local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight + self.element._borderBoxHeight = Units.resolve( + self.element.units.height.value, + self.element.units.height.unit, + newViewportWidth, + newViewportHeight, + parentHeight + ) + elseif self.element.units.height.unit == "px" and self.element.units.height.value and self._Context.baseScale then + -- Reapply base scaling to pixel heights (border-box) + self.element._borderBoxHeight = self.element.units.height.value * scaleY + end + + -- Recalculate position if using viewport or percentage units + -- Skip position recalculation for flex children (non-explicitly-absolute children with a parent) + -- Their x/y is entirely controlled by the parent's layoutChildren() call + local isFlexChild = self.element.parent and not self.element._explicitlyAbsolute + if not isFlexChild then + if self.element.units.x.unit ~= "px" then + local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth + local baseX = self.element.parent and self.element.parent.x or 0 + local offsetX = Units.resolve( + self.element.units.x.value, + self.element.units.x.unit, + newViewportWidth, + newViewportHeight, + parentWidth + ) + self.element.x = baseX + offsetX + else + -- For pixel units, update position relative to parent's new position (with base scaling) + if self.element.parent then + local baseX = self.element.parent.x + local scaledOffset = self._Context.baseScale and (self.element.units.x.value * scaleX) + or self.element.units.x.value + self.element.x = baseX + scaledOffset + elseif self._Context.baseScale then + -- Top-level element with pixel position - apply base scaling + self.element.x = self.element.units.x.value * scaleX + end + end + + if self.element.units.y.unit ~= "px" then + local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight + local baseY = self.element.parent and self.element.parent.y or 0 + local offsetY = Units.resolve( + self.element.units.y.value, + self.element.units.y.unit, + newViewportWidth, + newViewportHeight, + parentHeight + ) + self.element.y = baseY + offsetY + else + -- For pixel units, update position relative to parent's new position (with base scaling) + if self.element.parent then + local baseY = self.element.parent.y + local scaledOffset = self._Context.baseScale and (self.element.units.y.value * scaleY) + or self.element.units.y.value + self.element.y = baseY + scaledOffset + elseif self._Context.baseScale then + -- Top-level element with pixel position - apply base scaling + self.element.y = self.element.units.y.value * scaleY + end + end + end + + -- Recalculate textSize if auto-scaling is enabled or using viewport/element-relative units + if self.element.autoScaleText and self.element.units.textSize.value then + local unit = self.element.units.textSize.unit + local value = self.element.units.textSize.value + + if unit == "px" and self._Context.baseScale then + -- With base scaling: scale pixel values relative to base resolution + self.element.textSize = value * scaleY + elseif unit == "px" then + -- Without base scaling but auto-scaling enabled: text doesn't scale + self.element.textSize = value + elseif unit == "%" or unit == "vh" then + -- Percentage and vh are relative to viewport height + self.element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportHeight) + elseif unit == "vw" then + -- vw is relative to viewport width + self.element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportWidth) + else + self.element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil) + end + + -- Apply min/max constraints (with base scaling) + local minSize = self.element.minTextSize + and (self._Context.baseScale and (self.element.minTextSize * scaleY) or self.element.minTextSize) + local maxSize = self.element.maxTextSize + and (self._Context.baseScale and (self.element.maxTextSize * scaleY) or self.element.maxTextSize) + + if minSize and self.element.textSize < minSize then + self.element.textSize = minSize + end + if maxSize and self.element.textSize > maxSize then + self.element.textSize = maxSize + end + + -- Protect against too-small text sizes (minimum 1px) + if self.element.textSize < 1 then + self.element.textSize = 1 -- Minimum 1px + end + elseif self.element.units.textSize.unit == "px" and self.element.units.textSize.value and self._Context.baseScale then + -- No auto-scaling but base scaling is set: reapply base scaling to pixel text sizes + self.element.textSize = self.element.units.textSize.value * scaleY + + -- Protect against too-small text sizes (minimum 1px) + if self.element.textSize < 1 then + self.element.textSize = 1 -- Minimum 1px + end + end + + -- Final protection: ensure textSize is always at least 1px (catches all edge cases) + if self.element.text and self.element.textSize and self.element.textSize < 1 then + self.element.textSize = 1 -- Minimum 1px + end + + -- Recalculate gap if using viewport or percentage units + if self.element.units.gap.unit ~= "px" then + local containerSize = (self:_isHorizontal()) + and (self.element.parent and self.element.parent.width or newViewportWidth) + or (self.element.parent and self.element.parent.height or newViewportHeight) + self.element.gap = Units.resolve( + self.element.units.gap.value, + self.element.units.gap.unit, + newViewportWidth, + newViewportHeight, + containerSize + ) + end + + -- Recalculate flexBasis if using viewport or percentage units + if + self.element.units.flexBasis + and self.element.units.flexBasis.unit ~= "auto" + and self.element.units.flexBasis.unit ~= "px" + then + local value, unit = self.element.units.flexBasis.value, self.element.units.flexBasis.unit + -- flexBasis uses parent main-axis size for percentage resolution. + local parentMainIsHorizontal = true + if self.element.parent and self.element.parent.flexDirection then + local pd = self.element.parent.flexDirection + parentMainIsHorizontal = pd == self._FlexDirection.HORIZONTAL or pd == self._FlexDirection.HORIZONTAL_REVERSE + end + local parentSize = newViewportWidth + if self.element.parent then + if parentMainIsHorizontal then + parentSize = self.element.parent.width + else + parentSize = self.element.parent.height + end + end + local resolvedBasis = Units.resolve(value, unit, newViewportWidth, newViewportHeight, parentSize) + if type(resolvedBasis) == "number" then + self.element.flexBasis = resolvedBasis + end + end + + -- Recalculate spacing (padding/margin) if using viewport or percentage units + -- For percentage-based padding: + -- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements) + -- - If element has no parent: use element's own border-box dimensions (CSS spec for root elements) + local parentBorderBoxWidth = self.element.parent and self.element.parent._borderBoxWidth + or self.element._borderBoxWidth + or newViewportWidth + local parentBorderBoxHeight = self.element.parent and self.element.parent._borderBoxHeight + or self.element._borderBoxHeight + or newViewportHeight + + -- Handle shorthand properties first (horizontal/vertical) + local resolvedHorizontalPadding = nil + local resolvedVerticalPadding = nil + + if self.element.units.padding.horizontal and self.element.units.padding.horizontal.unit ~= "px" then + resolvedHorizontalPadding = Units.resolve( + self.element.units.padding.horizontal.value, + self.element.units.padding.horizontal.unit, + newViewportWidth, + newViewportHeight, + parentBorderBoxWidth + ) + elseif self.element.units.padding.horizontal and self.element.units.padding.horizontal.value then + resolvedHorizontalPadding = self.element.units.padding.horizontal.value + end + + if self.element.units.padding.vertical and self.element.units.padding.vertical.unit ~= "px" then + resolvedVerticalPadding = Units.resolve( + self.element.units.padding.vertical.value, + self.element.units.padding.vertical.unit, + newViewportWidth, + newViewportHeight, + parentBorderBoxHeight + ) + elseif self.element.units.padding.vertical and self.element.units.padding.vertical.value then + resolvedVerticalPadding = self.element.units.padding.vertical.value + end + -- Resolve individual padding sides (with fallback to shorthand) + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + -- Check if this side was explicitly set or if we should use shorthand + local useShorthand = false + if not self.element.units.padding[side].explicit then + -- Not explicitly set, check if we have shorthand + if side == "left" or side == "right" then + useShorthand = resolvedHorizontalPadding ~= nil + elseif side == "top" or side == "bottom" then + useShorthand = resolvedVerticalPadding ~= nil + end + end + + if useShorthand then + -- Use shorthand value + if side == "left" or side == "right" then + self.element.padding[side] = resolvedHorizontalPadding + else + self.element.padding[side] = resolvedVerticalPadding + end + elseif self.element.units.padding[side].unit ~= "px" then + -- Recalculate non-pixel units + local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth + self.element.padding[side] = Units.resolve( + self.element.units.padding[side].value, + self.element.units.padding[side].unit, + newViewportWidth, + newViewportHeight, + parentSize + ) + end + -- If unit is "px" and not using shorthand, value stays the same + end + + -- Handle margin shorthand properties + local resolvedHorizontalMargin = nil + local resolvedVerticalMargin = nil + + if self.element.units.margin.horizontal and self.element.units.margin.horizontal.unit ~= "px" then + resolvedHorizontalMargin = Units.resolve( + self.element.units.margin.horizontal.value, + self.element.units.margin.horizontal.unit, + newViewportWidth, + newViewportHeight, + parentBorderBoxWidth + ) + elseif self.element.units.margin.horizontal and self.element.units.margin.horizontal.value then + resolvedHorizontalMargin = self.element.units.margin.horizontal.value + end + + if self.element.units.margin.vertical and self.element.units.margin.vertical.unit ~= "px" then + resolvedVerticalMargin = Units.resolve( + self.element.units.margin.vertical.value, + self.element.units.margin.vertical.unit, + newViewportWidth, + newViewportHeight, + parentBorderBoxHeight + ) + elseif self.element.units.margin.vertical and self.element.units.margin.vertical.value then + resolvedVerticalMargin = self.element.units.margin.vertical.value + end + + -- Resolve individual margin sides (with fallback to shorthand) + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + -- Check if this side was explicitly set or if we should use shorthand + local useShorthand = false + if not self.element.units.margin[side].explicit then + -- Not explicitly set, check if we have shorthand + if side == "left" or side == "right" then + useShorthand = resolvedHorizontalMargin ~= nil + elseif side == "top" or side == "bottom" then + useShorthand = resolvedVerticalMargin ~= nil + end + end + + if useShorthand then + -- Use shorthand value + if side == "left" or side == "right" then + self.element.margin[side] = resolvedHorizontalMargin + else + self.element.margin[side] = resolvedVerticalMargin + end + elseif self.element.units.margin[side].unit ~= "px" then + -- Recalculate non-pixel units + local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth + self.element.margin[side] = Units.resolve( + self.element.units.margin[side].value, + self.element.units.margin[side].unit, + newViewportWidth, + newViewportHeight, + parentSize + ) + end + -- If unit is "px" and not using shorthand, value stays the same + end + + -- BORDER-BOX MODEL: Calculate content dimensions from border-box dimensions + -- For explicitly-sized elements (non-auto), _borderBoxWidth/_borderBoxHeight were set earlier + -- Now we calculate content width/height by subtracting padding + -- Only recalculate if using viewport/percentage units (where _borderBoxWidth actually changed) + if self.element.units.width.unit ~= "auto" and self.element.units.width.unit ~= "px" then + -- _borderBoxWidth was recalculated for viewport/percentage units + -- Calculate content width by subtracting padding + self.element.width = + math.max(0, self.element._borderBoxWidth - self.element.padding.left - self.element.padding.right) + elseif self.element.units.width.unit == "auto" then + -- For auto-sized elements, width is content width (calculated in resize method) + -- Update border-box to include padding + self.element._borderBoxWidth = self.element.width + self.element.padding.left + self.element.padding.right + end + -- For pixel units, width stays as-is (may have been manually modified) + + if self.element.units.height.unit ~= "auto" and self.element.units.height.unit ~= "px" then + -- _borderBoxHeight was recalculated for viewport/percentage units + -- Calculate content height by subtracting padding + self.element.height = + math.max(0, self.element._borderBoxHeight - self.element.padding.top - self.element.padding.bottom) + elseif self.element.units.height.unit == "auto" then + -- For auto-sized elements, height is content height (calculated in resize method) + -- Update border-box to include padding + self.element._borderBoxHeight = self.element.height + self.element.padding.top + self.element.padding.bottom + end + -- For pixel units, height stays as-is (may have been manually modified) + + -- Detect overflow after layout calculations + if self.element._detectOverflow then + self.element:_detectOverflow() + end +end + +--- Check if layout can be skipped based on cached state (memoization) +---@return boolean canSkip True if layout hasn't changed and can be skipped +function LayoutEngine:_canSkipLayout() + if not self.element then + return false + end + + -- Performance optimization: Check dirty flags first (fastest check) + -- If element or children are marked dirty, we must recalculate + if self.element._dirty or self.element._childrenDirty then + -- Clear dirty flags after acknowledging them + self.element._dirty = false + self.element._childrenDirty = false + return false + end + + -- If not dirty, check if layout inputs have actually changed (secondary check) + local childrenCount = #self.element.children + local containerWidth = self.element.width + local containerHeight = self.element.height + local containerX = self.element.x + local containerY = self.element.y + + -- Generate simple hash of children dimensions + display state + local childrenHash = "" + for i, child in ipairs(self.element.children) do + if i <= 5 then -- Only hash first 5 children for performance + childrenHash = childrenHash .. child.width .. "x" .. child.height .. "d" .. tostring(child.display) .. "," + end + end + + local cache = self._layoutCache + + -- Check if layout inputs have changed + if + cache.childrenCount == childrenCount + and cache.containerWidth == containerWidth + and cache.containerHeight == containerHeight + and cache.containerX == containerX + and cache.containerY == containerY + and cache.childrenHash == childrenHash + then + return true -- Layout hasn't changed, can skip + end + + -- Update cache with current values + cache.childrenCount = childrenCount + cache.containerWidth = containerWidth + cache.containerHeight = containerHeight + cache.containerX = containerX + cache.containerY = containerY + cache.childrenHash = childrenHash + + return false -- Layout has changed, must recalculate +end + +--- Track layout recalculations and warn about excessive layouts +function LayoutEngine:_trackLayoutRecalculation() + if not LayoutEngine._Performance or not LayoutEngine._Performance.warningsEnabled then + return + end + + -- Get current frame count from Context + local currentFrame = self._Context and self._Context._frameNumber or 0 + + -- Reset counter on new frame + if currentFrame ~= self._lastFrameCount then + self._lastFrameCount = currentFrame + self._layoutCount = 0 + end + + -- Increment layout count + self._layoutCount = self._layoutCount + 1 + + -- Warn if layout is recalculated excessively this frame + if self._layoutCount >= 10 then + local elementId = self.element and self.element.id or "unnamed" + LayoutEngine._Performance:logWarning( + string.format("excessive_layout_%s", elementId), + "LayoutEngine", + string.format("Layout recalculated %d times this frame for element '%s'", self._layoutCount, elementId), + { layoutCount = self._layoutCount, elementId = elementId }, + "This may indicate a layout thrashing issue. Check for circular dependencies or dynamic sizing that triggers re-layout" + ) + end +end + +return LayoutEngine diff --git a/libs/flexlove/modules/MemoryScanner.lua b/libs/flexlove/modules/MemoryScanner.lua new file mode 100644 index 00000000..07f5ca70 --- /dev/null +++ b/libs/flexlove/modules/MemoryScanner.lua @@ -0,0 +1,697 @@ +---@class MemoryScanner +---@field _StateManager table +---@field _Context table +---@field _ImageCache table +---@field _ErrorHandler table +local MemoryScanner = {} + +---Initialize MemoryScanner with dependencies +---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table} +function MemoryScanner.init(deps) + MemoryScanner._StateManager = deps.StateManager + MemoryScanner._Context = deps.Context + MemoryScanner._ImageCache = deps.ImageCache + MemoryScanner._ErrorHandler = deps.ErrorHandler +end + +---Count items in a table +---@param tbl table +---@return number +local function countTable(tbl) + local count = 0 + for _ in pairs(tbl) do + count = count + 1 + end + return count +end + +---Calculate memory size estimate for a table (recursive) +---@param tbl table +---@param visited table? Tracking table to prevent circular references +---@param depth number? Current recursion depth +---@return number bytes Estimated memory usage in bytes +local function estimateTableSize(tbl, visited, depth) + if type(tbl) ~= "table" then + return 0 + end + + visited = visited or {} + depth = depth or 0 + + -- Limit recursion depth to prevent stack overflow + if depth > 10 then + return 0 + end + + -- Check for circular references + if visited[tbl] then + return 0 + end + visited[tbl] = true + + local size = 40 -- Base table overhead (approximate) + + for k, v in pairs(tbl) do + -- Key size + if type(k) == "string" then + size = size + #k + 24 -- String overhead + elseif type(k) == "number" then + size = size + 8 + else + size = size + 8 -- Reference + end + + -- Value size + if type(v) == "string" then + size = size + #v + 24 + elseif type(v) == "number" then + size = size + 8 + elseif type(v) == "boolean" then + size = size + 4 + elseif type(v) == "table" then + size = size + estimateTableSize(v, visited, depth + 1) + elseif type(v) == "function" then + size = size + 16 -- Function reference + else + size = size + 8 -- Other references + end + end + + return size +end + +---Scan StateManager for memory issues +---@return table report Detailed report of StateManager memory usage +function MemoryScanner.scanStateManager() + local report = { + stateCount = 0, + stateStoreSize = 0, + metadataSize = 0, + callSiteCounterSize = 0, + orphanedStates = {}, + staleStates = {}, + largeStates = {}, + issues = {}, + } + + if not MemoryScanner._StateManager then + table.insert(report.issues, { + severity = "error", + message = "StateManager not initialized", + }) + return report + end + + local internal = MemoryScanner._StateManager._getInternalState() + local stateStore = internal.stateStore + local stateMetadata = internal.stateMetadata + local callSiteCounters = internal.callSiteCounters + local currentFrame = MemoryScanner._StateManager.getFrameNumber() + + -- Count states + report.stateCount = countTable(stateStore) + + -- Estimate sizes + report.stateStoreSize = estimateTableSize(stateStore) + report.metadataSize = estimateTableSize(stateMetadata) + report.callSiteCounterSize = estimateTableSize(callSiteCounters) + + -- Check for orphaned states (metadata without state) + for id, _ in pairs(stateMetadata) do + if not stateStore[id] then + table.insert(report.orphanedStates, id) + end + end + + -- Check for stale states (not accessed in many frames) + local staleThreshold = 120 -- 2 seconds at 60fps + for id, meta in pairs(stateMetadata) do + local framesSinceAccess = currentFrame - meta.lastFrame + if framesSinceAccess > staleThreshold then + table.insert(report.staleStates, { + id = id, + framesSinceAccess = framesSinceAccess, + createdFrame = meta.createdFrame, + accessCount = meta.accessCount, + }) + end + end + + -- Check for large states (may indicate memory bloat) + for id, state in pairs(stateStore) do + local stateSize = estimateTableSize(state) + if stateSize > 1024 then -- More than 1KB + table.insert(report.largeStates, { + id = id, + size = stateSize, + keyCount = countTable(state), + }) + end + end + + -- Check callSiteCounters (should be near 0 after frame cleanup) + local callSiteCount = countTable(callSiteCounters) + if callSiteCount > 100 then + table.insert(report.issues, { + severity = "warning", + message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount), + suggestion = "incrementFrame() may not be called properly, or counters aren't being reset", + }) + end + + -- Check for excessive state count + if report.stateCount > 500 then + table.insert(report.issues, { + severity = "warning", + message = string.format("High state count: %d states", report.stateCount), + suggestion = "Consider reducing element count or implementing more aggressive cleanup", + }) + end + + -- Check for orphaned states + if #report.orphanedStates > 0 then + table.insert(report.issues, { + severity = "error", + message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates), + suggestion = "This indicates a bug in state management - metadata should be cleaned up with state", + }) + end + + -- Check for stale states + if #report.staleStates > 10 then + table.insert(report.issues, { + severity = "warning", + message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates), + suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames", + }) + end + + return report +end + +---Scan Context for memory issues +---@return table report Detailed report of Context memory usage +function MemoryScanner.scanContext() + local report = { + topElementCount = 0, + zIndexElementCount = 0, + frameElementCount = 0, + issues = {}, + } + + if not MemoryScanner._Context then + table.insert(report.issues, { + severity = "error", + message = "Context not initialized", + }) + return report + end + + -- Count elements + report.topElementCount = #MemoryScanner._Context.topElements + report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements + report.frameElementCount = #MemoryScanner._Context._currentFrameElements + + -- Check for stale z-index elements (should be cleared each frame) + if MemoryScanner._Context.isImmediateMode() then + -- In immediate mode, _zIndexOrderedElements should be cleared at frame start + -- If it has elements outside of frame rendering, that's a leak + if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then + table.insert(report.issues, { + severity = "warning", + message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount), + suggestion = "clearFrameElements() may not be called properly in beginFrame()", + }) + end + end + + -- Check for excessive element count + if report.topElementCount > 100 then + table.insert(report.issues, { + severity = "info", + message = string.format("High top-level element count: %d", report.topElementCount), + suggestion = "Consider consolidating elements or using fewer top-level containers", + }) + end + + return report +end + +---Scan ImageCache for memory issues +---@return table report Detailed report of ImageCache memory usage +function MemoryScanner.scanImageCache() + local report = { + imageCount = 0, + estimatedMemory = 0, + issues = {}, + } + + if not MemoryScanner._ImageCache then + table.insert(report.issues, { + severity = "error", + message = "ImageCache not initialized", + }) + return report + end + + local stats = MemoryScanner._ImageCache.getStats() + report.imageCount = stats.count + report.estimatedMemory = stats.memoryEstimate + + -- Check for excessive memory usage (>100MB) + if report.estimatedMemory > 100 * 1024 * 1024 then + table.insert(report.issues, { + severity = "warning", + message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024), + suggestion = "Consider implementing cache eviction or clearing unused images", + }) + end + + -- Check for excessive image count + if report.imageCount > 50 then + table.insert(report.issues, { + severity = "info", + message = string.format("ImageCache has %d images", report.imageCount), + suggestion = "Review if all cached images are necessary", + }) + end + + return report +end + +---Check if a circular reference is intentional (parent-child, module, or metatable) +---@param path string The current path where circular ref was detected +---@param originalPath string The original path where the table was first seen +---@return boolean True if this is an intentional circular reference +local function isIntentionalCircularReference(path, originalPath) + -- Pattern 1: child.parent points back to parent + -- Example: "topElements.1.children.1.parent" -> "topElements.1" + if path:match("%.parent$") then + local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$") + if parentPath == originalPath then + return true + end + end + + -- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree + -- Example: "topElements.1" -> "topElements.1.children.1.parent" + if originalPath:match("%.parent$") then + local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$") + if childParentPath == path then + return true + end + end + + -- Pattern 3: Check for nested parent-child cycles + -- child.children[n].parent -> child + local segments = {} + for segment in path:gmatch("[^.]+") do + table.insert(segments, segment) + end + + -- Look for .children.N.parent pattern + for i = 1, #segments - 2 do + if segments[i] == "children" and segments[i + 2] == "parent" then + -- Reconstruct path without the .children.N.parent suffix + local reconstructedPath = table.concat(segments, ".", 1, i - 1) + if reconstructedPath == originalPath then + return true + end + end + end + + -- Pattern 4: Metatable __index self-references (modules) + -- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme" + if path:match("%.__index$") then + local basePath = path:match("^(.+)%.__index$") + if basePath == originalPath then + return true + end + end + + -- Pattern 5: Shared module references (elements sharing same module instances) + -- Example: Multiple elements referencing _utils, _Theme, _Blur, etc. + -- These start with _ and are typically modules + local pathModuleName = path:match("%.(_[%w]+)%.") + local originalModuleName = originalPath:match("%.(_[%w]+)%.") + + if pathModuleName and originalModuleName then + -- If both paths reference the same internal module (starting with _), it's intentional + if pathModuleName == originalModuleName then + return true + end + end + + -- Pattern 6: Shared Color/Transform objects between elements + -- These are value objects that can be safely shared + if path:match("Color") and originalPath:match("Color") then + return true + end + if path:match("Transform") and originalPath:match("Transform") then + return true + end + + -- Pattern 7: LayoutEngine holding reference to its element + -- Example: "element._layoutEngine.element" -> "element" + if path:match("%._layoutEngine%.element$") then + local elementPath = path:match("^(.+)%._layoutEngine%.element$") + if elementPath == originalPath then + return true + end + end + + -- Pattern 8: Renderer holding references to element properties + -- Example: "element._renderer.cornerRadius" -> "element.cornerRadius" + if path:match("%._renderer%.") then + local rendererBasePath = path:match("^(.+)%._renderer%.") + local originalBasePath = originalPath:match("^(.+)%.") + if rendererBasePath == originalBasePath then + return true + end + end + + -- Pattern 9: Context reference from layout engine (shared singleton) + -- Example: "element._layoutEngine._Context.topElements" -> "topElements" + if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then + return true + end + + return false +end + +---Detect circular references in a table +---@param tbl table Table to check +---@param path string? Current path (for reporting) +---@param visited table? Tracking table +---@return table[] circularRefs Array of circular reference paths +---@return table[] intentionalRefs Array of intentional parent-child refs +local function detectCircularReferences(tbl, path, visited) + if type(tbl) ~= "table" then + return {}, {} + end + + path = path or "root" + visited = visited or {} + local circularRefs = {} + local intentionalRefs = {} + + -- Check if we've seen this table before + if visited[tbl] then + local ref = { + path = path, + originalPath = visited[tbl], + } + + -- Determine if this is an intentional circular reference + if isIntentionalCircularReference(path, visited[tbl]) then + table.insert(intentionalRefs, ref) + else + table.insert(circularRefs, ref) + end + + return circularRefs, intentionalRefs + end + + -- Mark as visited + visited[tbl] = path + + -- Recursively check children + for k, v in pairs(tbl) do + if type(v) == "table" then + local childPath = path .. "." .. tostring(k) + local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited) + for _, ref in ipairs(childRefs) do + table.insert(circularRefs, ref) + end + for _, ref in ipairs(childIntentionalRefs) do + table.insert(intentionalRefs, ref) + end + end + end + + return circularRefs, intentionalRefs +end + +---Scan for circular references in immediate mode +---@return table report Detailed report of circular references +function MemoryScanner.scanCircularReferences() + local report = { + stateStoreCircularRefs = {}, + stateStoreIntentionalRefs = {}, + contextCircularRefs = {}, + contextIntentionalRefs = {}, + issues = {}, + } + + if MemoryScanner._StateManager then + local internal = MemoryScanner._StateManager._getInternalState() + report.stateStoreCircularRefs, report.stateStoreIntentionalRefs = + detectCircularReferences(internal.stateStore, "stateStore") + end + + if MemoryScanner._Context then + report.contextCircularRefs, report.contextIntentionalRefs = + detectCircularReferences(MemoryScanner._Context.topElements, "topElements") + end + + -- Report issues only for cross-module circular references + if #report.stateStoreCircularRefs > 0 then + table.insert(report.issues, { + severity = "info", + message = string.format( + "Found %d cross-module circular references in StateManager", + #report.stateStoreCircularRefs + ), + suggestion = "These are typically architectural dependencies between modules, not memory leaks", + }) + end + + if #report.contextCircularRefs > 0 then + table.insert(report.issues, { + severity = "info", + message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs), + suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks", + }) + end + + return report +end + +---Run comprehensive memory scan +---@return table report Complete memory analysis report +function MemoryScanner.scan() + local startMemory = collectgarbage("count") + + local report = { + timestamp = os.time(), + startMemory = startMemory / 1024, -- MB + stateManager = MemoryScanner.scanStateManager(), + context = MemoryScanner.scanContext(), + imageCache = MemoryScanner.scanImageCache(), + circularRefs = MemoryScanner.scanCircularReferences(), + summary = { + totalIssues = 0, + criticalIssues = 0, + warnings = 0, + info = 0, + }, + } + + -- Count issues by severity + local function countIssues(subReport) + for _, issue in ipairs(subReport.issues or {}) do + report.summary.totalIssues = report.summary.totalIssues + 1 + if issue.severity == "error" then + report.summary.criticalIssues = report.summary.criticalIssues + 1 + elseif issue.severity == "warning" then + report.summary.warnings = report.summary.warnings + 1 + elseif issue.severity == "info" then + report.summary.info = report.summary.info + 1 + end + end + end + + countIssues(report.stateManager) + countIssues(report.context) + countIssues(report.imageCache) + countIssues(report.circularRefs) + + -- Force GC and measure freed memory + local beforeGC = collectgarbage("count") + collectgarbage("collect") + collectgarbage("collect") + local afterGC = collectgarbage("count") + + report.gcAnalysis = { + beforeGC = beforeGC / 1024, -- MB + afterGC = afterGC / 1024, -- MB + freed = (beforeGC - afterGC) / 1024, -- MB + freedPercent = ((beforeGC - afterGC) / beforeGC) * 100, + } + + -- Analyze GC effectiveness + if report.gcAnalysis.freedPercent < 5 then + table.insert(report.stateManager.issues, { + severity = "info", + message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent), + suggestion = "Most memory is still referenced - this is normal if UI is active", + }) + elseif report.gcAnalysis.freedPercent > 30 then + table.insert(report.stateManager.issues, { + severity = "warning", + message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent), + suggestion = "Significant memory was unreferenced - may indicate cleanup issues", + }) + end + + return report +end + +---Format report as human-readable string +---@param report table Memory scan report +---@return string formatted Formatted report +function MemoryScanner.formatReport(report) + local lines = {} + + table.insert(lines, "=== FlexLöve Memory Scanner Report ===") + table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp))) + table.insert(lines, string.format("Memory: %.2f MB", report.startMemory)) + table.insert(lines, "") + + -- Summary + table.insert(lines, "--- Summary ---") + table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues)) + table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues)) + table.insert(lines, string.format(" Warnings: %d", report.summary.warnings)) + table.insert(lines, string.format(" Info: %d", report.summary.info)) + table.insert(lines, "") + + -- StateManager + table.insert(lines, "--- StateManager ---") + table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount)) + table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024)) + table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024)) + table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024)) + table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates)) + table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates)) + table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates)) + + if #report.stateManager.issues > 0 then + table.insert(lines, "Issues:") + for _, issue in ipairs(report.stateManager.issues) do + table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) + if issue.suggestion then + table.insert(lines, string.format(" → %s", issue.suggestion)) + end + end + end + table.insert(lines, "") + + -- Context + table.insert(lines, "--- Context ---") + table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount)) + table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount)) + table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount)) + + if #report.context.issues > 0 then + table.insert(lines, "Issues:") + for _, issue in ipairs(report.context.issues) do + table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) + if issue.suggestion then + table.insert(lines, string.format(" → %s", issue.suggestion)) + end + end + end + table.insert(lines, "") + + -- ImageCache + table.insert(lines, "--- ImageCache ---") + table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount)) + table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024)) + + if #report.imageCache.issues > 0 then + table.insert(lines, "Issues:") + for _, issue in ipairs(report.imageCache.issues) do + table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) + if issue.suggestion then + table.insert(lines, string.format(" → %s", issue.suggestion)) + end + end + end + table.insert(lines, "") + + -- Circular References + table.insert(lines, "--- Circular References ---") + table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs)) + table.insert( + lines, + string.format( + "StateStore (Intentional - parent-child, modules, metatables): %d", + #report.circularRefs.stateStoreIntentionalRefs + ) + ) + table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs)) + table.insert( + lines, + string.format( + "Context (Intentional - parent-child, modules, metatables): %d", + #report.circularRefs.contextIntentionalRefs + ) + ) + + if #report.circularRefs.issues > 0 then + table.insert(lines, "Issues:") + for _, issue in ipairs(report.circularRefs.issues) do + table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) + if issue.suggestion then + table.insert(lines, string.format(" → %s", issue.suggestion)) + end + end + else + table.insert(lines, " ✓ No unexpected circular references detected") + end + table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks") + table.insert(lines, "") + + -- GC Analysis + table.insert(lines, "--- Garbage Collection Analysis ---") + table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC)) + table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC)) + table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent)) + table.insert(lines, "") + + table.insert(lines, "=== End Report ===") + + return table.concat(lines, "\n") +end + +---Save report to file +---@param report table Memory scan report +---@param filename string? Output filename (default: memory_report.txt) +function MemoryScanner.saveReport(report, filename) + filename = filename or "memory_report.txt" + local formatted = MemoryScanner.formatReport(report) + + local file = io.open(filename, "w") + if file then + file:write(formatted) + file:close() + if MemoryScanner._ErrorHandler then + MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", { + resourceType = "report", + path = filename, + status = "saved", + }) + end + else + if MemoryScanner._ErrorHandler then + MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", { + resourceType = "report", + path = filename, + status = "failed to save", + }) + end + end +end + +return MemoryScanner diff --git a/libs/flexlove/modules/ModuleLoader.lua b/libs/flexlove/modules/ModuleLoader.lua new file mode 100644 index 00000000..1b48daf0 --- /dev/null +++ b/libs/flexlove/modules/ModuleLoader.lua @@ -0,0 +1,202 @@ +---@class ModuleLoader +local ModuleLoader = {} + +-- Module registry to track loaded vs. stub modules +ModuleLoader._registry = {} +ModuleLoader._ErrorHandler = nil + +--- Initialize ModuleLoader with dependencies +---@param deps table +function ModuleLoader.init(deps) + ModuleLoader._ErrorHandler = deps.ErrorHandler +end + +--- Create a null-object stub for a missing optional module +--- Provides safe defaults that won't cause runtime errors +---@param moduleName string +---@return table +local function createNullObject(moduleName) + local stub = { + _isStub = true, + _moduleName = moduleName, + } + + -- Common method stubs that return safe defaults + local metatable = { + __index = function(_, key) + -- Common initialization method + if key == "init" then + return function() + return stub + end + end + + -- Common constructor method + if key == "new" then + return function() + return stub + end + end + + -- Common draw method + if key == "draw" then + return function() end + end + + -- Common update method + if key == "update" then + return function() end + end + + -- Common render method + if key == "render" then + return function() end + end + + -- Common cleanup method + if key == "destroy" then + return function() end + end + + -- Common cleanup method + if key == "cleanup" then + return function() end + end + + -- Common clear method + if key == "clear" then + return function() end + end + + -- Common reset method + if key == "reset" then + return function() end + end + + -- Common get method + if key == "get" then + return function() + return nil + end + end + + -- Common set method + if key == "set" then + return function() end + end + + -- Common load method + if key == "load" then + return function() + return stub + end + end + + -- Common cache-related methods + if key == "cache" or key == "getCache" or key == "clearCache" then + return function() + return {} + end + end + + -- For any unknown method, return a no-op function that accepts any arguments + -- This allows safe method calls on stub objects (e.g., Performance:startFrame()) + return function() + return stub + end + end, + + -- Make function calls safe (in case the stub itself is called) + __call = function() + return stub + end, + } + + setmetatable(stub, metatable) + return stub +end + +--- Safely require a module with graceful fallback for optional modules +--- Returns the module if it exists, or a null-object stub if it's optional and missing +--- Throws an error if a required module is missing +---@param modulePath string Full path to the module (e.g., "modules.Performance") +---@param isOptional boolean If true, returns null-object on failure; if false, throws error +---@return table module The loaded module or a null-object stub +function ModuleLoader.safeRequire(modulePath, isOptional) + -- Check if already loaded + if ModuleLoader._registry[modulePath] then + return ModuleLoader._registry[modulePath] + end + + -- Attempt to load the module + local success, result = pcall(require, modulePath) + + if success then + -- Module loaded successfully + ModuleLoader._registry[modulePath] = result + return result + else + -- Module failed to load + if isOptional then + -- Create null-object stub for optional module + local stub = createNullObject(modulePath) + ModuleLoader._registry[modulePath] = stub + + -- Log warning about missing optional module + if ModuleLoader._ErrorHandler then + ModuleLoader._ErrorHandler:warn("ModuleLoader", "MOD_001", { + modulePath = modulePath, + }) + end + + return stub + else + -- Required module is missing - throw error + error(string.format("Required module '%s' not found: %s", modulePath, tostring(result))) + end + end +end + +--- Check if a module is actually loaded (not a stub) +---@param modulePath string Full path to the module +---@return boolean isLoaded True if module is loaded, false if it's a stub or not loaded +function ModuleLoader.isModuleLoaded(modulePath) + local module = ModuleLoader._registry[modulePath] + if not module then + return false + end + + -- Check if it's a stub + return not module._isStub +end + +--- Get list of all loaded modules +---@return table modules List of module paths that are actually loaded (not stubs) +function ModuleLoader.getLoadedModules() + local loaded = {} + for path, module in pairs(ModuleLoader._registry) do + if not module._isStub then + table.insert(loaded, path) + end + end + return loaded +end + +--- Get list of all stub modules +---@return table stubs List of module paths that are stubs +function ModuleLoader.getStubModules() + local stubs = {} + for path, module in pairs(ModuleLoader._registry) do + if module._isStub then + table.insert(stubs, path) + end + end + return stubs +end + +--- Clear the module registry (useful for testing) +function ModuleLoader._clearRegistry() + ModuleLoader._registry = {} +end + +return ModuleLoader diff --git a/libs/flexlove/modules/NinePatch.lua b/libs/flexlove/modules/NinePatch.lua new file mode 100644 index 00000000..4adb9696 --- /dev/null +++ b/libs/flexlove/modules/NinePatch.lua @@ -0,0 +1,217 @@ +local modulePath = (...):match("(.-)[^%.]+$") +local ImageScaler = require(modulePath .. "ImageScaler") + +local NinePatch = {} + +-- ErrorHandler will be injected via init +local ErrorHandler = nil + +--- Initialize NinePatch with dependencies +---@param deps table Dependencies table with ErrorHandler +function NinePatch.init(deps) + if deps and deps.ErrorHandler then + ErrorHandler = deps.ErrorHandler + end + -- Also initialize ImageScaler since it's a dependency + if ImageScaler.init then + ImageScaler.init(deps) + end +end + +--- Draw a 9-patch component using Android-style rendering +--- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only +---@param component ThemeComponent +---@param atlas love.Image +---@param x number -- X position (top-left corner) +---@param y number -- Y position (top-left corner) +---@param width number -- Total width (border-box) +---@param height number -- Total height (border-box) +---@param opacity number? +---@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier) +---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm +function NinePatch.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm) + if not component or not atlas then + return + end + + opacity = opacity or 1 + love.graphics.setColor(1, 1, 1, opacity) + + local regions = component.regions + + -- Extract border dimensions from regions (in pixels) + local left = regions.topLeft.w + local right = regions.topRight.w + local top = regions.topLeft.h + local bottom = regions.bottomLeft.h + local centerW = regions.middleCenter.w + local centerH = regions.middleCenter.h + + -- Calculate content area (space remaining after borders) + local contentWidth = width - left - right + local contentHeight = height - top - bottom + + -- Clamp to prevent negative dimensions + contentWidth = math.max(0, contentWidth) + contentHeight = math.max(0, contentHeight) + + -- Calculate stretch scales for edges and center + local scaleX = contentWidth / centerW + local scaleY = contentHeight / centerH + + -- Create quads for each region + local atlasWidth, atlasHeight = atlas:getDimensions() + + local function makeQuad(region) + return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight) + end + + -- Get corner scale multiplier + -- Priority: element-level override > component setting > default (nil = no scaling) + local scaleCorners = elementScaleCorners + if scaleCorners == nil then + scaleCorners = component.scaleCorners + end + + -- Priority: element-level override > component setting > default ("bilinear") + local scalingAlgorithm = elementScalingAlgorithm + if scalingAlgorithm == nil then + scalingAlgorithm = component.scalingAlgorithm or "bilinear" + end + + if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then + -- Initialize cache if needed + if not component._scaledRegionCache then + component._scaledRegionCache = {} + end + + -- Use the numeric scale multiplier directly + local scaleFactor = scaleCorners + + -- Helper to get or create scaled region + local function getScaledRegion(regionName, region, targetWidth, targetHeight) + local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm) + + if component._scaledRegionCache[cacheKey] then + return component._scaledRegionCache[cacheKey] + end + + -- Get ImageData from component (stored during theme loading) + local atlasData = component._loadedAtlasData + if not atlasData then + ErrorHandler.error( + "NinePatch", + "REN_007", + "No ImageData available for atlas. Image must be loaded with safeLoadImage.", + { + componentType = component.type, + } + ) + end + + local scaledData + + if scalingAlgorithm == "nearest" then + scaledData = + ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight) + else + scaledData = + ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight) + end + + -- Convert to image and cache + local scaledImage = love.graphics.newImage(scaledData) + component._scaledRegionCache[cacheKey] = scaledImage + + return scaledImage + end + + -- Calculate scaled dimensions for corners + local scaledLeft = math.floor(left * scaleFactor + 0.5) + local scaledRight = math.floor(right * scaleFactor + 0.5) + local scaledTop = math.floor(top * scaleFactor + 0.5) + local scaledBottom = math.floor(bottom * scaleFactor + 0.5) + + -- CORNERS (scaled using algorithm) + local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop) + local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop) + local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom) + local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom) + + love.graphics.draw(topLeftScaled, x, y) + love.graphics.draw(topRightScaled, x + width - scaledRight, y) + love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom) + love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom) + + -- Update content dimensions to account for scaled borders + local adjustedContentWidth = width - scaledLeft - scaledRight + local adjustedContentHeight = height - scaledTop - scaledBottom + adjustedContentWidth = math.max(0, adjustedContentWidth) + adjustedContentHeight = math.max(0, adjustedContentHeight) + + -- Recalculate stretch scales + local adjustedScaleX = adjustedContentWidth / centerW + local adjustedScaleY = adjustedContentHeight / centerH + + -- TOP/BOTTOM EDGES (stretch horizontally, scale vertically) + if adjustedContentWidth > 0 then + local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop) + local bottomCenterScaled = + getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom) + + love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1) + love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1) + end + + -- LEFT/RIGHT EDGES (stretch vertically, scale horizontally) + if adjustedContentHeight > 0 then + local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h) + local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h) + + love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY) + love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY) + end + + -- CENTER (stretch both dimensions, no scaling) + if adjustedContentWidth > 0 and adjustedContentHeight > 0 then + love.graphics.draw( + atlas, + makeQuad(regions.middleCenter), + x + scaledLeft, + y + scaledTop, + 0, + adjustedScaleX, + adjustedScaleY + ) + end + else + -- Original rendering logic (no scaling) + -- CORNERS (no scaling - 1:1 pixel perfect) + love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y) + love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y) + love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight) + love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight) + + -- TOP/BOTTOM EDGES (stretch horizontally only) + if contentWidth > 0 then + love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1) + love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1) + end + + -- LEFT/RIGHT EDGES (stretch vertically only) + if contentHeight > 0 then + love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY) + love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY) + end + + -- CENTER (stretch both dimensions) + if contentWidth > 0 and contentHeight > 0 then + love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY) + end + end + + -- Reset color + love.graphics.setColor(1, 1, 1, 1) +end + +return NinePatch diff --git a/libs/flexlove/modules/NumberValidation.lua b/libs/flexlove/modules/NumberValidation.lua new file mode 100644 index 00000000..94124e35 --- /dev/null +++ b/libs/flexlove/modules/NumberValidation.lua @@ -0,0 +1,351 @@ +local modulePath = (...):match("(.-)[^%.]+$") +local function req(name) + return require(modulePath .. name) +end + +-- All numeric, range, type, and enum validation lives here. +-- `clamp` is injected via init() to avoid a cross-import into utils. +-- `ErrorHandler` is injected via init() so error reporting routes through +-- the shared handler (matching the pre-split behavior of utils.validate*). + +local ErrorHandler = nil +local clamp = nil + +--- Initialize dependencies +---@param deps table Dependencies: { ErrorHandler = table, clamp = function } +local function init(deps) + if type(deps) == "table" then + ErrorHandler = deps.ErrorHandler or ErrorHandler + clamp = deps.clamp or clamp + end +end + +-- Numeric validation utilities + +--- Check if a value is NaN (not-a-number) +--- @param value any Value to check +--- @return boolean +local function isNaN(value) + return type(value) == "number" and value ~= value +end + +--- Check if a value is Infinity +--- @param value any Value to check +--- @return boolean +local function isInfinity(value) + return type(value) == "number" and (value == math.huge or value == -math.huge) +end + +--- Validate a numeric value with comprehensive checks +--- @param value any Value to validate +--- @param options table? Validation options +--- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue +local function validateNumber(value, options) + options = options or {} + + -- Check if value is a number type + if type(value) ~= "number" then + if options.default ~= nil then + return true, nil, options.default + end + return false, string.format("Value must be a number, got %s", type(value)), nil + end + + -- Check for NaN + if isNaN(value) then + if not options.allowNaN then + if options.default ~= nil then + return true, nil, options.default + end + return false, "Value is NaN (not-a-number)", nil + end + end + + -- Check for Infinity + if isInfinity(value) then + if not options.allowInfinity then + if options.default ~= nil then + return true, nil, options.default + end + return false, "Value is Infinity", nil + end + end + + -- Check for integer requirement + if options.integer and math.floor(value) ~= value then + return false, string.format("Value must be an integer, got %s", value), nil + end + + -- Check for positive requirement + if options.positive and value <= 0 then + return false, string.format("Value must be positive, got %s", value), nil + end + + -- Check bounds + if options.min and value < options.min then + return false, string.format("Value %s is below minimum %s", value, options.min), nil + end + + if options.max and value > options.max then + return false, string.format("Value %s is above maximum %s", value, options.max), nil + end + + return true, nil, value +end + +--- Sanitize a numeric value (never errors, always returns valid number) +--- @param value any Value to sanitize +--- @param min number? Minimum value +--- @param max number? Maximum value +--- @param default number? Default value for invalid inputs +--- @return number Sanitized value +local function sanitizeNumber(value, min, max, default) + default = default or 0 + min = min or -math.huge + max = max or math.huge + + -- Convert to number if possible + if type(value) == "string" then + value = tonumber(value) + end + + -- Handle non-numeric + if type(value) ~= "number" then + return default + end + + -- Handle NaN + if isNaN(value) then + return default + end + + -- Handle Infinity + if value == math.huge then + return max + end + if value == -math.huge then + return min + end + + -- Clamp to range + return clamp(value, min, max) +end + +--- Validate and convert to integer +--- @param value any Value to validate +--- @param min number? Minimum value +--- @param max number? Maximum value +--- @return boolean, string?, number? Returns valid, errorMessage, integerValue +local function validateInteger(value, min, max) + local valid, err, sanitized = validateNumber(value, { + min = min, + max = max, + integer = true, + }) + + if not valid then + return false, err, nil + end + + return true, nil, math.floor(sanitized or value) +end + +--- Validate and normalize percentage value +--- @param value any Value to validate (can be "50%", 0.5, or 50) +--- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1) +local function validatePercentage(value) + -- Handle string percentage + if type(value) == "string" then + local num = value:match("^(%d+%.?%d*)%%$") + if num then + value = tonumber(num) + if value then + value = value / 100 + end + else + value = tonumber(value) + end + end + + if type(value) ~= "number" then + return false, "Percentage must be a number", nil + end + + if isNaN(value) or isInfinity(value) then + return false, "Percentage cannot be NaN or Infinity", nil + end + + -- If value is > 1, assume it's 0-100 range + if value > 1 then + value = value / 100 + end + + -- Clamp to 0-1 + value = clamp(value, 0, 1) + + return true, nil, value +end + +--- Validate opacity value (0-1) +--- @param value any Value to validate +--- @return boolean, string?, number? Returns valid, errorMessage, opacityValue +local function validateOpacity(value) + return validateNumber(value, { min = 0, max = 1, default = 1 }) +end + +--- Validate degree value (0-360) +--- @param value any Value to validate +--- @return boolean, string?, number? Returns valid, errorMessage, degreeValue +local function validateDegrees(value) + local valid, err, sanitized = validateNumber(value) + if not valid then + return false, err, nil + end + + -- Normalize to 0-360 range + local degrees = sanitized or value + degrees = degrees % 360 + if degrees < 0 then + degrees = degrees + 360 + end + + return true, nil, degrees +end + +--- Validate coordinate value (pixel position) +--- @param value any Value to validate +--- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue +local function validateCoordinate(value) + return validateNumber(value, { + allowNaN = false, + allowInfinity = false, + }) +end + +--- Validate dimension value (width/height, must be non-negative) +--- @param value any Value to validate +--- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue +local function validateDimension(value) + return validateNumber(value, { + min = 0, + allowNaN = false, + allowInfinity = false, + }) +end + +--- Validate that a value is in an enum table +---@param value any Value to validate +---@param enumTable table Enum table with valid values +---@param propName string Property name for error messages +---@param moduleName string? Module name for error messages (default: "Element") +---@return boolean True if valid +local function validateEnum(value, enumTable, propName, moduleName) + if value == nil then + return true + end + + for _, validValue in pairs(enumTable) do + if value == validValue then + return true + end + end + + -- Build list of valid options + local validOptions = {} + for _, v in pairs(enumTable) do + table.insert(validOptions, "'" .. v .. "'") + end + table.sort(validOptions) + + if ErrorHandler then + ErrorHandler:error(moduleName or "Element", "VAL_007", { + property = propName, + expected = table.concat(validOptions, ", "), + got = tostring(value), + }) + else + error( + string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value)) + ) + end +end + +--- Validate that a numeric value is within a range +---@param value any Value to validate +---@param min number Minimum allowed value +---@param max number Maximum allowed value +---@param propName string Property name for error messages +---@param moduleName string? Module name for error messages (default: "Element") +---@return boolean True if valid +local function validateRange(value, min, max, propName, moduleName) + if value == nil then + return true + end + if type(value) ~= "number" then + if ErrorHandler then + ErrorHandler:error(moduleName or "Element", "VAL_001", { + property = propName, + expected = "number", + got = type(value), + }) + else + error(string.format("%s must be a number, got %s", propName, type(value))) + end + elseif value < min or value > max then + if ErrorHandler then + ErrorHandler:error(moduleName or "Element", "VAL_002", { + property = propName, + min = tostring(min), + max = tostring(max), + value = tostring(value), + }) + else + error( + string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value)) + ) + end + end + return true +end + +--- Validate that a value is of the expected type +---@param value any Value to validate +---@param expectedType string Expected type name +---@param propName string Property name for error messages +---@param moduleName string? Module name for error messages (default: "Element") +---@return boolean True if valid +local function validateType(value, expectedType, propName, moduleName) + if value == nil then + return true + end + local actualType = type(value) + if actualType ~= expectedType then + if ErrorHandler then + ErrorHandler:error(moduleName or "Element", "VAL_001", { + property = propName, + expected = expectedType, + got = actualType, + }) + else + error(string.format("%s must be %s, got %s", propName, expectedType, actualType)) + end + end + return true +end + +return { + init = init, + isNaN = isNaN, + isInfinity = isInfinity, + validateNumber = validateNumber, + sanitizeNumber = sanitizeNumber, + validateInteger = validateInteger, + validatePercentage = validatePercentage, + validateOpacity = validateOpacity, + validateDegrees = validateDegrees, + validateCoordinate = validateCoordinate, + validateDimension = validateDimension, + validateEnum = validateEnum, + validateRange = validateRange, + validateType = validateType, +} diff --git a/libs/flexlove/modules/PathValidator.lua b/libs/flexlove/modules/PathValidator.lua new file mode 100644 index 00000000..b3ba0e81 --- /dev/null +++ b/libs/flexlove/modules/PathValidator.lua @@ -0,0 +1,198 @@ +local modulePath = (...):match("(.-)[^%.]+$") +local function req(name) + return require(modulePath .. name) +end + +-- Path sanitization, validation, and file-extension helpers. +-- Uses love.filesystem when available (optional) for existence checks. + +--- Normalize a file path for consistent cache keys +---@param path string File path to normalize +---@return string Normalized path +local function normalizePath(path) + path = path:match("^%s*(.-)%s*$") + path = path:gsub("\\", "/") + path = path:gsub("/+", "/") + return path +end + +--- Sanitize a file path +--- @param path string Path to sanitize +--- @return string Sanitized path +local function sanitizePath(path) + if path == nil then + return "" + end + path = tostring(path) + + -- Trim whitespace + path = path:match("^%s*(.-)%s*$") or "" + + -- Normalize separators to forward slash + path = path:gsub("\\", "/") + + -- Remove duplicate slashes + path = path:gsub("/+", "/") + + -- Remove trailing slash (except for root) + if #path > 1 and path:sub(-1) == "/" then + path = path:sub(1, -2) + end + + return path +end + +--- Check if a path is safe (no traversal attacks) +--- @param path string Path to check +--- @param baseDir string? Base directory to check against (optional) +--- @return boolean, string? Returns true if safe, or false with reason +local function isPathSafe(path, baseDir) + if path == nil or path == "" then + return false, "Path is empty" + end + + -- Sanitize the path + path = sanitizePath(path) + + -- Check for suspicious patterns + if path:match("%.%.") then + return false, "Path contains '..' (parent directory reference)" + end + + -- Check for null bytes + if path:match("%z") then + return false, "Path contains null bytes" + end + + -- Check for encoded traversal attempts (including double-encoding) + local lowerPath = path:lower() + if + lowerPath:match("%%2e") + or lowerPath:match("%%2f") + or lowerPath:match("%%5c") + or lowerPath:match("%%252e") + or lowerPath:match("%%252f") + or lowerPath:match("%%255c") + then + return false, "Path contains URL-encoded directory separators" + end + + -- If baseDir is provided, ensure path is within it + if baseDir then + baseDir = sanitizePath(baseDir) + + -- For relative paths, prepend baseDir + local fullPath = path + if not path:match("^/") and not path:match("^%a:") then + fullPath = baseDir .. "/" .. path + end + fullPath = sanitizePath(fullPath) + + -- Check if fullPath starts with baseDir + if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then + return false, "Path is outside allowed directory" + end + end + + return true, nil +end + +--- Validate a file path with comprehensive checks +--- @param path string Path to validate +--- @param options table? Validation options +--- @return boolean, string? Returns true if valid, or false with error message +local function validatePath(path, options) + options = options or {} + + -- Check path is not nil/empty + if path == nil or path == "" then + return false, "Path is empty" + end + + path = tostring(path) + + -- Check maximum length + local maxLength = options.maxLength or 4096 + if #path > maxLength then + return false, string.format("Path exceeds maximum length of %d characters", maxLength) + end + + -- Sanitize path + path = sanitizePath(path) + + -- Check for safety (traversal attacks) + local safe, reason = isPathSafe(path, options.baseDir) + if not safe then + return false, reason + end + + -- Check allowed extensions + if options.allowedExtensions then + local ext = path:match("%.([^%.]+)$") + if not ext then + return false, "Path has no file extension" + end + + ext = ext:lower() + local allowed = false + for _, allowedExt in ipairs(options.allowedExtensions) do + if ext == allowedExt:lower() then + allowed = true + break + end + end + + if not allowed then + return false, string.format("File extension '%s' is not allowed", ext) + end + end + + -- Check if file must exist + if options.mustExist and love and love.filesystem then + local info = love.filesystem.getInfo(path) + if not info then + return false, "File does not exist" + end + end + + return true, nil +end + +--- Get file extension from path +--- @param path string File path +--- @return string? extension File extension (lowercase) or nil +local function getFileExtension(path) + if not path then + return nil + end + local ext = path:match("%.([^%.]+)$") + return ext and ext:lower() or nil +end + +--- Check if path has allowed extension +--- @param path string File path +--- @param allowedExtensions table Array of allowed extensions +--- @return boolean +local function hasAllowedExtension(path, allowedExtensions) + local ext = getFileExtension(path) + if not ext then + return false + end + + for _, allowedExt in ipairs(allowedExtensions) do + if ext == allowedExt:lower() then + return true + end + end + + return false +end + +return { + normalizePath = normalizePath, + sanitizePath = sanitizePath, + isPathSafe = isPathSafe, + validatePath = validatePath, + getFileExtension = getFileExtension, + hasAllowedExtension = hasAllowedExtension, +} diff --git a/libs/flexlove/modules/Performance.lua b/libs/flexlove/modules/Performance.lua new file mode 100644 index 00000000..cf0458f0 --- /dev/null +++ b/libs/flexlove/modules/Performance.lua @@ -0,0 +1,560 @@ +---@class Performance +---@field enabled boolean +---@field hudEnabled boolean +---@field hudToggleKey string +---@field hudPosition {x: number, y: number} +---@field warningThresholdMs number +---@field criticalThresholdMs number +---@field logToConsole boolean +---@field logWarnings boolean +---@field warningsEnabled boolean +---@field _ErrorHandler table? +---@field _timers table +---@field _metrics table +---@field _lastMetricsCleanup number +---@field _frameMetrics table +---@field _memoryMetrics table +---@field _warnings table +---@field _lastFrameStart number? +---@field _shownWarnings table +---@field _memoryProfiler table +local Performance = {} +Performance.__index = Performance + +---@type Performance|nil +local instance = nil + +local METRICS_CLEANUP_INTERVAL = 30 +local METRICS_RETENTION_TIME = 10 +local MAX_METRICS_COUNT = 500 +local CORE_METRICS = { frame = true, layout = true, render = true } + +---@param config {enabled?: boolean, hudEnabled?: boolean, hudToggleKey?: string, hudPosition?: {x: number, y: number}, warningThresholdMs?: number, criticalThresholdMs?: number, logToConsole?: boolean, logWarnings?: boolean, warningsEnabled?: boolean, memoryProfiling?: boolean}? +---@param deps {ErrorHandler: ErrorHandler} +---@return Performance +function Performance.init(config, deps) + if instance == nil then + local self = setmetatable({}, Performance) + + -- Configuration + self.enabled = config and config.enabled or false + self.hudEnabled = config and config.hudEnabled or false + self.hudToggleKey = config and config.hudToggleKey or "f3" + self.hudPosition = config and config.hudPosition or { x = 10, y = 10 } + self.warningThresholdMs = config and config.warningThresholdMs or 13.0 + self.criticalThresholdMs = config and config.criticalThresholdMs or 16.67 + self.logToConsole = config and config.logToConsole or false + self.logWarnings = config and config.logWarnings or true + self.warningsEnabled = config and config.warningsEnabled or true + + self._timers = {} + self._metrics = {} + self._lastMetricsCleanup = 0 + self._frameMetrics = { + frameCount = 0, + totalTime = 0, + lastFrameTime = 0, + minFrameTime = math.huge, + maxFrameTime = 0, + fps = 0, + lastFpsUpdate = 0, + fpsUpdateInterval = 0.5, + } + self._memoryMetrics = { + current = 0, + peak = 0, + gcCount = 0, + lastGcCheck = 0, + } + self._warnings = {} + self._lastFrameStart = nil + self._shownWarnings = {} + self._memoryProfiler = { + enabled = config and config.memoryProfiling or false, + sampleInterval = 60, + framesSinceLastSample = 0, + samples = {}, + maxSamples = 20, + monitoredTables = {}, + } + self._ErrorHandler = deps and deps.ErrorHandler + instance = self + end + return instance +end + +--- Toggle HUD visibility +function Performance:toggleHUD() + self.hudEnabled = not self.hudEnabled +end + +function Performance:startTimer(name) + if not self.enabled then + return + end + self._timers[name] = love.timer.getTime() +end + +function Performance:stopTimer(name) + if not self.enabled then + return nil + end + + local startTime = self._timers[name] + if not startTime then + -- Silently return nil if timer wasn't started + -- This can happen legitimately when Performance is toggled mid-frame + -- or when layout functions have early returns + return nil + end + + local elapsed = (love.timer.getTime() - startTime) * 1000 + self._timers[name] = nil + + -- Update metrics + if not self._metrics[name] then + self._metrics[name] = { + total = 0, + count = 0, + min = math.huge, + max = 0, + average = 0, + lastUsed = love.timer.getTime(), + } + end + + local m = self._metrics[name] + m.total = m.total + elapsed + m.count = m.count + 1 + m.min = math.min(m.min, elapsed) + m.max = math.max(m.max, elapsed) + m.average = m.total / m.count + m.lastUsed = love.timer.getTime() + + -- Check for warnings + if elapsed > self.criticalThresholdMs then + self:_addWarning(name, elapsed, "critical") + elseif elapsed > self.warningThresholdMs then + self:_addWarning(name, elapsed, "warning") + end + + if self.logToConsole then + -- Use ErrorHandler if available, otherwise fall back to print + if self._ErrorHandler and self._ErrorHandler.warn then + self._ErrorHandler:warn("Performance", "PERF_001", { + metric = name, + elapsed = string.format("%.3fms", elapsed), + }) + else + print(string.format("[Performance] %s: %.3fms", name, elapsed)) + end + end + + return elapsed +end + +--- Update with actual delta time from LÖVE (call from love.update) +---@param dt number Delta time in seconds +function Performance:updateDeltaTime(dt) + if not self.enabled then + return + end + local now = love.timer.getTime() + if now - self._frameMetrics.lastFpsUpdate >= self._frameMetrics.fpsUpdateInterval then + if dt > 0 then + self._frameMetrics.fps = math.floor(1 / dt + 0.5) + end + self._frameMetrics.lastFpsUpdate = now + end +end + +--- Start frame timing (call at beginning of frame) +function Performance:startFrame() + if not self.enabled then + return + end + self._lastFrameStart = love.timer.getTime() + self:_updateMemory() +end + +function Performance:endFrame() + if not self.enabled or not self._lastFrameStart then + return + end + + local now = love.timer.getTime() + local frameTime = (now - self._lastFrameStart) * 1000 + + self._frameMetrics.lastFrameTime = frameTime + self._frameMetrics.totalTime = self._frameMetrics.totalTime + frameTime + self._frameMetrics.frameCount = self._frameMetrics.frameCount + 1 + self._frameMetrics.minFrameTime = math.min(self._frameMetrics.minFrameTime, frameTime) + self._frameMetrics.maxFrameTime = math.max(self._frameMetrics.maxFrameTime, frameTime) + + if frameTime > self.criticalThresholdMs then + self:_addWarning("frame", frameTime, "critical") + end + + self:updateMemoryProfiling() + + -- Periodic metrics cleanup + if now - self._lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then + local cleanupTime = now - METRICS_RETENTION_TIME + for name, data in pairs(self._metrics) do + if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then + self._metrics[name] = nil + end + end + self._lastMetricsCleanup = now + end + + -- Enforce max metrics limit + local metricsCount = 0 + for _ in pairs(self._metrics) do + metricsCount = metricsCount + 1 + end + + if metricsCount > MAX_METRICS_COUNT then + local sortedMetrics = {} + for name, data in pairs(self._metrics) do + if not CORE_METRICS[name] then + table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 }) + end + end + + table.sort(sortedMetrics, function(a, b) + return a.lastUsed < b.lastUsed + end) + + local toRemove = metricsCount - MAX_METRICS_COUNT + for i = 1, math.min(toRemove, #sortedMetrics) do + self._metrics[sortedMetrics[i].name] = nil + end + end +end + +--- Update memory metrics +function Performance:_updateMemory() + if not self.enabled then + return + end + + local memKb = collectgarbage("count") + self._memoryMetrics.current = memKb + self._memoryMetrics.peak = math.max(self._memoryMetrics.peak, memKb) + + local now = love.timer.getTime() + if now - self._memoryMetrics.lastGcCheck >= 1.0 then + self._memoryMetrics.gcCount = self._memoryMetrics.gcCount + 1 + self._memoryMetrics.lastGcCheck = now + end +end + +--- Add a performance warning (private) +--- @param name string Metric name +--- @param value number Metric value +--- @param level "warning"|"critical" Warning level +function Performance:_addWarning(name, value, level) + if not self.logWarnings then + return + end + + local warning = { + name = name, + value = value, + level = level, + time = love.timer.getTime(), + } + + table.insert(self._warnings, warning) + + if #self._warnings > 100 then + table.remove(self._warnings, 1) + end + + if self.logToConsole or self.warningsEnabled then + local warningKey = name .. "_" .. level + local lastWarningTime = self._shownWarnings[warningKey] or 0 + local now = love.timer.getTime() + + if now - lastWarningTime >= 60 then + if self._ErrorHandler and self._ErrorHandler.warn then + local code = level == "critical" and "PERF_002" or "PERF_001" + + self._ErrorHandler:warn("Performance", code, { + metric = name, + value = string.format("%.2fms", value), + threshold = level == "critical" and self.criticalThresholdMs or self.warningThresholdMs, + }) + end + + self._shownWarnings[warningKey] = now + end + end +end + +--- Render performance HUD +--- @param x number? X position (default: 10) +--- @param y number? Y position (default: 10) +function Performance:renderHUD(x, y) + if not self.hudEnabled then + return + end + + x = x or self.hudPosition.x + y = y or self.hudPosition.y + + self:_updateMemory() + + local fm = self._frameMetrics + local mm = self._memoryMetrics + + love.graphics.setColor(0, 0, 0, 0.8) + love.graphics.rectangle("fill", x, y, 300, 220) + + love.graphics.setColor(1, 1, 1, 1) + local lineHeight = 18 + local currentY = y + 10 + + -- FPS + local fpsColor = { 1, 1, 1 } + if fm.lastFrameTime > self.criticalThresholdMs then + fpsColor = { 1, 0, 0 } + elseif fm.lastFrameTime > self.warningThresholdMs then + fpsColor = { 1, 1, 0 } + end + love.graphics.setColor(fpsColor) + love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY) + currentY = currentY + lineHeight + + love.graphics.setColor(1, 1, 1, 1) + local avgFrame = fm.frameCount > 0 and fm.totalTime / fm.frameCount or 0 + love.graphics.print(string.format("Avg Frame: %.2fms", avgFrame), x + 10, currentY) + currentY = currentY + lineHeight + love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY) + currentY = currentY + lineHeight + + local currentMb = mm.current / 1024 + local peakMb = mm.peak / 1024 + love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", currentMb, peakMb), x + 10, currentY) + currentY = currentY + lineHeight + + local metricsCount = 0 + for _ in pairs(self._metrics) do + metricsCount = metricsCount + 1 + end + local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 } + love.graphics.setColor(metricsColor) + love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY) + currentY = currentY + lineHeight + 5 + + -- Top timings + love.graphics.setColor(1, 1, 1, 1) + local sortedMetrics = {} + for name, data in pairs(self._metrics) do + table.insert(sortedMetrics, { name = name, average = data.average }) + end + table.sort(sortedMetrics, function(a, b) + return a.average > b.average + end) + + love.graphics.print("Top Timings:", x + 10, currentY) + currentY = currentY + lineHeight + + for i = 1, math.min(5, #sortedMetrics) do + local m = sortedMetrics[i] + love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY) + currentY = currentY + lineHeight + end + + if #self._warnings > 0 then + love.graphics.setColor(1, 0.5, 0, 1) + love.graphics.print(string.format("Warnings: %d", #self._warnings), x + 10, currentY) + end +end + +--- Handle keyboard input for HUD toggle +--- @param key string Key pressed +function Performance:keypressed(key) + if key == self.hudToggleKey then + self:toggleHUD() + end +end + +--- Log a performance warning (only once per warning key) +--- @param warningKey string Unique key for this warning type +--- @param module string Module name (e.g., "LayoutEngine", "Element") +--- @param message string Warning message +--- @param details table? Additional details +--- @param suggestion string? Optimization suggestion +function Performance:logWarning(warningKey, module, message, details, suggestion) + if not self.warningsEnabled then + return + end + + if self._shownWarnings[warningKey] then + return + end + + self._shownWarnings[warningKey] = true + + local count = 0 + for _ in pairs(self._shownWarnings) do + count = count + 1 + end + if count > 1000 then + self._shownWarnings = { [warningKey] = true } + end + + if self._ErrorHandler and self._ErrorHandler.warn then + self._ErrorHandler:warn(module, "PERF_001", details or {}) + end +end + +--- Track a counter metric (increments per frame) +--- @param name string Counter name +--- @param value number? Value to add (default: 1) +function Performance:incrementCounter(name, value) + if not self.enabled then + return + end + + value = value or 1 + + if not self._metrics[name] then + self._metrics[name] = { + total = 0, + count = 0, + min = math.huge, + max = 0, + average = 0, + frameValue = 0, + lastUsed = love.timer.getTime(), + } + end + + local m = self._metrics[name] + m.frameValue = (m.frameValue or 0) + value + m.lastUsed = love.timer.getTime() +end + +--- Reset frame counters (call at end of frame) +function Performance:resetFrameCounters() + if not self.enabled then + return + end + + local now = love.timer.getTime() + local toRemove = {} + + for name, data in pairs(self._metrics) do + if data.frameValue then + if data.frameValue > 0 then + data.total = data.total + data.frameValue + data.count = data.count + 1 + data.min = math.min(data.min, data.frameValue) + data.max = math.max(data.max, data.frameValue) + data.average = data.total / data.count + data.lastUsed = now + end + + data.frameValue = 0 + + if data.count == 0 and not CORE_METRICS[name] then + table.insert(toRemove, name) + end + end + end + + for _, name in ipairs(toRemove) do + self._metrics[name] = nil + end +end + +--- Register a table for memory leak monitoring +--- @param name string Friendly name for the table +--- @param tableRef table Reference to the table to monitor +function Performance:registerTableForMonitoring(name, tableRef) + self._memoryProfiler.monitoredTables[name] = tableRef +end + +function Performance:_sampleMemory() + local sample = { + time = love.timer.getTime(), + memory = collectgarbage("count") / 1024, -- MB + tableSizes = {}, + } + local function getTableSize(tbl) + local count = 0 + for _ in pairs(tbl) do + count = count + 1 + end + return count + end + + for name, tableRef in pairs(self._memoryProfiler.monitoredTables) do + sample.tableSizes[name] = getTableSize(tableRef) + end + + table.insert(self._memoryProfiler.samples, sample) + + -- Keep only maxSamples + if #self._memoryProfiler.samples > self._memoryProfiler.maxSamples then + table.remove(self._memoryProfiler.samples, 1) + end + + -- Check for memory leaks (consistent growth) + if #self._memoryProfiler.samples >= 5 then + for name, _ in pairs(self._memoryProfiler.monitoredTables) do + local sizes = {} + for i = math.max(1, #self._memoryProfiler.samples - 4), #self._memoryProfiler.samples do + table.insert(sizes, self._memoryProfiler.samples[i].tableSizes[name]) + end + + -- Check if table is consistently growing + local growing = true + for i = 2, #sizes do + if sizes[i] <= sizes[i - 1] then + growing = false + break + end + end + + if growing and sizes[#sizes] > sizes[1] * 1.5 then + self:_addWarning("memory_leak", sizes[#sizes], "warning") + + if not self._shownWarnings[name] then + local message = string.format("Table '%s' growing consistently", name) + if self._ErrorHandler and self._ErrorHandler.warn then + self._ErrorHandler:warn("Performance", "MEM_001", { + table = name, + initialSize = sizes[1], + currentSize = sizes[#sizes], + growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100), + }) + end + + self._shownWarnings[name] = true + end + elseif not growing then + self._shownWarnings[name] = nil + end + end + end +end + +--- Update memory profiling (call from endFrame) +function Performance:updateMemoryProfiling() + if not self._memoryProfiler.enabled then + return + end + + self._memoryProfiler.framesSinceLastSample = self._memoryProfiler.framesSinceLastSample + 1 + + if self._memoryProfiler.framesSinceLastSample >= self._memoryProfiler.sampleInterval then + self:_sampleMemory() + self._memoryProfiler.framesSinceLastSample = 0 + end +end + +return Performance diff --git a/libs/flexlove/modules/PropertySchema.lua b/libs/flexlove/modules/PropertySchema.lua new file mode 100644 index 00000000..d2fe5b43 --- /dev/null +++ b/libs/flexlove/modules/PropertySchema.lua @@ -0,0 +1,505 @@ +-- modules/PropertySchema.lua +-- +-- Declarative source of truth for every Element prop. +-- +-- Each entry describes one prop that Element.new / Element:setProperty currently +-- handles inline. Downstream tasks (03 data-driven prop binding, 05 registry-driven +-- setProperty dispatch) read this metadata instead of hardcoding property names. +-- +-- Design constraints (locked — tasks 03/05 depend on this API): +-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler. +-- Normalizers/validators are small, dependency-free closures so the module is +-- unit-testable standalone. Color/^/unit/enum *defaults* that require those +-- modules are left as `nil` here and applied by construction-time special +-- handlers in Task 03; only defaults expressible as literals are stored. +-- * O(1) lookup — `get(name)` is a single table index into a pre-built registry; +-- no per-call construction. +-- * Additive — `define(specs)` merges entries by name so build profiles can +-- extend/override without rebuilding the whole table. +-- +-- Metadata shape per prop (all fields present, false/nil when not applicable): +-- type string — type tag for tooling ("number"|"string"|"boolean"| +-- "table"|"function"|"color"|"any") +-- default any|nil — literal default value applied when prop is absent +-- normalizer fn|nil — pure fn(value) -> value; transforms input before +-- storage (e.g. single-value padding -> 4-side table) +-- validator fn|nil — pure fn(value) -> bool; returns false for invalid +-- input (Task 03 warns + falls back on false) +-- isDimension boolean — true for width/height: setProperty routes these +-- through _resolveDimensionProperty (unit-string +-- resolution + border-box sync). Other unit-accepting +-- props (x/y/gap/padding/etc.) are resolved at +-- construction via special handlers, NOT via this flag. +-- affectsLayout boolean — true for props in the legacy setProperty +-- `layoutProperties` table; setting one invalidates +-- layout (matches baseline behavior exactly). +-- syncsTheme boolean — true for props whose setProperty path must reach +-- ThemeManager/Renderer (disabled/active/themeComponent) +-- hasDeferred boolean — true for callbacks that have an `onDeferred` +-- boolean companion prop (auto-wired by Task 03) +-- storageKey string|nil— when set, the prop is stored on the element under +-- this key instead of its own name (prop aliases, e.g. +-- isDisabled -> stored as `disabled`) + +local PropertySchema = {} + +---@type table +local registry = {} + +-- --------------------------------------------------------------------------- +-- Pure normalizers (small + dependency-free; hot-pathed during construction) +-- --------------------------------------------------------------------------- + +--- Expand a single value to a 4-side table. Leaves tables unchanged. nil passthrough. +--- Used by padding/margin: `padding = 5` -> `{top=5,right=5,bottom=5,left=5}`. +local function expandSides(value) + if value == nil then + return nil + end + if type(value) == "table" then + return value + end + return { top = value, right = value, bottom = value, left = value } +end + +--- Normalize flex direction aliases to internal enum names. +--- "row" -> "horizontal", "column" -> "vertical", +--- "row-reverse" -> "horizontal-reverse", "column-reverse" -> "vertical-reverse"; +--- everything else passes through. +local function normalizeFlexDirection(value) + if value == "row" then + return "horizontal" + elseif value == "column" then + return "vertical" + elseif value == "row-reverse" then + return "horizontal-reverse" + elseif value == "column-reverse" then + return "vertical-reverse" + end + return value +end + +--- Replicate Element.new's border-shape normalization (pure). +--- * table with sides: true -> 1, number -> value, false/nil -> false; nil if no +--- truthy side remains. +--- * number / other truthy scalar: kept as-is. +--- * nil / false: nil. +local function normalizeBorder(value) + if value == nil or value == false then + return nil + end + if type(value) == "table" then + local function side(v) + if v == true then + return 1 + elseif type(v) == "number" then + return v + else + return false + end + end + local t = side(value.top) + local r = side(value.right) + local b = side(value.bottom) + local l = side(value.left) + if not (t or r or b or l) then + return nil + end + return { top = t, right = r, bottom = b, left = l } + end + return value +end + +--- Replicate Element.new's cornerRadius-shape normalization (pure). +--- * number: 0 -> nil, else the number. +--- * table: nil if all four sides are zero/absent, else fill zeros for absent sides. +--- * nil -> nil. +local function normalizeCornerRadius(value) + if value == nil then + return nil + end + if type(value) == "number" then + if value == 0 then + return nil + end + return value + end + if type(value) == "table" then + -- Mirrors Element.new: `or` truthiness (0 is truthy in Lua). Only an all- + -- nil/false table collapses to nil; any present side — including 0 — yields + -- the 4-side table with zero-filled absent sides. + local hasAny = value.topLeft or value.topRight or value.bottomLeft or value.bottomRight + if not hasAny then + return nil + end + return { + topLeft = value.topLeft or 0, + topRight = value.topRight or 0, + bottomLeft = value.bottomLeft or 0, + bottomRight = value.bottomRight or 0, + } + end + return value +end + +-- --------------------------------------------------------------------------- +-- Pure validators (dependency-free; return boolean) +-- --------------------------------------------------------------------------- + +--- Range validator factory: returns fn(v) -> bool. nil is treated as valid +--- (absence handling is the default mechanism's job). +local function rangeValidator(min, max) + return function(v) + if v == nil then + return true + end + return type(v) == "number" and v >= min and v <= max + end +end + +--- Enum validator factory: returns fn(v) -> bool for membership in `set` (set may +--- be an array or a map of value->truthy). +local function enumValidator(set) + local lookup = {} + if type(set) == "table" then + for k, v in pairs(set) do + if type(k) == "number" then + lookup[v] = true + else + lookup[k] = true + end + end + end + return function(v) + if v == nil then + return true + end + return lookup[v] == true + end +end + +--- Boolean validator: nil is valid (absence); otherwise must be a boolean. +local function booleanValidator(v) + return v == nil or type(v) == "boolean" +end + +-- --------------------------------------------------------------------------- +-- Registry construction +-- --------------------------------------------------------------------------- + +--- Build a fully-populated metadata entry, filling omitted fields with defaults. +local function entry(spec) + return { + type = spec.type or "any", + default = spec.default, + normalizer = spec.normalizer, + validator = spec.validator, + isDimension = spec.isDimension == true, + affectsLayout = spec.affectsLayout == true, + syncsTheme = spec.syncsTheme == true, + hasDeferred = spec.hasDeferred == true, + storageKey = spec.storageKey, + } +end + +--- Merge prop specs into the registry (additive; later entries override earlier). +---@param specs table map of prop-name -> spec +---@return table registry the live registry table (for chaining/inspection) +function PropertySchema.define(specs) + for name, spec in pairs(specs) do + registry[name] = entry(spec) + end + return registry +end + +--- O(1) metadata lookup. +---@param name string prop name +---@return table|nil metadata nil for unknown props (no error) +function PropertySchema.get(name) + return registry[name] +end + +--- Return the live registry (for inspection / coverage assertions only — not for +--- per-call construction). +---@return table +function PropertySchema.all() + return registry +end + +--- True if a prop is registered. +---@param name string +---@return boolean +function PropertySchema.has(name) + return registry[name] ~= nil +end + +--- True if setting this prop invalidates layout (legacy `layoutProperties` set). +--- O(1) registry lookup — no per-call table construction. Unknown props return false, +--- matching the legacy `layoutProperties[name]` nil-lookup behavior exactly. +---@param name string prop name +---@return boolean +function PropertySchema.affectsLayout(name) + local meta = registry[name] + return meta ~= nil and meta.affectsLayout == true +end + +--- True for dimension props (width/height) that `setProperty` routes through +--- `_resolveDimensionProperty` (unit-string resolution + border-box sync). +--- O(1) registry lookup — no per-call table construction. Unknown props return false, +--- matching the legacy `dimensionProperties[name]` nil-lookup behavior exactly. +---@param name string prop name +---@return boolean +function PropertySchema.isDimension(name) + local meta = registry[name] + return meta ~= nil and meta.isDimension == true +end + +--- True for props whose setProperty path must reach ThemeManager/Renderer +--- (disabled/active/themeComponent). O(1) registry lookup — no per-call table +--- construction. Unknown props return false, matching a legacy nil-lookup exactly. +---@param name string prop name +---@return boolean +function PropertySchema.syncsTheme(name) + local meta = registry[name] + return meta ~= nil and meta.syncsTheme == true +end + +-- --------------------------------------------------------------------------- +-- Default schema (covers every prop handled in Element.new lines 259-1909 and +-- Element:setProperty lines 4291-4417 of the Task-01 baseline). +-- --------------------------------------------------------------------------- +local function defineDefaults() + PropertySchema.define({ + -- ------------------------------------------------------------------ identity + id = { type = "string" }, + userdata = { type = "any" }, + parent = { type = "table", affectsLayout = true }, + children = { type = "table" }, + + -- ------------------------------------------------------------------ callbacks + onEvent = { type = "function", hasDeferred = true }, + onFocus = { type = "function", hasDeferred = true }, + onBlur = { type = "function", hasDeferred = true }, + onTextInput = { type = "function", hasDeferred = true }, + onTextChange = { type = "function", hasDeferred = true }, + onEnter = { type = "function", hasDeferred = true }, + onCreate = { type = "function", hasDeferred = true }, + onTouchEvent = { type = "function", hasDeferred = true }, + onGesture = { type = "function", hasDeferred = true }, + onImageLoad = { type = "function", hasDeferred = true }, + onImageError = { type = "function", hasDeferred = true }, + + -- Deferred companion flags (stored directly; no further Deferred companion) + onEventDeferred = { type = "boolean", default = false }, + onFocusDeferred = { type = "boolean", default = false }, + onBlurDeferred = { type = "boolean", default = false }, + onTextInputDeferred = { type = "boolean", default = false }, + onTextChangeDeferred = { type = "boolean", default = false }, + onEnterDeferred = { type = "boolean", default = false }, + onCreateDeferred = { type = "boolean", default = false }, + onTouchEventDeferred = { type = "boolean", default = false }, + onGestureDeferred = { type = "boolean", default = false }, + onImageLoadDeferred = { type = "boolean", default = false }, + onImageErrorDeferred = { type = "boolean", default = false }, + + -- focus / touch behavior + dropFocusOnSelection = { type = "boolean" }, + customDraw = { type = "function" }, + touchEnabled = { type = "boolean", default = true }, + multiTouchEnabled = { type = "boolean", default = false }, + + -- ------------------------------------------------------------------ theme + theme = { type = "table" }, + themeComponent = { type = "string", syncsTheme = true }, + disabled = { type = "boolean", default = false, syncsTheme = true }, + isDisabled = { + type = "boolean", + default = false, + syncsTheme = true, + storageKey = "disabled", + }, + active = { type = "boolean", default = false, syncsTheme = true }, + disableHighlight = { type = "boolean" }, + themeStateLock = { type = "boolean" }, + themeComponentDisabledStates = { type = "table" }, + scaleCorners = { type = "boolean" }, + scalingAlgorithm = { type = "string" }, + contentAutoSizingMultiplier = { type = "table" }, + contentBlur = { type = "table" }, + backdropBlur = { type = "table" }, + + -- ------------------------------------------------------------------ text editing + editable = { type = "boolean", default = false }, + multiline = { type = "boolean", default = false }, + passwordMode = { type = "boolean", default = false }, + textWrap = { type = "string" }, -- default computed from multiline + maxLines = { type = "number" }, + maxLength = { type = "number" }, + placeholder = { type = "string" }, + inputType = { type = "string", default = "text" }, + textOverflow = { type = "string", default = "clip" }, + scrollable = { type = "boolean" }, -- default = multiline + autoGrow = { type = "boolean" }, -- default = multiline + selectOnFocus = { type = "boolean", default = false }, + cursorColor = { type = "color" }, + selectionColor = { type = "color" }, + cursorBlinkRate = { type = "number", default = 0.5 }, + text = { type = "string" }, + textAlign = { + type = "string", + default = "start", + validator = enumValidator({ "start", "center", "end", "justify" }), + }, + -- textAlignVertical is a derived storage field split out from textAlign + -- (bindVisualState resolves table/compound-string input into H + V). Its + -- validator is exposed for bindVisualState to validate the V component; the + -- prop itself stays in SPECIAL_PROPS because compound parsing needs + -- ErrorHandler warnings (schema is pure-Lua, cannot warn). + textAlignVertical = { + type = "string", + default = "start", + validator = enumValidator({ "start", "center", "end" }), + }, + textColor = { type = "color" }, + fontFamily = { type = "string" }, + textSize = { type = "any" }, -- number | preset string; resolved by special handler + minTextSize = { type = "number" }, + maxTextSize = { type = "number" }, + autoScaleText = { type = "boolean", default = true }, + + -- ------------------------------------------------------------------ dimensions / box model + width = { type = "any", isDimension = true, affectsLayout = true }, + height = { type = "any", isDimension = true, affectsLayout = true }, + x = { type = "any", affectsLayout = false }, + y = { type = "any", affectsLayout = false }, + minWidth = { type = "any" }, + maxWidth = { type = "any" }, + minHeight = { type = "any" }, + maxHeight = { type = "any" }, + gap = { type = "any", affectsLayout = true }, + padding = { + type = "any", + affectsLayout = true, + normalizer = expandSides, + }, + margin = { + type = "any", + affectsLayout = true, + normalizer = expandSides, + }, + flexDirection = { + type = "string", + default = "horizontal", + affectsLayout = true, + normalizer = normalizeFlexDirection, + }, + flexWrap = { type = "string", default = "nowrap", affectsLayout = true }, + justifyContent = { type = "string", default = "flex-start", affectsLayout = true }, + alignItems = { type = "string", default = "stretch", affectsLayout = true }, + alignContent = { type = "string", default = "stretch", affectsLayout = true }, + positioning = { type = "string", default = "relative", affectsLayout = true }, + gridRows = { type = "number", affectsLayout = true }, + gridColumns = { type = "number", affectsLayout = true }, + top = { type = "any", affectsLayout = true }, + right = { type = "any", affectsLayout = true }, + bottom = { type = "any", affectsLayout = true }, + left = { type = "any", affectsLayout = true }, + columnGap = { type = "any" }, + rowGap = { type = "any" }, + flex = { type = "any" }, -- shorthand: expands to flexGrow/flexShrink/flexBasis + flexGrow = { type = "number", default = 0, validator = rangeValidator(0, math.huge) }, + flexShrink = { type = "number", default = 1, validator = rangeValidator(0, math.huge) }, + flexBasis = { type = "any", default = "auto" }, + alignSelf = { type = "string", default = "auto" }, + justifySelf = { type = "string" }, + z = { type = "number", default = 0 }, + tabIndex = { type = "number" }, + + -- ------------------------------------------------------------------ border / background / visual + border = { type = "any", normalizer = normalizeBorder }, + borderColor = { type = "color" }, -- default Color.new(0,0,0,1) via special handler + backgroundColor = { type = "color" }, -- default transparent via special handler + opacity = { + type = "number", + default = 1, + validator = rangeValidator(0, 1), + }, + visibility = { type = "string", default = "visible" }, + display = { + type = "boolean", + default = true, + validator = booleanValidator, + }, + transform = { type = "table" }, + cornerRadius = { type = "any", normalizer = normalizeCornerRadius }, + + -- ------------------------------------------------------------------ image + imagePath = { type = "string" }, + image = { type = "table" }, + objectFit = { + type = "string", + default = "fill", + validator = enumValidator({ "fill", "contain", "cover", "scale-down", "none" }), + }, + objectPosition = { type = "string", default = "center center" }, + imageOpacity = { + type = "number", + default = 1, + validator = rangeValidator(0, 1), + }, + imageRepeat = { + type = "string", + default = "no-repeat", + validator = enumValidator({ + "no-repeat", + "repeat", + "repeat-x", + "repeat-y", + "space", + "round", + }), + }, + imageTint = { type = "color" }, + + -- ------------------------------------------------------------------ scroll / scrollbar + overflow = { type = "string" }, + overflowX = { type = "string" }, + overflowY = { type = "string" }, + scrollbarWidth = { type = "number" }, + scrollbarColor = { type = "color" }, + scrollbarTrackColor = { type = "color" }, + scrollbarRadius = { type = "number" }, + scrollbarPadding = { type = "number" }, + scrollSpeed = { type = "number" }, + invertScroll = { type = "boolean" }, + smoothScrollEnabled = { type = "boolean" }, + scrollBarStyle = { type = "string" }, + scrollbarKnobOffset = { type = "number" }, + hideScrollbars = { type = "boolean" }, + scrollbarPlacement = { type = "string" }, + scrollbarBalance = { type = "number" }, + _scrollX = { type = "number", storageKey = "_scrollX" }, + _scrollY = { type = "number", storageKey = "_scrollY" }, + + -- ------------------------------------------------------------------ select + selectParent = { type = "table" }, + selectOption = { type = "table" }, + + -- ------------------------------------------------------------------ transition + transition = { type = "table", default = {} }, + }) +end + +--- (Re)populate the default schema. Idempotent: safe to call from Element.init +--- for build profiles that re-require the module. Returns the live registry. +---@return table registry +function PropertySchema.populate() + defineDefaults() + return registry +end + +-- Auto-populate on require so the registry is ready without an explicit init call +-- (pure module, no external deps — safe at load time). +PropertySchema.populate() + +return PropertySchema diff --git a/libs/flexlove/modules/Renderer.lua b/libs/flexlove/modules/Renderer.lua new file mode 100644 index 00000000..8265793e --- /dev/null +++ b/libs/flexlove/modules/Renderer.lua @@ -0,0 +1,1230 @@ +local UTF8 = require((...):match("(.-)[^%.]+$") .. "UTF8") + +---@class Renderer +---@field backgroundColor Color +---@field borderColor Color +---@field opacity number +---@field border {top:boolean, right:boolean, bottom:boolean, left:boolean} +---@field cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} +---@field theme string? +---@field themeComponent string? +---@field _themeState string +---@field imagePath string? +---@field image love.Image? +---@field _loadedImage love.Image? +---@field objectFit string +---@field objectPosition string +---@field imageOpacity number +---@field contentBlur {intensity:number, quality:number}? +---@field backdropBlur {intensity:number, quality:number}? +---@field _blurInstance table? +---@field _element Element? +---@field _Color Color +---@field _RoundedRect table +---@field _NinePatch table +---@field _ImageRenderer table +---@field _ImageCache table +---@field _Theme table +---@field _Transform Transform +---@field _Blur Blur +---@field _utils table +---@field _FONT_CACHE table +---@field _TextAlign table +---@field _ErrorHandler ErrorHandler +---@field _Performance Performance? Performance module dependency +local Renderer = {} +Renderer.__index = Renderer + +--- Initialize module with shared dependencies +---@param deps table Dependencies {ErrorHandler, Performance} +function Renderer.init(deps) + Renderer._ErrorHandler = deps.ErrorHandler + Renderer._Performance = deps.Performance +end + +--- Create a new Renderer instance +---@param config table Configuration table with rendering properties +---@param deps table Dependencies {Color, RoundedRect, NinePatch, ImageRenderer, ImageCache, Theme, Blur, Transform, utils} +function Renderer.new(config, deps) + local Color = deps.Color + local ImageCache = deps.ImageCache + + local self = setmetatable({}, Renderer) + + -- Store dependencies for instance methods + self._Color = Color + self._RoundedRect = deps.RoundedRect + self._NinePatch = deps.NinePatch + self._ImageRenderer = deps.ImageRenderer + self._ImageCache = ImageCache + self._Theme = deps.Theme + self._Blur = deps.Blur + self._Transform = deps.Transform + self._utils = deps.utils + self._FONT_CACHE = deps.utils.FONT_CACHE + self._TextAlign = deps.utils.enums.TextAlign + self._TextAlignVertical = deps.utils.enums.TextAlignVertical + + -- Visual properties + self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0) + self.borderColor = config.borderColor or Color.new(0, 0, 0, 1) + self.opacity = config.opacity or 1 + + -- NOTE: border is intentionally NOT cached here. Renderer:draw resolves it from + -- element.border (source of truth) so retained-mode bare writes and + -- setProperty("border", ...) both take effect immediately. + + -- Corner radius + self.cornerRadius = config.cornerRadius + or { + topLeft = 0, + topRight = 0, + bottomLeft = 0, + bottomRight = 0, + } + + -- Theme properties + self.theme = config.theme + self.themeComponent = config.themeComponent + self._themeState = "normal" + + -- Image properties + self.imagePath = config.imagePath + self.image = config.image + self._loadedImage = nil + self.objectFit = config.objectFit or "fill" + self.objectPosition = config.objectPosition or "center center" + self.imageOpacity = config.imageOpacity or 1 + self.imageRepeat = config.imageRepeat or "no-repeat" + self.imageTint = config.imageTint + + -- Blur effects + self.contentBlur = config.contentBlur + self.backdropBlur = config.backdropBlur + self._blurInstance = nil + + -- Load image if path provided + if self.imagePath and not self.image then + local loadedImage = ImageCache.load(self.imagePath) + if loadedImage then + self._loadedImage = loadedImage + else + self._loadedImage = nil + end + elseif self.image then + self._loadedImage = self.image + else + self._loadedImage = nil + end + + return self +end + +--- Get or create blur instance for this element +---@return table|nil Blur instance or nil +function Renderer:getBlurInstance() + -- Determine quality from blur settings + local quality = "medium" + if self.contentBlur and self.contentBlur.quality then + quality = self.contentBlur.quality + elseif self.backdropBlur and self.backdropBlur.quality then + quality = self.backdropBlur.quality + end + + -- Map string quality to numeric quality (1-10) + local numericQuality = 5 -- default medium + if type(quality) == "string" then + if quality == "low" then + numericQuality = 3 + elseif quality == "medium" then + numericQuality = 5 + elseif quality == "high" then + numericQuality = 8 + end + elseif type(quality) == "number" then + numericQuality = quality + end + + -- Create or reuse blur instance + if not self._blurInstance or self._blurInstance.quality ~= numericQuality then + self._blurInstance = self._Blur.new({ quality = numericQuality }) + end + + return self._blurInstance +end + +--- Set theme state (normal, hover, pressed, disabled, active) +---@param state string The theme state +function Renderer:setThemeState(state) + self._themeState = state +end + +--- Execute a single core draw command (background, image, theme, borders). +--- Commands are plain tables: { type = "background"|"image"|"theme"|"borders", ... } +---@param cmd table Command table +---@param ctx table Resolved draw context +function Renderer:_executeDrawCommand(cmd, ctx) + if cmd.type == "background" then + local c = self._Color.new(cmd.color.r, cmd.color.g, cmd.color.b, cmd.color.a * ctx.opacity) + love.graphics.setColor(c:toRGBA()) + self._RoundedRect.draw("fill", ctx.x, ctx.y, ctx.borderBoxWidth, ctx.borderBoxHeight, ctx.cornerRadius) + elseif cmd.type == "image" then + -- Image value props (imageOpacity/imageRepeat/imageTint/objectFit/ + -- objectPosition) and imagePath are read from the element as the single + -- source of truth, so retained-mode bare writes (`element.imageOpacity = 0.5`), + -- the setImage* setters, and setProperty(...) are all immediately + -- consistent. The renderer's own config is a fallback for standalone + -- Renderer usage with a sparse element (mirrors `element.onEvent or + -- self.onEvent`); the integrated path always supplies an element whose + -- _applyProps-bound values take precedence. _loadedImage intentionally + -- remains on the renderer (the resolved love.Image from the Imageable load + -- pipeline). See TestRetainedPropertyConsistency. + local el = self._element + local imageOpacity = (el and el.imageOpacity) or self.imageOpacity + local imageRepeat = (el and el.imageRepeat) or self.imageRepeat + local imageTint = (el and el.imageTint) or self.imageTint + local objectFit = (el and el.objectFit) or self.objectFit + local objectPosition = (el and el.objectPosition) or self.objectPosition + local imagePath = (el and el.imagePath) or self.imagePath + if not self._loadedImage then + return + end + local img = self._loadedImage + local imageX = ctx.x + ctx.paddingLeft + local imageY = ctx.y + ctx.paddingTop + local finalOpacity = ctx.opacity * imageOpacity + local hasCornerRadius = false + if ctx.cornerRadius then + if type(ctx.cornerRadius) == "number" then + hasCornerRadius = ctx.cornerRadius > 0 + else + hasCornerRadius = ctx.cornerRadius.topLeft > 0 + or ctx.cornerRadius.topRight > 0 + or ctx.cornerRadius.bottomLeft > 0 + or ctx.cornerRadius.bottomRight > 0 + end + end + if hasCornerRadius then + local success, err = pcall(function() + love.graphics.stencil(function() + self._RoundedRect.draw("fill", ctx.x, ctx.y, ctx.borderBoxWidth, ctx.borderBoxHeight, ctx.cornerRadius) + end, "replace", 1) + love.graphics.setStencilTest("greater", 0) + end) + if not success then + if err and err:match("stencil") then + local cr = ctx.cornerRadius + local crStr = type(cr) == "number" and tostring(cr) + or string.format("TL:%d TR:%d BL:%d BR:%d", cr.topLeft, cr.topRight, cr.bottomLeft, cr.bottomRight) + Renderer._ErrorHandler:warn( + "Renderer", + "IMG_001", + { imagePath = imagePath or "unknown", cornerRadius = crStr, error = tostring(err) } + ) + hasCornerRadius = false + else + error(err, 2) + end + end + end + if imageRepeat and imageRepeat ~= "no-repeat" then + self._ImageRenderer.drawTiled( + img, + imageX, + imageY, + ctx.contentWidth, + ctx.contentHeight, + imageRepeat, + finalOpacity, + imageTint + ) + else + self._ImageRenderer.draw( + img, + imageX, + imageY, + ctx.contentWidth, + ctx.contentHeight, + objectFit, + objectPosition, + finalOpacity, + imageTint + ) + end + if hasCornerRadius then + love.graphics.setStencilTest() + end + elseif cmd.type == "theme" then + if not cmd.themeComponent then + return + end + local themeToUse = nil + if self.theme then + themeToUse = self._Theme.get(self.theme) + if not themeToUse then + pcall(function() + self._Theme.load(self.theme) + end) + themeToUse = self._Theme.get(self.theme) + end + else + themeToUse = self._Theme.getActive() + end + if not themeToUse then + return + end + local component = themeToUse.components[cmd.themeComponent] + if not component then + return + end + local state = self._themeState + if state and component.states and component.states[state] then + component = component.states[state] + end + local atlasToUse = component._loadedAtlas or themeToUse.atlas + if atlasToUse and component.regions then + local r = component.regions + if + r.topLeft + and r.topCenter + and r.topRight + and r.middleLeft + and r.middleCenter + and r.middleRight + and r.bottomLeft + and r.bottomCenter + and r.bottomRight + then + self._NinePatch.draw( + component, + atlasToUse, + ctx.x, + ctx.y, + ctx.borderBoxWidth, + ctx.borderBoxHeight, + ctx.opacity, + cmd.scaleCorners, + cmd.scalingAlgorithm + ) + end + end + elseif cmd.type == "borders" then + local border = cmd.border + if not border then + return + end + local bc = cmd.borderColor + local borderColorWithOpacity = self._Color.new(bc.r, bc.g, bc.b, bc.a * ctx.opacity) + love.graphics.setColor(borderColorWithOpacity:toRGBA()) + local bw, bh = ctx.borderBoxWidth, ctx.borderBoxHeight + if type(border) == "number" then + love.graphics.setLineWidth(border) + self._RoundedRect.draw("line", ctx.x, ctx.y, bw, bh, ctx.cornerRadius) + love.graphics.setLineWidth(1) + else + local allBorders = border.top and border.bottom and border.left and border.right + local uniformWidth = allBorders + and type(border.top) == "number" + and border.top == border.right + and border.top == border.bottom + and border.top == border.left + if uniformWidth then + love.graphics.setLineWidth(border.top) + self._RoundedRect.draw("line", ctx.x, ctx.y, bw, bh, ctx.cornerRadius) + love.graphics.setLineWidth(1) + else + if border.top then + love.graphics.setLineWidth(type(border.top) == "number" and border.top or 1) + love.graphics.line(ctx.x, ctx.y, ctx.x + bw, ctx.y) + end + if border.bottom then + love.graphics.setLineWidth(type(border.bottom) == "number" and border.bottom or 1) + love.graphics.line(ctx.x, ctx.y + bh, ctx.x + bw, ctx.y + bh) + end + if border.left then + love.graphics.setLineWidth(type(border.left) == "number" and border.left or 1) + love.graphics.line(ctx.x, ctx.y, ctx.x, ctx.y + bh) + end + if border.right then + love.graphics.setLineWidth(type(border.right) == "number" and border.right or 1) + love.graphics.line(ctx.x + bw, ctx.y, ctx.x + bw, ctx.y + bh) + end + love.graphics.setLineWidth(1) + end + end + end +end + +--- Build the render command buffer: resolve draw properties once from the +--- element (source of truth) and return a flat command list + draw context. +---@param element table Element instance +---@param backdropCanvas table|nil +---@return table cmds, table ctx Command list and resolved context +function Renderer:_buildCommands(element, backdropCanvas) + local opacity = element.opacity ~= nil and element.opacity or 1 + local backgroundColor = element.backgroundColor or self._Color.new(0, 0, 0, 0) + local borderColor = element.borderColor or self._Color.new(0, 0, 0, 1) + local cornerRadius = element.cornerRadius ~= nil and element.cornerRadius or nil + local themeComponent = element.themeComponent + local border = element.border + local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + local borderBoxHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) + + -- Handle opacity during animation + local drawBackgroundColor = backgroundColor + if element.animation then + local anim = element.animation:interpolate() + if anim.opacity then + drawBackgroundColor = self._Color.new(backgroundColor.r, backgroundColor.g, backgroundColor.b, anim.opacity) + end + end + + -- Build resolved context (shared by all commands — eliminates per-method params) + local ctx = { + x = element.x, + y = element.y, + opacity = opacity, + cornerRadius = cornerRadius, + borderBoxWidth = borderBoxWidth, + borderBoxHeight = borderBoxHeight, + paddingLeft = element.padding.left, + paddingTop = element.padding.top, + contentWidth = element.width, + contentHeight = element.height, + backdropCanvas = backdropCanvas, + } + + -- Build command list (conditional — only emit layers that have data) + local cmds = {} + local n = 0 + + -- LAYER 0.5: backdrop blur (handled separately, not a command — needs canvas access) + if self.backdropBlur and self.backdropBlur.radius > 0 then + n = n + 1 + cmds[n] = { type = "backdropBlur", radius = self.backdropBlur.radius } -- executed before background + end + + -- LAYER 1: background + n = n + 1 + cmds[n] = { type = "background", color = drawBackgroundColor } + + -- LAYER 1.5: image (always emit; _executeCommand early-exits if no image) + n = n + 1 + cmds[n] = { type = "image" } + + -- LAYER 2: theme 9-patch + n = n + 1 + cmds[n] = { + type = "theme", + themeComponent = themeComponent, + scaleCorners = element.scaleCorners, + scalingAlgorithm = element.scalingAlgorithm, + } + + -- LAYER 3: borders + n = n + 1 + cmds[n] = { type = "borders", borderColor = borderColor, border = border } + + -- LAYER 4: text (cursor, selection, placeholder, password masking) + n = n + 1 + cmds[n] = { type = "text" } + + -- LAYER 4.5: custom draw callback (if provided) + if element.customDraw then + n = n + 1 + cmds[n] = { type = "customDraw" } + end + + -- NOTE: pressed-state overlay (former Layer 5) is now owned by the Clickable + -- behavior's onDraw, dispatched from Element:draw. The renderer no longer + -- branches on element.onEvent for press feedback. + + return cmds, ctx +end + +--- Execute a special render command (backdropBlur, customDraw, pressedState). +--- These interact with love.graphics state in non-uniform ways and are handled separately +--- from the core draw commands (background/image/theme/borders). +---@param cmd table Command table +---@param ctx table Resolved draw context +function Renderer:_executeSpecialCommand(cmd, ctx) + if cmd.type == "backdropBlur" then + if ctx.backdropCanvas then + local blurInstance = self:getBlurInstance() + if blurInstance then + local eid = self._element and self._element.id and self._element.id ~= "" and self._element.id or nil + blurInstance:applyBackdropCached( + cmd.radius, + ctx.x, + ctx.y, + ctx.borderBoxWidth, + ctx.borderBoxHeight, + ctx.backdropCanvas, + eid + ) + end + end + elseif cmd.type == "text" then + self:drawText(self._element) + elseif cmd.type == "customDraw" then + love.graphics.push() + love.graphics.setColor(1, 1, 1, 1) + self._element.customDraw(self._element) + love.graphics.pop() + end +end + +--- Main draw method - renders all visual layers via command buffer. +---@param element Element The parent Element instance +---@param backdropCanvas table|nil Backdrop canvas for backdrop blur +function Renderer:draw(element, backdropCanvas) + self._element = element -- cache for customDraw/pressedState + + if not element then + Renderer._ErrorHandler:warn("Renderer", "SYS_002", { method = "draw" }) + return + end + + -- Start performance timing + local elementId + if Renderer._Performance and Renderer._Performance.enabled then + elementId = element.id or "unnamed" + Renderer._Performance:startTimer("render_" .. elementId) + Renderer._Performance:incrementCounter("draw_calls", 1) + end + + -- Early exit if element is invisible (optimization) + if element.opacity ~= nil and element.opacity <= 0 then + if Renderer._Performance and Renderer._Performance.enabled and elementId then + Renderer._Performance:stopTimer("render_" .. elementId) + end + return + end + + -- Build command buffer + resolve draw context once + local cmds, ctx = self:_buildCommands(element, backdropCanvas) + + -- Apply transform if exists + local hasTransform = element.transform and self._Transform and not self._Transform.isIdentity(element.transform) + if hasTransform then + self._Transform.apply(element.transform, element.x, element.y, element.width, element.height) + end + + -- Execute all commands in order + for _, cmd in ipairs(cmds) do + -- Draw commands (background, image, theme, borders) use the core executor; + -- special commands (backdropBlur, customDraw, pressedState) are handled in _executeCommand. + local typ = cmd.type + if typ == "background" or typ == "image" or typ == "theme" or typ == "borders" then + self:_executeDrawCommand(cmd, ctx) + else + self:_executeSpecialCommand(cmd, ctx) + end + end + + -- Unapply transform if it was applied + if hasTransform then + self._Transform.unapply() + end + + -- Stop performance timing + if Renderer._Performance and Renderer._Performance.enabled and elementId then + Renderer._Performance:stopTimer("render_" .. elementId) + end +end + +--- Get font for element (resolves from theme or fontFamily) +---@param element table Reference to the parent Element instance +---@return love.Font +function Renderer:getFont(element) + return self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager) +end + +--- Wrap a line of text based on element's textWrap mode +---@param element table Reference to the parent Element instance +---@param line string The line of text to wrap +---@param maxWidth number Maximum width for wrapping +---@return table Array of {text, startIdx, endIdx} +function Renderer:wrapLine(element, line, maxWidth) + -- UTF-8 support + local utf8 = UTF8 + + if not element.editable then + return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } + end + + local font = self:getFont(element) + local wrappedParts = {} + local currentLine = "" + local startIdx = 0 + + -- Helper function to extract a UTF-8 character by character index + local function getUtf8Char(str, charIndex) + local byteStart = utf8.offset(str, charIndex) + if not byteStart then + return "" + end + local byteEnd = utf8.offset(str, charIndex + 1) + if byteEnd then + return str:sub(byteStart, byteEnd - 1) + else + return str:sub(byteStart) + end + end + + if element.textWrap == "word" then + -- Tokenize into words and whitespace, preserving exact spacing + local tokens = {} + local pos = 1 + local lineLen = utf8.len(line) + + while pos <= lineLen do + -- Check if current position is whitespace + local char = getUtf8Char(line, pos) + if char:match("%s") then + -- Collect whitespace sequence + local wsStart = pos + while pos <= lineLen and getUtf8Char(line, pos):match("%s") do + pos = pos + 1 + end + table.insert(tokens, { + type = "space", + text = line:sub(utf8.offset(line, wsStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), + startPos = wsStart - 1, + length = pos - wsStart, + }) + else + -- Collect word (non-whitespace sequence) + local wordStart = pos + while pos <= lineLen and not getUtf8Char(line, pos):match("%s") do + pos = pos + 1 + end + table.insert(tokens, { + type = "word", + text = line:sub(utf8.offset(line, wordStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), + startPos = wordStart - 1, + length = pos - wordStart, + }) + end + end + + -- Process tokens and wrap + local charPos = 0 -- Track our position in the original line + for _, token in ipairs(tokens) do + if token.type == "word" then + local testLine = currentLine .. token.text + local width = font:getWidth(testLine) + + if width > maxWidth and currentLine ~= "" then + -- Current line is full, wrap before this word + local currentLineLen = utf8.len(currentLine) + table.insert(wrappedParts, { + text = currentLine, + startIdx = startIdx, + endIdx = startIdx + currentLineLen, + }) + startIdx = charPos + currentLine = token.text + charPos = charPos + token.length + + -- Check if the word itself is too long - if so, break it with character wrapping + if font:getWidth(token.text) > maxWidth then + local wordLen = utf8.len(token.text) + local charLine = "" + local charStartIdx = startIdx + + for j = 1, wordLen do + local char = getUtf8Char(token.text, j) + local testCharLine = charLine .. char + local charWidth = font:getWidth(testCharLine) + + if charWidth > maxWidth and charLine ~= "" then + table.insert(wrappedParts, { + text = charLine, + startIdx = charStartIdx, + endIdx = charStartIdx + utf8.len(charLine), + }) + charStartIdx = charStartIdx + utf8.len(charLine) + charLine = char + else + charLine = testCharLine + end + end + + currentLine = charLine + startIdx = charStartIdx + end + elseif width > maxWidth and currentLine == "" then + -- Word is too long to fit on a line by itself - use character wrapping + local wordLen = utf8.len(token.text) + local charLine = "" + local charStartIdx = startIdx + + for j = 1, wordLen do + local char = getUtf8Char(token.text, j) + local testCharLine = charLine .. char + local charWidth = font:getWidth(testCharLine) + + if charWidth > maxWidth and charLine ~= "" then + table.insert(wrappedParts, { + text = charLine, + startIdx = charStartIdx, + endIdx = charStartIdx + utf8.len(charLine), + }) + charStartIdx = charStartIdx + utf8.len(charLine) + charLine = char + else + charLine = testCharLine + end + end + + currentLine = charLine + startIdx = charStartIdx + charPos = charPos + token.length + else + currentLine = testLine + charPos = charPos + token.length + end + else + -- It's whitespace - add to current line + currentLine = currentLine .. token.text + charPos = charPos + token.length + end + end + else + -- Character wrapping + local lineLength = utf8.len(line) + for i = 1, lineLength do + local char = getUtf8Char(line, i) + local testLine = currentLine .. char + local width = font:getWidth(testLine) + + if width > maxWidth and currentLine ~= "" then + table.insert(wrappedParts, { + text = currentLine, + startIdx = startIdx, + endIdx = startIdx + utf8.len(currentLine), + }) + currentLine = char + startIdx = i - 1 + else + currentLine = testLine + end + end + end + + -- Add remaining text + if currentLine ~= "" then + table.insert(wrappedParts, { + text = currentLine, + startIdx = startIdx, + endIdx = startIdx + utf8.len(currentLine), + }) + end + + -- Ensure at least one part + if #wrappedParts == 0 then + table.insert(wrappedParts, { + text = "", + startIdx = 0, + endIdx = 0, + }) + end + + return wrappedParts +end + +--- Draw text content (includes text, cursor, selection, placeholder, password masking) +---@param element table Reference to the parent Element instance +function Renderer:drawText(element) + -- Update text layout if dirty (for multiline auto-grow) + if element._textEditor then + element._textEditor:_updateTextIfDirty(element) + element._textEditor:updateAutoGrowHeight(element) + end + + -- For editable elements, use TextEditor buffer; for non-editable, use text + local displayText = element._textEditor and element._textEditor:getText() or element.text + local isPlaceholder = false + + -- Show placeholder if editable and empty + if element.editable and (not displayText or displayText == "") and element.placeholder then + displayText = element.placeholder + isPlaceholder = true + end + + -- Apply password masking if enabled + if element.passwordMode and displayText and displayText ~= "" and not isPlaceholder then + local maskedText = string.rep("•", UTF8.len(displayText)) + displayText = maskedText + end + + if displayText and displayText ~= "" then + local textColor = isPlaceholder + and self._Color.new( + element.textColor.r * 0.5, + element.textColor.g * 0.5, + element.textColor.b * 0.5, + element.textColor.a * 0.5 + ) + or element.textColor + local textColorOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1) + local textColorWithOpacity = self._Color.new(textColor.r, textColor.g, textColor.b, textColor.a * textColorOpacity) + love.graphics.setColor(textColorWithOpacity:toRGBA()) + + local origFont = love.graphics.getFont() + if element.textSize then + -- Use cached font instead of creating new one every frame + local font = + self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager) + love.graphics.setFont(font) + end + local font = love.graphics.getFont() + local textWidth = font:getWidth(displayText) + local textHeight = font:getHeight() + local tx, ty + + -- Text is drawn in the content box (inside padding) + -- For 9-patch components, use contentPadding if available + local textPaddingLeft = element.padding.left + local textPaddingTop = element.padding.top + local textAreaWidth = element.width + local textAreaHeight = element.height + + -- Check if we should use 9-patch contentPadding for text positioning + local scaledContentPadding = element:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + local borderBoxHeight = element._borderBoxHeight + or (element.height + element.padding.top + element.padding.bottom) + + textPaddingLeft = scaledContentPadding.left + textPaddingTop = scaledContentPadding.top + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + textAreaHeight = borderBoxHeight - scaledContentPadding.top - scaledContentPadding.bottom + end + + local contentX = element.x + textPaddingLeft + local contentY = element.y + textPaddingTop + + -- Resolve horizontal and vertical alignment (new format with backward compatibility) + local hAlign = element.textAlignHorizontal or element.textAlign or self._TextAlign.START + local vAlign = element.textAlignVertical or self._TextAlignVertical.START + + -- Check if text wrapping is enabled + if element.textWrap and (element.textWrap == "word" or element.textWrap == "char" or element.textWrap == true) then + -- Use printf for wrapped text (horizontal alignment only) + local align = "left" + if hAlign == self._TextAlign.CENTER then + align = "center" + elseif hAlign == self._TextAlign.END then + align = "right" + elseif hAlign == self._TextAlign.JUSTIFY then + align = "justify" + end + + tx = contentX + ty = contentY + + -- Use printf with the available width for wrapping + love.graphics.printf(displayText, tx, ty, textAreaWidth, align) + else + -- Use regular print for non-wrapped text + -- Horizontal alignment + if hAlign == self._TextAlign.START then + tx = contentX + elseif hAlign == self._TextAlign.CENTER then + tx = contentX + (textAreaWidth - textWidth) / 2 + elseif hAlign == self._TextAlign.END then + tx = contentX + textAreaWidth - textWidth - 10 + else -- JUSTIFY or unknown + tx = contentX + end + + -- Vertical alignment + if vAlign == self._TextAlignVertical.START then + ty = contentY + elseif vAlign == self._TextAlignVertical.CENTER then + ty = contentY + (textAreaHeight - textHeight) / 2 + elseif vAlign == self._TextAlignVertical.END then + ty = contentY + textAreaHeight - textHeight + else + ty = contentY + end + + -- Apply scroll offset for editable single-line inputs + if element.editable and not element.multiline and element._textScrollX then + tx = tx - element._textScrollX + end + + -- Use scissor to clip text to content area for editable inputs + if element.editable and not element.multiline then + love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight) + end + + love.graphics.print(displayText, tx, ty) + + -- Reset scissor + if element.editable and not element.multiline then + love.graphics.setScissor() + end + end + + -- Draw cursor for focused editable elements (even if text is empty) + if element._textEditor and element._textEditor:isFocused() and element._textEditor._cursorVisible then + local cursorColor = element.cursorColor or element.textColor + local elemOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1) + local cursorWithOpacity = + self._Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * elemOpacity) + love.graphics.setColor(cursorWithOpacity:toRGBA()) + + -- Calculate cursor position using TextEditor method + local cursorRelX, cursorRelY = element._textEditor:_getCursorScreenPosition(element) + local cursorX = contentX + cursorRelX + local cursorY = contentY + cursorRelY + local cursorHeight = textHeight + + -- Apply scroll offset for single-line inputs + if not element.multiline and element._textEditor._textScrollX then + cursorX = cursorX - element._textEditor._textScrollX + end + + -- Apply scissor for single-line editable inputs + if not element.multiline then + love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight) + end + + -- Draw cursor line + love.graphics.rectangle("fill", cursorX, cursorY, 2, cursorHeight) + + -- Reset scissor + if not element.multiline then + love.graphics.setScissor() + end + end + + -- Draw selection highlight for editable elements + if element._textEditor and element._textEditor:isFocused() and element._textEditor:hasSelection() then + -- For editable elements, check TextEditor buffer instead of element.text + local textBuffer = element._textEditor:getText() + if textBuffer and textBuffer ~= "" then + local selStart, selEnd = element._textEditor:getSelection() + local selectionColor = element.selectionColor or self._Color.new(0.3, 0.5, 0.8, 0.5) + local elemOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1) + local selectionWithOpacity = + self._Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * elemOpacity) + + -- Get selection rectangles from TextEditor + local selectionRects = element._textEditor:_getSelectionRects(element, selStart, selEnd) + + -- Apply scissor for single-line editable inputs + if not element.multiline then + love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight) + end + + -- Draw selection background rectangles + love.graphics.setColor(selectionWithOpacity:toRGBA()) + for _, rect in ipairs(selectionRects) do + local rectX = contentX + rect.x + local rectY = contentY + rect.y + if not element.multiline and element._textEditor._textScrollX then + rectX = rectX - element._textEditor._textScrollX + end + love.graphics.rectangle("fill", rectX, rectY, rect.width, rect.height) + end + + -- Reset scissor + if not element.multiline then + love.graphics.setScissor() + end + end + end + + if element.textSize then + love.graphics.setFont(origFont) + end + end + + -- Draw cursor for focused editable elements even when empty + if + element._textEditor + and element._textEditor:isFocused() + and element._textEditor._cursorVisible + and (not displayText or displayText == "") + then + -- Set up font for cursor rendering + local origFont = love.graphics.getFont() + if element.textSize then + local font = + self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager) + love.graphics.setFont(font) + end + + local font = love.graphics.getFont() + local textHeight = font:getHeight() + + -- Calculate text area position + local textPaddingLeft = element.padding.left + local textPaddingTop = element.padding.top + local scaledContentPadding = element:getScaledContentPadding() + if scaledContentPadding then + textPaddingLeft = scaledContentPadding.left + textPaddingTop = scaledContentPadding.top + end + + local contentX = element.x + textPaddingLeft + local contentY = element.y + textPaddingTop + + -- Draw cursor + local cursorColor = element.cursorColor or element.textColor + local elemOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1) + local cursorWithOpacity = self._Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * elemOpacity) + love.graphics.setColor(cursorWithOpacity:toRGBA()) + love.graphics.rectangle("fill", contentX, contentY, 2, textHeight) + + if element.textSize then + love.graphics.setFont(origFont) + end + end +end + +--- Draw scrollbars (both vertical and horizontal) +---@param element table Reference to the parent Element instance +---@param x number X position +---@param y number Y position +---@param w number Width +---@param h number Height +---@param dims table Scrollbar dimensions from _calculateScrollbarDimensions +function Renderer:drawScrollbars(element, x, y, w, h, dims) + -- Try to get themed scrollbar component + local scrollbarComponent = nil + if element.scrollBarStyle or self._Theme.hasActive() then + scrollbarComponent = self._Theme.getScrollbar(element.scrollBarStyle) + end + + -- Vertical scrollbar + if dims.vertical.visible and not element.hideScrollbars.vertical then + -- Position scrollbar within content area (x, y is border-box origin) + local contentX = x + element.padding.left + local contentY = y + element.padding.top + local trackX = contentX + w - element.scrollbarWidth - element.scrollbarPadding + local trackY = contentY + element.scrollbarPadding + + -- Check if we should use themed rendering + if scrollbarComponent then + -- Themed scrollbar rendering using NinePatch + local frameComponent = scrollbarComponent.frame or scrollbarComponent + local barComponent = scrollbarComponent.bar or scrollbarComponent + + -- Calculate knob offset (element overrides theme) + local knobOffsetX = 0 + local knobOffsetY = 0 + + -- Use element offset if provided, otherwise use theme offset + if element.scrollbarKnobOffset then + knobOffsetX = element.scrollbarKnobOffset.x or 0 + knobOffsetY = element.scrollbarKnobOffset.vertical or 0 + elseif barComponent and barComponent.knobOffset then + local themeOffset = self._utils.normalizeOffsetTable(barComponent.knobOffset, 0) + knobOffsetX = themeOffset.x + knobOffsetY = themeOffset.vertical + end + + -- Extract contentPadding top inset from frame for knob sizing. + -- Vertical scrollbar only consumes framePaddingTop; other insets are unused. + local framePaddingTop = 0 + if frameComponent and frameComponent._ninePatchData and frameComponent._ninePatchData.contentPadding then + framePaddingTop = frameComponent._ninePatchData.contentPadding.top or 0 + end + + -- Draw track (frame) if component exists + if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then + self._NinePatch.draw( + frameComponent, + frameComponent._loadedAtlas, + trackX, + trackY, + element.scrollbarWidth, + dims.vertical.trackHeight + ) + end + + -- Draw thumb (bar) if component exists + if barComponent and barComponent._loadedAtlas and barComponent.regions then + -- Adjust knob dimensions to account for frame's contentPadding + -- Vertical scrollbar: width affected by left+right, height affected by top+bottom + local knobWidth = element.scrollbarWidth + local knobHeight = dims.vertical.thumbHeight - framePaddingTop / 2 + self._NinePatch.draw( + barComponent, + barComponent._loadedAtlas, + trackX + knobOffsetX, + trackY + dims.vertical.thumbY + knobOffsetY, + knobWidth, + knobHeight + ) + end + else + -- Fallback to color-based rendering + -- Determine thumb color based on state (independent for vertical) + local thumbColor = element.scrollbarColor + if element._scrollbarDragging and element._hoveredScrollbar == "vertical" then + -- Active state: brighter + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) + thumbColor = self._Color.new(r, g, b, a) + elseif element._scrollbarHoveredVertical then + -- Hover state: slightly brighter + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) + thumbColor = self._Color.new(r, g, b, a) + end + + -- Draw track + love.graphics.setColor(element.scrollbarTrackColor:toRGBA()) + love.graphics.rectangle( + "fill", + trackX, + trackY, + element.scrollbarWidth, + dims.vertical.trackHeight, + element.scrollbarRadius + ) + + -- Draw thumb with state-based color + love.graphics.setColor(thumbColor:toRGBA()) + love.graphics.rectangle( + "fill", + trackX, + trackY + dims.vertical.thumbY, + element.scrollbarWidth, + dims.vertical.thumbHeight, + element.scrollbarRadius + ) + end + end + + -- Horizontal scrollbar + if dims.horizontal.visible and not element.hideScrollbars.horizontal then + -- Position scrollbar within content area (x, y is border-box origin) + local contentX = x + element.padding.left + local contentY = y + element.padding.top + local trackX = contentX + element.scrollbarPadding + local trackY = contentY + h - element.scrollbarWidth - element.scrollbarPadding + + -- Check if we should use themed rendering + if scrollbarComponent then + -- Themed scrollbar rendering using NinePatch + local frameComponent = scrollbarComponent.frame or scrollbarComponent + local barComponent = scrollbarComponent.bar or scrollbarComponent + + -- Calculate knob offset (element overrides theme) + local knobOffsetX = 0 + local knobOffsetY = 0 + + -- Use element offset if provided, otherwise use theme offset + if element.scrollbarKnobOffset then + knobOffsetX = element.scrollbarKnobOffset.horizontal or 0 + knobOffsetY = element.scrollbarKnobOffset.y or 0 + elseif barComponent and barComponent.knobOffset then + local themeOffset = self._utils.normalizeOffsetTable(barComponent.knobOffset, 0) + knobOffsetX = themeOffset.horizontal + knobOffsetY = themeOffset.y + end + + -- Extract contentPadding from frame for knob sizing (horizontal: right inset unused). + local framePaddingLeft = 0 + local framePaddingTop = 0 + local framePaddingBottom = 0 + if frameComponent and frameComponent._ninePatchData and frameComponent._ninePatchData.contentPadding then + framePaddingLeft = frameComponent._ninePatchData.contentPadding.left or 0 + framePaddingTop = frameComponent._ninePatchData.contentPadding.top or 0 + framePaddingBottom = frameComponent._ninePatchData.contentPadding.bottom or 0 + end + + -- Draw track (frame) if component exists + if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then + self._NinePatch.draw( + frameComponent, + frameComponent._loadedAtlas, + trackX, + trackY, + dims.horizontal.trackWidth, + element.scrollbarWidth + ) + end + + -- Draw thumb (bar) if component exists + if barComponent and barComponent._loadedAtlas and barComponent.regions then + -- Adjust knob dimensions to account for frame's contentPadding + -- Horizontal scrollbar: width affected by left+right, height affected by top+bottom + local knobWidth = dims.horizontal.thumbWidth - framePaddingLeft / 2 + local knobHeight = element.scrollbarWidth - framePaddingTop - framePaddingBottom + self._NinePatch.draw( + barComponent, + barComponent._loadedAtlas, + trackX + dims.horizontal.thumbX + knobOffsetX, + trackY + knobOffsetY, + knobWidth, + knobHeight + ) + end + else + -- Fallback to color-based rendering + -- Determine thumb color based on state (independent for horizontal) + local thumbColor = element.scrollbarColor + if element._scrollbarDragging and element._hoveredScrollbar == "horizontal" then + -- Active state: brighter + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) + thumbColor = self._Color.new(r, g, b, a) + elseif element._scrollbarHoveredHorizontal then + -- Hover state: slightly brighter + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) + thumbColor = self._Color.new(r, g, b, a) + end + + -- Draw track + love.graphics.setColor(element.scrollbarTrackColor:toRGBA()) + love.graphics.rectangle( + "fill", + trackX, + trackY, + dims.horizontal.trackWidth, + element.scrollbarWidth, + element.scrollbarRadius + ) + + -- Draw thumb with state-based color + love.graphics.setColor(thumbColor:toRGBA()) + love.graphics.rectangle( + "fill", + trackX + dims.horizontal.thumbX, + trackY, + dims.horizontal.thumbWidth, + element.scrollbarWidth, + element.scrollbarRadius + ) + end + end + + -- Reset color + love.graphics.setColor(1, 1, 1, 1) +end + +--- Draw visual feedback when element is pressed +---@param x number X position +---@param y number Y position +---@param borderBoxWidth number Border box width +---@param borderBoxHeight number Border box height +---@param opacity number Element opacity +---@param cornerRadius number|table Corner radius +function Renderer:drawPressedState(x, y, borderBoxWidth, borderBoxHeight, opacity, cornerRadius) + love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * (opacity or 1)) + self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, cornerRadius) +end + +--- Cleanup renderer resources +function Renderer:destroy() + self._loadedImage = nil + self._blurInstance = nil +end + +return Renderer diff --git a/libs/flexlove/modules/RoundedRect.lua b/libs/flexlove/modules/RoundedRect.lua new file mode 100644 index 00000000..8db7222d --- /dev/null +++ b/libs/flexlove/modules/RoundedRect.lua @@ -0,0 +1,124 @@ +local RoundedRect = {} + +--- Generate points for a rounded rectangle +---@param x number +---@param y number +---@param width number +---@param height number +---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number +---@param segments number? -- Number of segments per corner arc (default: 10) +---@return table -- Array of vertices for love.graphics.polygon +function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments) + segments = segments or 10 + local points = {} + + -- Helper to add arc points + local function addArc(cx, cy, radius, startAngle, endAngle) + if radius <= 0 then + table.insert(points, cx) + table.insert(points, cy) + return + end + + for i = 0, segments do + local angle = startAngle + (endAngle - startAngle) * (i / segments) + table.insert(points, cx + math.cos(angle) * radius) + table.insert(points, cy + math.sin(angle) * radius) + end + end + + -- Handle uniform corner radius (number) + if type(cornerRadius) == "number" then + cornerRadius = { + topLeft = cornerRadius, + topRight = cornerRadius, + bottomLeft = cornerRadius, + bottomRight = cornerRadius, + } + end + + local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2) + local r2 = math.min(cornerRadius.topRight, width / 2, height / 2) + local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2) + local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2) + + -- Top-right corner + addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0) + + -- Bottom-right corner + addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2) + + -- Bottom-left corner + addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi) + + -- Top-left corner + addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5) + + return points +end + +--- Draw a filled rounded rectangle +---@param mode string -- "fill" or "line" +---@param x number +---@param y number +---@param width number +---@param height number +---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil +function RoundedRect.draw(mode, x, y, width, height, cornerRadius) + -- OPTIMIZATION: Handle nil cornerRadius (no rounding) + if not cornerRadius then + love.graphics.rectangle(mode, x, y, width, height) + return + end + + -- Handle uniform corner radius (number) + if type(cornerRadius) == "number" then + if cornerRadius <= 0 then + love.graphics.rectangle(mode, x, y, width, height) + return + end + -- Convert to table format for processing + cornerRadius = { + topLeft = cornerRadius, + topRight = cornerRadius, + bottomLeft = cornerRadius, + bottomRight = cornerRadius, + } + end + + -- Check if any corners are rounded + local hasRoundedCorners = cornerRadius.topLeft > 0 + or cornerRadius.topRight > 0 + or cornerRadius.bottomLeft > 0 + or cornerRadius.bottomRight > 0 + + if not hasRoundedCorners then + -- No rounded corners, use regular rectangle + love.graphics.rectangle(mode, x, y, width, height) + return + end + + local points = RoundedRect.getPoints(x, y, width, height, cornerRadius) + + if mode == "fill" then + love.graphics.polygon("fill", points) + else + -- For line mode, draw the outline + love.graphics.polygon("line", points) + end +end + +--- Create a stencil function for rounded rectangle clipping +---@param x number +---@param y number +---@param width number +---@param height number +---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil +---@return function +function RoundedRect.stencilFunction(x, y, width, height, cornerRadius) + return function() + RoundedRect.draw("fill", x, y, width, height, cornerRadius) + end +end + +return RoundedRect diff --git a/libs/flexlove/modules/ScrollManager.lua b/libs/flexlove/modules/ScrollManager.lua new file mode 100644 index 00000000..a2a5d698 --- /dev/null +++ b/libs/flexlove/modules/ScrollManager.lua @@ -0,0 +1,1446 @@ +---@class ScrollManager +---@field overflow string -- "visible"|"hidden"|"auto"|"scroll" +---@field overflowX string? -- X-axis specific overflow (overrides overflow) +---@field overflowY string? -- Y-axis specific overflow (overrides overflow) +---@field scrollbarWidth number -- Width/height of scrollbar track +---@field scrollbarColor Color -- Scrollbar thumb color +---@field scrollbarTrackColor Color -- Scrollbar track background color +---@field scrollbarRadius number -- Border radius for scrollbars +---@field scrollbarPadding number -- Padding around scrollbar +---@field scrollSpeed number -- Scroll speed for wheel events (pixels per wheel unit) +---@field invertScroll boolean -- Invert mouse wheel scroll direction (default: false) +---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars) +---@field scrollbarKnobOffset table -- {x: number, y: number, horizontal: number, vertical: number} -- Offset for scrollbar knob/handle position +---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean} +---@field scrollbarPlacement string -- "reserve-space"|"overlay" -- Whether scrollbar reserves space or overlays content (default: "reserve-space") +---@field scrollbarBalance boolean -- When true, reserve space on both sides of content for visual balance (default: false) +---@field touchScrollEnabled boolean -- Enable touch scrolling +---@field momentumScrollEnabled boolean -- Enable momentum scrolling +---@field bounceEnabled boolean -- Enable bounce effects at boundaries +---@field scrollFriction number -- Friction coefficient for momentum (0.95-0.98) +---@field bounceStiffness number -- Bounce spring constant (0.1-0.3) +---@field maxOverscroll number -- Maximum overscroll distance (pixels) +---@field _overflowX boolean -- True if content overflows horizontally +---@field _overflowY boolean -- True if content overflows vertically +---@field _contentWidth number -- Total content width (including overflow) +---@field _contentHeight number -- Total content height (including overflow) +---@field _scrollX number -- Current horizontal scroll position +---@field _scrollY number -- Current vertical scroll position +---@field _targetScrollX number? -- Target scroll X for smooth scrolling +---@field _targetScrollY number? -- Target scroll Y for smooth scrolling +---@field _smoothScrollSpeed number -- Speed of smooth scroll interpolation (0-1, higher = faster) +---@field _maxScrollX number -- Maximum horizontal scroll (contentWidth - containerWidth) +---@field _maxScrollY number -- Maximum vertical scroll (contentHeight - containerHeight) +---@field _scrollbarHoveredVertical boolean -- True if mouse is over vertical scrollbar +---@field _scrollbarHoveredHorizontal boolean -- True if mouse is over horizontal scrollbar +---@field _scrollbarDragging boolean -- True if currently dragging a scrollbar +---@field _hoveredScrollbar string? -- "vertical" or "horizontal" when dragging +---@field _scrollbarDragOffset number -- DEPRECATED: Offset from thumb top when drag started (kept for compatibility) +---@field _dragStartMouseX number -- Mouse X position when drag started +---@field _dragStartMouseY number -- Mouse Y position when drag started +---@field _dragStartScrollX number -- Scroll X position when drag started +---@field _dragStartScrollY number -- Scroll Y position when drag started +---@field _scrollbarPressHandled boolean -- Track if scrollbar press was handled this frame +---@field _touchScrolling boolean -- True if currently touch scrolling +---@field _scrollVelocityX number -- Current horizontal scroll velocity (px/s) +---@field _scrollVelocityY number -- Current vertical scroll velocity (px/s) +---@field _momentumScrolling boolean -- True if momentum scrolling is active +---@field _lastTouchTime number -- Timestamp of last touch move +---@field _lastTouchX number -- Last touch X position +---@field _lastTouchY number -- Last touch Y position +---@field _Color table +---@field _utils table +---@field _ErrorHandler table? ErrorHandler module dependency +local ScrollManager = {} +ScrollManager.__index = ScrollManager + +--- Initialize module with shared dependencies +---@param deps table Dependencies {ErrorHandler} +function ScrollManager.init(deps) + if type(deps) == "table" then + ScrollManager._ErrorHandler = deps.ErrorHandler or ScrollManager._ErrorHandler + ScrollManager._Context = deps.Context or ScrollManager._Context + ScrollManager._StateManager = deps.StateManager or ScrollManager._StateManager + end +end + +--- Create a new ScrollManager instance +---@param config table Configuration options +---@param deps table Dependencies {Color: Color module, utils: utils module} +---@return ScrollManager +function ScrollManager.new(config, deps) + local Color = deps.Color + local self = setmetatable({}, ScrollManager) + + -- Store dependencies for instance methods + self._Color = Color + self._utils = deps.utils + + -- Configuration + self.overflow = config.overflow or "hidden" + self.overflowX = config.overflowX + self.overflowY = config.overflowY + + -- Scrollbar appearance + self.scrollbarWidth = config.scrollbarWidth or 12 + self.scrollbarColor = config.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8) + self.scrollbarTrackColor = config.scrollbarTrackColor or Color.new(0.2, 0.2, 0.2, 0.5) + self.scrollbarRadius = config.scrollbarRadius or 6 + self.scrollbarPadding = config.scrollbarPadding or 2 + self.scrollSpeed = config.scrollSpeed or 20 + self.invertScroll = config.invertScroll or false + self.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default) + + -- scrollbarKnobOffset can be number or table {x, y} or {horizontal, vertical} + -- Only normalize if actually provided (nil means use theme default) + if config.scrollbarKnobOffset ~= nil then + self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(config.scrollbarKnobOffset, 0) + else + self.scrollbarKnobOffset = nil + end + + -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean} + self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false) + + -- Scrollbar placement: "reserve-space" (default) or "overlay" + self.scrollbarPlacement = config.scrollbarPlacement or "reserve-space" + + -- Scrollbar balance: when true, reserve space on both sides for visual balance + self.scrollbarBalance = config.scrollbarBalance or false + + -- Touch scrolling configuration + self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true + self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true + self.bounceEnabled = config.bounceEnabled ~= false -- Default true + self.scrollFriction = config.scrollFriction or 0.95 -- Exponential decay per frame + self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant + self.maxOverscroll = config.maxOverscroll or 100 -- pixels + + -- Internal overflow state + self._overflowX = false + self._overflowY = false + self._contentWidth = 0 + self._contentHeight = 0 + + -- Scroll state (can be restored from config in immediate mode) + self._scrollX = config._scrollX or 0 + self._scrollY = config._scrollY or 0 + self._targetScrollX = nil + self._targetScrollY = nil + self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster) + self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling + self._maxScrollX = 0 + self._maxScrollY = 0 + + -- Scrollbar interaction state + self._scrollbarHoveredVertical = false + self._scrollbarHoveredHorizontal = false + self._scrollbarDragging = false + self._hoveredScrollbar = nil -- "vertical" or "horizontal" + self._scrollbarDragOffset = 0 -- DEPRECATED: kept for backward compatibility + self._dragStartMouseX = 0 -- Mouse X position when drag started + self._dragStartMouseY = 0 -- Mouse Y position when drag started + self._dragStartScrollX = 0 -- Scroll X position when drag started + self._dragStartScrollY = 0 -- Scroll Y position when drag started + self._scrollbarPressHandled = false + + -- Touch scrolling state + self._touchScrolling = false + self._scrollVelocityX = 0 + self._scrollVelocityY = 0 + self._momentumScrolling = false + self._lastTouchTime = 0 + self._lastTouchX = 0 + self._lastTouchY = 0 + + return self +end + +--- Get the space reserved for scrollbars (width and height reduction) +--- This is called BEFORE layout to reduce available space for children +---@param element Element The parent Element instance +---@return number reservedWidth, number reservedHeight +function ScrollManager:getReservedSpace() + if self.scrollbarPlacement ~= "reserve-space" then + return 0, 0 + end + + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + local reservedWidth = 0 + local reservedHeight = 0 + + -- Reserve space for vertical scrollbar if overflow mode requires it + if (overflowY == "scroll" or overflowY == "auto") and not self.hideScrollbars.vertical then + local scrollbarSpace = self.scrollbarWidth + (self.scrollbarPadding * 2) + reservedWidth = self.scrollbarBalance and (scrollbarSpace * 2) or scrollbarSpace + end + + -- Reserve space for horizontal scrollbar if overflow mode requires it + if (overflowX == "scroll" or overflowX == "auto") and not self.hideScrollbars.horizontal then + local scrollbarSpace = self.scrollbarWidth + (self.scrollbarPadding * 2) + reservedHeight = self.scrollbarBalance and (scrollbarSpace * 2) or scrollbarSpace + end + + return reservedWidth, reservedHeight +end + +--- Detect if content overflows container bounds +---@param element Element The parent Element instance +function ScrollManager:detectOverflow(element) + -- Reset overflow state + self._overflowX = false + self._overflowY = false + self._contentWidth = element.width + self._contentHeight = element.height + + -- Skip detection if overflow is visible (no clipping needed) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + if overflowX == "visible" and overflowY == "visible" then + return + end + + -- Calculate content bounds based on children + if #element.children == 0 then + return -- No children, no overflow + end + + local maxX, maxY = 0, 0 + + -- Content area starts after padding + local contentX = element.x + element.padding.left + local contentY = element.y + element.padding.top + + for _, child in ipairs(element.children) do + -- Skip absolutely positioned children (they don't contribute to overflow) + if not child._explicitlyAbsolute then + -- Calculate child's margin box bounds relative to content area + local childMarginRight = child.x - contentX + child:getBorderBoxWidth() + child.margin.right + local childMarginBottom = child.y - contentY + child:getBorderBoxHeight() + child.margin.bottom + + -- Track the maximum extents (we ignore negative space from margins) + maxX = math.max(maxX, childMarginRight) + maxY = math.max(maxY, childMarginBottom) + end + end + + -- Calculate content dimensions + self._contentWidth = maxX + self._contentHeight = maxY + + -- Detect overflow (compare against content area, not total element size). + -- element.width/height semantics depend on unit type: + -- px units → border-box size (padding NOT yet subtracted) + -- %, vh, vw → content size (padding already subtracted by LayoutEngine) + -- auto → content size + -- Using getBorderBoxWidth/Height() normalises both cases: border-box - padding = content. + local containerWidth = element:getBorderBoxWidth() - element.padding.left - element.padding.right + local containerHeight = element:getBorderBoxHeight() - element.padding.top - element.padding.bottom + + -- If scrollbarPlacement is "reserve-space", we need to subtract the reserved space + -- because the layout already accounted for it, but element.width/height are still full size + if self.scrollbarPlacement == "reserve-space" then + local reservedWidth, reservedHeight = self:getReservedSpace() + containerWidth = containerWidth - reservedWidth + containerHeight = containerHeight - reservedHeight + end + + self._overflowX = self._contentWidth > containerWidth + self._overflowY = self._contentHeight > containerHeight + + -- Calculate maximum scroll bounds + self._maxScrollX = math.max(0, self._contentWidth - containerWidth) + self._maxScrollY = math.max(0, self._contentHeight - containerHeight) + + -- Clamp current scroll position to new bounds + self._scrollX = self._utils.clamp(self._scrollX, 0, self._maxScrollX) + self._scrollY = self._utils.clamp(self._scrollY, 0, self._maxScrollY) +end + +--- Set scroll position with bounds clamping +---@param x number? -- X scroll position (nil to keep current) +---@param y number? -- Y scroll position (nil to keep current) +function ScrollManager:setScroll(x, y) + if x ~= nil then + self._scrollX = self._utils.clamp(x, 0, self._maxScrollX) + end + if y ~= nil then + self._scrollY = self._utils.clamp(y, 0, self._maxScrollY) + end +end + +--- Get current scroll position +---@return number scrollX, number scrollY +function ScrollManager:getScroll() + return self._scrollX, self._scrollY +end + +--- Scroll by delta amount +---@param dx number? -- X delta (nil for no change) +---@param dy number? -- Y delta (nil for no change) +function ScrollManager:scrollBy(dx, dy) + if dx then + self._scrollX = self._utils.clamp(self._scrollX + dx, 0, self._maxScrollX) + end + if dy then + self._scrollY = self._utils.clamp(self._scrollY + dy, 0, self._maxScrollY) + end +end + +--- Get maximum scroll bounds +---@return number maxScrollX, number maxScrollY +function ScrollManager:getMaxScroll() + return self._maxScrollX, self._maxScrollY +end + +--- Get scroll percentage (0-1) +---@return number percentX, number percentY +function ScrollManager:getScrollPercentage() + local percentX = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 + local percentY = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 + return percentX, percentY +end + +--- Check if element has overflow +---@return boolean hasOverflowX, boolean hasOverflowY +function ScrollManager:hasOverflow() + return self._overflowX, self._overflowY +end + +--- Get content dimensions (including overflow) +---@return number contentWidth, number contentHeight +function ScrollManager:getContentSize() + return self._contentWidth, self._contentHeight +end + +--- Calculate scrollbar dimensions and positions +---@param element Element The parent Element instance +---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}} +function ScrollManager:calculateScrollbarDimensions(element) + local result = { + vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, + horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, + } + + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + -- Vertical scrollbar + -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows + if overflowY == "scroll" then + -- Always show scrollbar for "scroll" mode + result.vertical.visible = true + result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2) + + if self._overflowY then + -- Content overflows, calculate proper thumb size + local contentRatio = element.height / math.max(self._contentHeight, element.height) + result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 + local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight + result.vertical.thumbY = maxThumbY * scrollRatio + else + -- No overflow, thumb fills entire track + result.vertical.thumbHeight = result.vertical.trackHeight + result.vertical.thumbY = 0 + end + elseif self._overflowY and overflowY == "auto" then + -- Only show scrollbar when content actually overflows + result.vertical.visible = true + result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2) + + -- Calculate thumb height based on content ratio + local contentRatio = element.height / math.max(self._contentHeight, element.height) + result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 + local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight + result.vertical.thumbY = maxThumbY * scrollRatio + end + + -- Horizontal scrollbar + -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows + if overflowX == "scroll" then + -- Always show scrollbar for "scroll" mode + result.horizontal.visible = true + result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2) + + if self._overflowX then + -- Content overflows, calculate proper thumb size + local contentRatio = element.width / math.max(self._contentWidth, element.width) + result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 + local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth + result.horizontal.thumbX = maxThumbX * scrollRatio + else + -- No overflow, thumb fills entire track + result.horizontal.thumbWidth = result.horizontal.trackWidth + result.horizontal.thumbX = 0 + end + elseif self._overflowX and overflowX == "auto" then + -- Only show scrollbar when content actually overflows + result.horizontal.visible = true + result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2) + + -- Calculate thumb width based on content ratio + local contentRatio = element.width / math.max(self._contentWidth, element.width) + result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 + local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth + result.horizontal.thumbX = maxThumbX * scrollRatio + end + + return result +end + +--- Get scrollbar at mouse position +---@param element Element The parent Element instance +---@param mouseX number +---@param mouseY number +---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"} +function ScrollManager:getScrollbarAtPosition(element, mouseX, mouseY) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then + return nil + end + + local dims = self:calculateScrollbarDimensions(element) + local x, y = element.x, element.y + local w, h = element.width, element.height + + -- Check vertical scrollbar (only if not hidden) + if dims.vertical.visible and not self.hideScrollbars.vertical then + -- Position scrollbar within content area (x, y is border-box origin) + local contentX = x + element.padding.left + local contentY = y + element.padding.top + local trackX = contentX + w - self.scrollbarWidth - self.scrollbarPadding + local trackY = contentY + self.scrollbarPadding + local trackW = self.scrollbarWidth + local trackH = dims.vertical.trackHeight + + if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then + -- Check if over thumb + local thumbY = trackY + dims.vertical.thumbY + local thumbH = dims.vertical.thumbHeight + if mouseY >= thumbY and mouseY <= thumbY + thumbH then + return { component = "vertical", region = "thumb" } + else + return { component = "vertical", region = "track" } + end + end + end + + -- Check horizontal scrollbar (only if not hidden) + if dims.horizontal.visible and not self.hideScrollbars.horizontal then + -- Position scrollbar within content area (x, y is border-box origin) + local contentX = x + element.padding.left + local contentY = y + element.padding.top + local trackX = contentX + self.scrollbarPadding + local trackY = contentY + h - self.scrollbarWidth - self.scrollbarPadding + local trackW = dims.horizontal.trackWidth + local trackH = self.scrollbarWidth + + if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then + -- Check if over thumb + local thumbX = trackX + dims.horizontal.thumbX + local thumbW = dims.horizontal.thumbWidth + if mouseX >= thumbX and mouseX <= thumbX + thumbW then + return { component = "horizontal", region = "thumb" } + else + return { component = "horizontal", region = "track" } + end + end + end + + return nil +end + +--- Handle scrollbar mouse press +---@param element Element The parent Element instance +---@param mouseX number +---@param mouseY number +---@param button number +---@return boolean -- True if event was consumed +function ScrollManager:handleMousePress(element, mouseX, mouseY, button) + if button ~= 1 then + return false + end -- Only left click + + local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY) + if not scrollbar then + return false + end + + if scrollbar.region == "thumb" then + -- Start dragging thumb - store start positions for relative movement tracking + self._scrollbarDragging = true + self._hoveredScrollbar = scrollbar.component + + -- Store drag start positions for relative movement calculation + self._dragStartMouseX = mouseX + self._dragStartMouseY = mouseY + self._dragStartScrollX = self._scrollX + self._dragStartScrollY = self._scrollY + + return true -- Event consumed + elseif scrollbar.region == "track" then + self:_scrollToTrackPosition(element, mouseX, mouseY, scrollbar.component) + return true + end + + return false +end + +--- Handle scrollbar drag +---@param element Element The parent Element instance +---@param mouseX number +---@param mouseY number +---@return boolean -- True if event was consumed +function ScrollManager:handleMouseMove(element, mouseX, mouseY) + if not self._scrollbarDragging then + return false + end + + local dims = self:calculateScrollbarDimensions(element) + + if self._hoveredScrollbar == "vertical" then + local trackH = dims.vertical.trackHeight + local thumbH = dims.vertical.thumbHeight + + -- Calculate relative mouse movement from drag start + local mouseDeltaY = mouseY - self._dragStartMouseY + + -- Convert mouse delta to scroll delta + -- scrollDelta / maxScroll = thumbDelta / (trackHeight - thumbHeight) + local scrollableTrackHeight = trackH - thumbH + local scrollDelta = scrollableTrackHeight > 0 and (mouseDeltaY / scrollableTrackHeight) * self._maxScrollY or 0 + + local newScrollY = self._dragStartScrollY + scrollDelta + newScrollY = self._utils.clamp(newScrollY, 0, self._maxScrollY) + + self:setScroll(nil, newScrollY) + return true + elseif self._hoveredScrollbar == "horizontal" then + local trackW = dims.horizontal.trackWidth + local thumbW = dims.horizontal.thumbWidth + + -- Calculate relative mouse movement from drag start + local mouseDeltaX = mouseX - self._dragStartMouseX + + -- Convert mouse delta to scroll delta + local scrollableTrackWidth = trackW - thumbW + local scrollDelta = scrollableTrackWidth > 0 and (mouseDeltaX / scrollableTrackWidth) * self._maxScrollX or 0 + + -- Apply delta to starting scroll position + local newScrollX = self._dragStartScrollX + scrollDelta + newScrollX = self._utils.clamp(newScrollX, 0, self._maxScrollX) + + self:setScroll(newScrollX, nil) + return true + end + + return false +end + +--- Handle scrollbar release +---@param button number +---@return boolean -- True if event was consumed +function ScrollManager:handleMouseRelease(button) + if button ~= 1 then + return false + end + + if self._scrollbarDragging then + self._scrollbarDragging = false + return true + end + + return false +end + +--- Scroll to track click position (internal helper) +---@param element Element The parent Element instance +---@param mouseX number +---@param mouseY number +---@param component string -- "vertical" or "horizontal" +function ScrollManager:_scrollToTrackPosition(element, mouseX, mouseY, component) + local dims = self:calculateScrollbarDimensions(element) + + if component == "vertical" then + local contentY = element.y + element.padding.top + local trackY = contentY + self.scrollbarPadding + local trackH = dims.vertical.trackHeight + local thumbH = dims.vertical.thumbHeight + + -- Calculate target thumb position (centered on click) + local targetThumbY = mouseY - trackY - (thumbH / 2) + targetThumbY = self._utils.clamp(targetThumbY, 0, trackH - thumbH) + + -- Convert to scroll position + local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0 + local newScrollY = scrollRatio * self._maxScrollY + + self:setScroll(nil, newScrollY) + elseif component == "horizontal" then + local contentX = element.x + element.padding.left + local trackX = contentX + self.scrollbarPadding + local trackW = dims.horizontal.trackWidth + local thumbW = dims.horizontal.thumbWidth + + -- Calculate target thumb position (centered on click) + local targetThumbX = mouseX - trackX - (thumbW / 2) + targetThumbX = self._utils.clamp(targetThumbX, 0, trackW - thumbW) + + -- Convert to scroll position + local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0 + local newScrollX = scrollRatio * self._maxScrollX + + self:setScroll(newScrollX, nil) + end +end + +--- Handle mouse wheel scrolling +---@param x number -- Horizontal scroll amount +---@param y number -- Vertical scroll amount +---@return boolean -- True if scroll was handled +function ScrollManager:handleWheel(x, y) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then + return false + end + + -- In immediate mode, overflow might not be calculated yet, so allow scrolling based on maxScroll values + -- If _overflowY is nil/false but _maxScrollY > 0, we should still allow scrolling (from restored state) + local hasVerticalOverflow = (self._overflowY and self._maxScrollY > 0) or (self._maxScrollY and self._maxScrollY > 0) + local hasHorizontalOverflow = (self._overflowX and self._maxScrollX > 0) + or (self._maxScrollX and self._maxScrollX > 0) + + local scrolled = false + + -- Vertical scrolling + if y ~= 0 and (overflowY == "scroll" or overflowY == "auto") and hasVerticalOverflow then + local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up + if self.invertScroll then + delta = -delta -- Invert scroll direction if enabled + end + if self.smoothScrollEnabled then + -- Set target for smooth scrolling instead of instant jump + self._targetScrollY = self._utils.clamp((self._targetScrollY or self._scrollY) + delta, 0, self._maxScrollY) + else + -- Instant scrolling (default behavior) + local newScrollY = self._scrollY + delta + self:setScroll(nil, newScrollY) + end + scrolled = true + end + + -- Horizontal scrolling + if x ~= 0 and (overflowX == "scroll" or overflowX == "auto") and hasHorizontalOverflow then + local delta = -x * self.scrollSpeed + if self.invertScroll then + delta = -delta -- Invert scroll direction if enabled + end + if self.smoothScrollEnabled then + -- Set target for smooth scrolling instead of instant jump + self._targetScrollX = self._utils.clamp((self._targetScrollX or self._scrollX) + delta, 0, self._maxScrollX) + else + -- Instant scrolling (default behavior) + local newScrollX = self._scrollX + delta + self:setScroll(newScrollX, nil) + end + scrolled = true + end + + return scrolled +end + +--- Update scrollbar hover state based on mouse position +---@param element Element The parent Element instance +---@param mouseX number +---@param mouseY number +function ScrollManager:updateHoverState(element, mouseX, mouseY) + local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY) + + if scrollbar then + if scrollbar.component == "vertical" then + self._scrollbarHoveredVertical = true + self._scrollbarHoveredHorizontal = false + elseif scrollbar.component == "horizontal" then + self._scrollbarHoveredVertical = false + self._scrollbarHoveredHorizontal = true + end + else + self._scrollbarHoveredVertical = false + self._scrollbarHoveredHorizontal = false + end +end + +--- Reset scrollbar press handled flag (call at start of frame) +function ScrollManager:resetScrollbarPressFlag() + self._scrollbarPressHandled = false +end + +--- Check if scrollbar press was handled this frame +---@return boolean +function ScrollManager:wasScrollbarPressHandled() + return self._scrollbarPressHandled +end + +--- Set scrollbar press handled flag +function ScrollManager:setScrollbarPressHandled() + self._scrollbarPressHandled = true +end + +--- Get state for immediate mode persistence +---@return table State data +function ScrollManager:getState() + return { + _scrollX = self._scrollX or 0, + _scrollY = self._scrollY or 0, + _targetScrollX = self._targetScrollX, + _targetScrollY = self._targetScrollY, + _scrollbarDragging = self._scrollbarDragging or false, + _hoveredScrollbar = self._hoveredScrollbar, + _scrollbarDragOffset = self._scrollbarDragOffset or 0, -- Deprecated but kept for compatibility + _dragStartMouseX = self._dragStartMouseX or 0, + _dragStartMouseY = self._dragStartMouseY or 0, + _dragStartScrollX = self._dragStartScrollX or 0, + _dragStartScrollY = self._dragStartScrollY or 0, + _scrollbarHoveredVertical = self._scrollbarHoveredVertical or false, + _scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false, + scrollBarStyle = self.scrollBarStyle, + scrollbarKnobOffset = self.scrollbarKnobOffset, + scrollbarPlacement = self.scrollbarPlacement, + scrollbarBalance = self.scrollbarBalance, + _overflowX = self._overflowX, + _overflowY = self._overflowY, + _contentWidth = self._contentWidth, + _contentHeight = self._contentHeight, + } +end + +--- Set state from immediate mode persistence +---@param state table State data +function ScrollManager:setState(state) + if not state then + return + end + + -- Support both old (scrollX) and new (_scrollX) field names for backward compatibility + if state._scrollX ~= nil then + self._scrollX = state._scrollX + elseif state.scrollX ~= nil then + self._scrollX = state.scrollX + end + + if state._scrollY ~= nil then + self._scrollY = state._scrollY + elseif state.scrollY ~= nil then + self._scrollY = state.scrollY + end + + if state._scrollbarDragging ~= nil then + self._scrollbarDragging = state._scrollbarDragging + elseif state.scrollbarDragging ~= nil then + self._scrollbarDragging = state.scrollbarDragging + end + + if state._hoveredScrollbar ~= nil then + self._hoveredScrollbar = state._hoveredScrollbar + elseif state.hoveredScrollbar ~= nil then + self._hoveredScrollbar = state.hoveredScrollbar + end + + if state._scrollbarDragOffset ~= nil then + self._scrollbarDragOffset = state._scrollbarDragOffset + elseif state.scrollbarDragOffset ~= nil then + self._scrollbarDragOffset = state.scrollbarDragOffset + end + + -- Restore drag start positions for relative movement tracking + if state._dragStartMouseX ~= nil then + self._dragStartMouseX = state._dragStartMouseX + end + + if state._dragStartMouseY ~= nil then + self._dragStartMouseY = state._dragStartMouseY + end + + if state._dragStartScrollX ~= nil then + self._dragStartScrollX = state._dragStartScrollX + end + + if state._dragStartScrollY ~= nil then + self._dragStartScrollY = state._dragStartScrollY + end + + if state._scrollbarHoveredVertical ~= nil then + self._scrollbarHoveredVertical = state._scrollbarHoveredVertical + end + + if state._scrollbarHoveredHorizontal ~= nil then + self._scrollbarHoveredHorizontal = state._scrollbarHoveredHorizontal + end + + if state.scrollBarStyle ~= nil then + self.scrollBarStyle = state.scrollBarStyle + end + + if state.scrollbarKnobOffset ~= nil then + self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(state.scrollbarKnobOffset, 0) + end + + if state.scrollbarPlacement ~= nil then + self.scrollbarPlacement = state.scrollbarPlacement + end + + if state.scrollbarBalance ~= nil then + self.scrollbarBalance = state.scrollbarBalance + end + + if state._overflowX ~= nil then + self._overflowX = state._overflowX + end + + if state._overflowY ~= nil then + self._overflowY = state._overflowY + end + + if state._contentWidth ~= nil then + self._contentWidth = state._contentWidth + end + + if state._contentHeight ~= nil then + self._contentHeight = state._contentHeight + end + + if state._targetScrollX ~= nil then + self._targetScrollX = state._targetScrollX + end + + if state._targetScrollY ~= nil then + self._targetScrollY = state._targetScrollY + end +end + +--- Handle touch press for scrolling +---@param touchX number +---@param touchY number +---@return boolean -- True if touch scroll started +function ScrollManager:handleTouchPress(touchX, touchY) + if not self.touchScrollEnabled then + return false + end + + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then + return false + end + + -- Stop momentum scrolling if active + if self._momentumScrolling then + self._momentumScrolling = false + self._scrollVelocityX = 0 + self._scrollVelocityY = 0 + end + + -- Start touch scrolling + self._touchScrolling = true + self._lastTouchX = touchX + self._lastTouchY = touchY + self._lastTouchTime = love.timer.getTime() + + return true +end + +--- Handle touch move for scrolling +---@param touchX number +---@param touchY number +---@return boolean -- True if touch scroll was handled +function ScrollManager:handleTouchMove(touchX, touchY) + if not self._touchScrolling then + return false + end + + local currentTime = love.timer.getTime() + local dt = currentTime - self._lastTouchTime + + if dt <= 0 then + return false + end + + -- Calculate delta and velocity + local dx = touchX - self._lastTouchX + local dy = touchY - self._lastTouchY + + -- Invert deltas (touch moves opposite to scroll) + dx = -dx + dy = -dy + + -- Calculate velocity (pixels per second) + self._scrollVelocityX = dx / dt + self._scrollVelocityY = dy / dt + + -- Apply scroll with bounce if enabled + if self.bounceEnabled then + -- Allow overscroll + local newScrollX = self._scrollX + dx + local newScrollY = self._scrollY + dy + + -- Clamp to max overscroll limits + local minScrollX = -self.maxOverscroll + local maxScrollX = self._maxScrollX + self.maxOverscroll + local minScrollY = -self.maxOverscroll + local maxScrollY = self._maxScrollY + self.maxOverscroll + + newScrollX = self._utils.clamp(newScrollX, minScrollX, maxScrollX) + newScrollY = self._utils.clamp(newScrollY, minScrollY, maxScrollY) + + self._scrollX = newScrollX + self._scrollY = newScrollY + else + -- Normal clamped scrolling + self:scrollBy(dx, dy) + end + + -- Update last touch state + self._lastTouchX = touchX + self._lastTouchY = touchY + self._lastTouchTime = currentTime + + return true +end + +--- Handle touch release for scrolling +---@return boolean -- True if touch scroll was active +function ScrollManager:handleTouchRelease() + if not self._touchScrolling then + return false + end + + self._touchScrolling = false + + -- Start momentum scrolling if enabled and velocity is significant + if self.momentumScrollEnabled then + local velocityThreshold = 50 -- pixels per second + local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2) + + if totalVelocity > velocityThreshold then + self._momentumScrolling = true + else + self._scrollVelocityX = 0 + self._scrollVelocityY = 0 + end + else + self._scrollVelocityX = 0 + self._scrollVelocityY = 0 + end + + return true +end + +--- Update momentum scrolling (call every frame with dt) +---@param dt number Delta time in seconds +function ScrollManager:update(dt) + -- Smooth scroll interpolation + if self._targetScrollX or self._targetScrollY then + if self._targetScrollY then + local diff = self._targetScrollY - self._scrollY + if math.abs(diff) > 0.5 then + self._scrollY = self._scrollY + diff * self._smoothScrollSpeed + else + self._scrollY = self._targetScrollY + self._targetScrollY = nil + end + end + + if self._targetScrollX then + local diff = self._targetScrollX - self._scrollX + if math.abs(diff) > 0.5 then + self._scrollX = self._scrollX + diff * self._smoothScrollSpeed + else + self._scrollX = self._targetScrollX + self._targetScrollX = nil + end + end + end + + if not self._momentumScrolling then + -- Handle bounce back if overscrolled + if self.bounceEnabled then + self:_updateBounce(dt) + end + return + end + + -- Apply velocity to scroll position + local dx = self._scrollVelocityX * dt + local dy = self._scrollVelocityY * dt + + if self.bounceEnabled then + -- Allow overscroll during momentum + self._scrollX = self._scrollX + dx + self._scrollY = self._scrollY + dy + else + self:scrollBy(dx, dy) + end + + -- Apply friction (exponential decay) + self._scrollVelocityX = self._scrollVelocityX * self.scrollFriction + self._scrollVelocityY = self._scrollVelocityY * self.scrollFriction + + -- Stop momentum when velocity is very low + local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2) + if totalVelocity < 1 then + self._momentumScrolling = false + self._scrollVelocityX = 0 + self._scrollVelocityY = 0 + end + + -- Handle bounce back if overscrolled + if self.bounceEnabled then + self:_updateBounce(dt) + end +end + +--- Update bounce effect when overscrolled (internal) +---@param dt number Delta time in seconds +function ScrollManager:_updateBounce() + local bounced = false + + -- Bounce back horizontal overscroll + if self._scrollX < 0 then + local springForce = -self._scrollX * self.bounceStiffness + self._scrollX = self._scrollX + springForce + if math.abs(self._scrollX) < 0.5 then + self._scrollX = 0 + end + bounced = true + elseif self._scrollX > self._maxScrollX then + local overflow = self._scrollX - self._maxScrollX + local springForce = -overflow * self.bounceStiffness + self._scrollX = self._scrollX + springForce + if math.abs(overflow) < 0.5 then + self._scrollX = self._maxScrollX + end + bounced = true + end + + -- Bounce back vertical overscroll + if self._scrollY < 0 then + local springForce = -self._scrollY * self.bounceStiffness + self._scrollY = self._scrollY + springForce + if math.abs(self._scrollY) < 0.5 then + self._scrollY = 0 + end + bounced = true + elseif self._scrollY > self._maxScrollY then + local overflow = self._scrollY - self._maxScrollY + local springForce = -overflow * self.bounceStiffness + self._scrollY = self._scrollY + springForce + if math.abs(overflow) < 0.5 then + self._scrollY = self._maxScrollY + end + bounced = true + end + + -- Stop momentum if bouncing + if bounced and self._momentumScrolling then + -- Reduce velocity during bounce + self._scrollVelocityX = self._scrollVelocityX * 0.9 + self._scrollVelocityY = self._scrollVelocityY * 0.9 + end +end + +--- Check if currently touch scrolling +---@return boolean +function ScrollManager:isTouchScrolling() + return self._touchScrolling +end + +--- Check if currently momentum scrolling +---@return boolean +function ScrollManager:isMomentumScrolling() + return self._momentumScrolling +end + +------------------------------------------------------------------------------- +-- Element-facing delegates +-- +-- These wrappers bind the Element class's scroll API directly onto the +-- ScrollManager instance methods. Each takes the Element as its first argument +-- (the role `self` played when these methods lived on Element), performs the +-- nil-safety guard, invokes the owning ScrollManager instance, and syncs state +-- back onto the element for backward-compatible readers (Renderer, FlexLove, +-- Context hit-testing read these fields from Element). +-- +-- Element binds them via direct assignment in Element.init, e.g. +-- Element.scrollToTop = Element._ScrollManager.scrollToTop +-- so Element retains only 1-line delegates and owns no scroll logic. +------------------------------------------------------------------------------- + +local _EMPTY_SCROLLBAR_DIMS = { + vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, + horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, +} + +--- Sync internal scroll state onto the element for backward-compatible readers. +---@param element table Element instance whose _scrollManager holds the state +function ScrollManager.syncToElement(element) + local sm = element._scrollManager + if not sm then + return + end + element._overflowX = sm._overflowX + element._overflowY = sm._overflowY + element._contentWidth = sm._contentWidth + element._contentHeight = sm._contentHeight + element._scrollX = sm._scrollX + element._scrollY = sm._scrollY + element._maxScrollX = sm._maxScrollX + element._maxScrollY = sm._maxScrollY + element._scrollbarHoveredVertical = sm._scrollbarHoveredVertical + element._scrollbarHoveredHorizontal = sm._scrollbarHoveredHorizontal + element._scrollbarDragging = sm._scrollbarDragging + element._hoveredScrollbar = sm._hoveredScrollbar + element._scrollbarDragOffset = sm._scrollbarDragOffset +end + +--- Backward-compatible alias retained by Element internals (update hover/drag). +ScrollManager.syncScrollManagerState = ScrollManager.syncToElement + +--- Detect overflow and sync state onto element. +---@param element table Element instance +function ScrollManager._detectOverflow(element) + local sm = element._scrollManager + if not sm then + return + end + sm:detectOverflow(element) + ScrollManager.syncToElement(element) +end + +--- Set scroll position (element-facing). Nil args keep the current axis. +---@param element table Element instance +---@param x number? X scroll position +---@param y number? Y scroll position +function ScrollManager.setScrollPosition(element, x, y) + local sm = element._scrollManager + if not sm then + return + end + sm:setScroll(x, y) + ScrollManager.syncToElement(element) +end + +--- Calculate scrollbar dimensions (element-facing). +---@param element table Element instance +---@return table dims {vertical, horizontal} +function ScrollManager._calculateScrollbarDimensions(element) + local sm = element._scrollManager + if not sm then + return _EMPTY_SCROLLBAR_DIMS + end + return sm:calculateScrollbarDimensions(element) +end + +--- Get scrollbar at mouse position (element-facing). +---@param element table Element instance +---@param mouseX number +---@param mouseY number +---@return table|nil {component, region} +function ScrollManager._getScrollbarAtPosition(element, mouseX, mouseY) + local sm = element._scrollManager + if not sm then + return nil + end + return sm:getScrollbarAtPosition(element, mouseX, mouseY) +end + +--- Handle scrollbar mouse press (element-facing). +---@param element table Element instance +---@param mouseX number +---@param mouseY number +---@param button number +---@return boolean consumed +function ScrollManager._handleScrollbarPress(element, mouseX, mouseY, button) + local sm = element._scrollManager + if not sm then + return false + end + local consumed = sm:handleMousePress(element, mouseX, mouseY, button) + ScrollManager.syncToElement(element) + return consumed +end + +--- Handle scrollbar drag (element-facing). +---@param element table Element instance +---@param mouseX number +---@param mouseY number +---@return boolean consumed +function ScrollManager._handleScrollbarDrag(element, mouseX, mouseY) + local sm = element._scrollManager + if not sm then + return false + end + local consumed = sm:handleMouseMove(element, mouseX, mouseY) + ScrollManager.syncToElement(element) + return consumed +end + +--- Handle scrollbar release (element-facing). +---@param element table Element instance +---@param button number +---@return boolean consumed +function ScrollManager._handleScrollbarRelease(element, button) + local sm = element._scrollManager + if not sm then + return false + end + local consumed = sm:handleMouseRelease(button) + ScrollManager.syncToElement(element) + return consumed +end + +--- Handle mouse wheel scrolling (element-facing). +---@param element table Element instance +---@param x number Horizontal scroll amount +---@param y number Vertical scroll amount +---@return boolean consumed +function ScrollManager._handleWheelScroll(element, x, y) + local sm = element._scrollManager + if not sm then + return false + end + local consumed = sm:handleWheel(x, y) + ScrollManager.syncToElement(element) + return consumed +end + +--- Get current scroll position (element-facing). +---@param element table Element instance +---@return number scrollX, number scrollY +function ScrollManager.getScrollPosition(element) + local sm = element._scrollManager + if not sm then + return 0, 0 + end + return sm:getScroll() +end + +-- The following getters share names with ScrollManager *instance* methods, +-- so the element-facing wrappers use distinct `element`-prefixed names to +-- avoid shadowing the instance API (tests call sm:getMaxScroll() etc.). + +--- Get maximum scroll bounds (element-facing). +---@param element table Element instance +---@return number maxScrollX, number maxScrollY +function ScrollManager.elementGetMaxScroll(element) + local sm = element._scrollManager + if not sm then + return 0, 0 + end + return sm:getMaxScroll() +end + +--- Get scroll percentage 0-1 (element-facing). +---@param element table Element instance +---@return number percentX, number percentY +function ScrollManager.elementGetScrollPercentage(element) + local sm = element._scrollManager + if not sm then + return 0, 0 + end + return sm:getScrollPercentage() +end + +--- Check if element has overflow (element-facing). +---@param element table Element instance +---@return boolean hasOverflowX, boolean hasOverflowY +function ScrollManager.elementHasOverflow(element) + local sm = element._scrollManager + if not sm then + return false, false + end + return sm:hasOverflow() +end + +--- Get content dimensions (element-facing). +---@param element table Element instance +---@return number contentWidth, number contentHeight +function ScrollManager.elementGetContentSize(element) + local sm = element._scrollManager + if not sm then + return 0, 0 + end + return sm:getContentSize() +end + +--- Scroll by relative delta (element-facing). +-- In immediate mode, per-axis deltas whose scroll bound is still 0 are deferred +-- until layout calculates the bound (delegates to Element:_deferMethod). +---@param element table Element instance +---@param dx number? X delta +---@param dy number? Y delta +function ScrollManager.elementScrollBy(element, dx, dy) + local sm = element._scrollManager + if not sm then + return + end + local maxScrollX, maxScrollY = sm:getMaxScroll() + if dx ~= nil and maxScrollX == 0 then + element:_deferMethod("scrollBy", dx, nil) + dx = nil + end + if dy ~= nil and maxScrollY == 0 then + element:_deferMethod("scrollBy", nil, dy) + dy = nil + end + if dx ~= nil or dy ~= nil then + sm:scrollBy(dx, dy) + ScrollManager.syncToElement(element) + end +end + +--- Jump to the top of scrollable content. +---@param element table Element instance +function ScrollManager.scrollToTop(element) + element:setScrollPosition(nil, 0) +end + +--- Jump to the bottom of scrollable content. +-- Defers until layout has calculated the vertical scroll bound. +---@param element table Element instance +function ScrollManager.scrollToBottom(element) + local sm = element._scrollManager + if not sm then + return + end + local _, maxScrollY = sm:getMaxScroll() + if maxScrollY > 0 then + element:setScrollPosition(nil, maxScrollY) + else + element:_deferMethod("scrollToBottom") + end +end + +--- Jump to the leftmost position of scrollable content. +---@param element table Element instance +function ScrollManager.scrollToLeft(element) + element:setScrollPosition(0, nil) +end + +--- Jump to the rightmost position of scrollable content. +-- Defers until layout has calculated the horizontal scroll bound. +---@param element table Element instance +function ScrollManager.scrollToRight(element) + local sm = element._scrollManager + if not sm then + return + end + local maxScrollX, _ = sm:getMaxScroll() + if maxScrollX > 0 then + element:setScrollPosition(maxScrollX, nil) + else + element:_deferMethod("scrollToRight") + end +end + +--- Restore scrollbar state from StateManager in immediate mode. +---@param element table Element instance +function ScrollManager.restoreImmediateState(element) + -- Mode-aware guard: only immediate-mode frames keep state in StateManager; + -- in retained mode the element (and its ScrollManager) persist between + -- frames, so there is nothing to restore. Routed through StateManager so no + -- raw mode check lives here (behavior-mode-unification task 11). + if not element._stateId or not ScrollManager._StateManager.isImmediateMode() then + return + end + local state = ScrollManager._StateManager.getState(element._stateId) + if not state or not state.scrollManager then + return + end + local sm_state = state.scrollManager + element._scrollbarHoveredVertical = sm_state._scrollbarHoveredVertical or false + element._scrollbarHoveredHorizontal = sm_state._scrollbarHoveredHorizontal or false + element._scrollbarDragging = sm_state._scrollbarDragging or false + element._hoveredScrollbar = sm_state._hoveredScrollbar + element._scrollbarDragOffset = sm_state._scrollbarDragOffset or 0 + + local sm = element._scrollManager + if sm then + sm._scrollbarHoveredVertical = element._scrollbarHoveredVertical + sm._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal + sm._scrollbarDragging = element._scrollbarDragging + sm._hoveredScrollbar = element._hoveredScrollbar + sm._scrollbarDragOffset = element._scrollbarDragOffset + sm._dragStartMouseX = sm_state._dragStartMouseX or 0 + sm._dragStartMouseY = sm_state._dragStartMouseY or 0 + sm._dragStartScrollX = sm_state._dragStartScrollX or 0 + sm._dragStartScrollY = sm_state._dragStartScrollY or 0 + end +end + +--- Update hover, drag, and press interaction for scrollbars during Element:update. +---@param element table Element instance +---@param mx number Mouse X +---@param my number Mouse Y +function ScrollManager.updateInteraction(element, mx, my) + local sm = element._scrollManager + if sm then + sm:updateHoverState(element, mx, my) + ScrollManager.syncToElement(element) + end + + if element._scrollbarDragging and love.mouse.isDown(1) then + ScrollManager._handleScrollbarDrag(element, mx, my) + elseif element._scrollbarDragging then + if sm then + sm:handleMouseRelease(1) + ScrollManager.syncToElement(element) + end + if element._stateId and ScrollManager._StateManager.isImmediateMode() then + ScrollManager._StateManager.updateState(element._stateId, { + scrollbarDragging = false, + }) + end + end + + -- Handle scrollbar press for elements with scrollable overflow + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + local hasScrollableOverflow = ( + overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" + ) + + if hasScrollableOverflow and not element._scrollbarDragging then + if love.mouse.isDown(1) and not element._scrollbarPressHandled then + local scrollbarPressed = ScrollManager._handleScrollbarPress(element, mx, my, 1) + if scrollbarPressed then + element._scrollbarPressHandled = true + end + elseif not love.mouse.isDown(1) then + element._scrollbarPressHandled = false + end + end +end + +return ScrollManager diff --git a/libs/flexlove/modules/Select.lua b/libs/flexlove/modules/Select.lua new file mode 100644 index 00000000..21de8ecd --- /dev/null +++ b/libs/flexlove/modules/Select.lua @@ -0,0 +1,719 @@ +---@class Select +local Select = {} + +---Initialize Select module with required dependencies +---@param deps table +function Select.init(deps) + Select._ErrorHandler = deps.ErrorHandler + Select._Context = deps.Context + Select._StateManager = deps.StateManager + Select._utils = deps.utils + Select._Element = deps.Element +end + +---Initialize selectParent state on an element +---@param element Element +---@param selectParentConfig table +function Select.initSelectParent(element, selectParentConfig) + element._selectState = { + value = selectParentConfig.value, + open = selectParentConfig.open or false, + placeholder = selectParentConfig.placeholder, + selectFrame = nil, + selectAnchor = nil, + onChange = selectParentConfig.onChange, + options = {}, + optionLookup = {}, + expectedFrameParent = nil, + frameAdopted = false, + } + + -- Restore select state from StateManager. Mode-aware via + -- Context.isImmediateMode (behavior-mode-unification task 11). + if Select._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then + local state = Select._StateManager.getState(element._stateId) + if state and state._selectOpen ~= nil then + element._selectState.open = state._selectOpen + end + if state and state._selectValue ~= nil then + element._selectState.value = state._selectValue + if element.selectParent then + element.selectParent.value = state._selectValue + end + end + if state and state._selectSelectedLabel ~= nil then + element._selectState.selectedLabel = state._selectSelectedLabel + end + end +end + +---Initialize selectOption on an element +---@param element Element +---@param selectOptionConfig table +function Select.initSelectOption(element, selectOptionConfig) + element.selectOption = { + value = selectOptionConfig.value, + label = selectOptionConfig.label or element.text, + disabled = selectOptionConfig.disabled or false, + } +end + +---@param selectParent Element +function Select.rebuildOptionLookup(selectParent) + if not selectParent or not selectParent._selectState then + return + end + + selectParent._selectState.optionLookup = {} + for _, optionElement in ipairs(selectParent._selectState.options) do + if optionElement and optionElement.selectOption then + selectParent._selectState.optionLookup[optionElement.selectOption.value] = optionElement + end + end +end + +---@param selectParent Element +function Select.syncOptionStates(selectParent) + if not selectParent or not selectParent._selectState then + return + end + + local selectedOption = nil + local selectedLabel = selectParent._selectState.selectedLabel + + for _, optionElement in ipairs(selectParent._selectState.options) do + local isSelected = optionElement.selectOption + and optionElement.selectOption.value == selectParent._selectState.value + optionElement._selectSelected = isSelected + optionElement.ariaChecked = isSelected + + if isSelected then + selectedOption = optionElement + selectedLabel = optionElement.selectOption.label or optionElement.text + end + end + + selectParent._selectState.selectedOption = selectedOption + selectParent._selectState.selectedLabel = selectedLabel +end + +---@param element Element +function Select.resetOptions(element) + if not element._selectState then + return + end + + element._selectState.options = {} + element._selectState.optionLookup = {} + element._selectState.selectedOption = nil +end + +---@param frame any +---@return boolean +function Select.isValidSelectFrame(frame) + local Element = Select._Element + return type(frame) == "table" and getmetatable(frame) == Element +end + +---@param element Element +---@param code string +---@param details table? +function Select.warnSelectFrame(element, code, details) + Select._ErrorHandler:warn("Element", code, details or { element = element.id }) +end + +---@param element Element +---@param frame Element +function Select.trackManagedFrame(element, frame) + element._selectState.selectFrame = frame + local expectedParent = element._selectState.selectAnchor or element + element._selectState.expectedFrameParent = expectedParent + element._selectState.frameAdopted = frame.parent == expectedParent + if frame._managedSelectBaseOpacity == nil then + frame._managedSelectBaseOpacity = frame.opacity + end + if frame._managedSelectBaseVisibility == nil then + frame._managedSelectBaseVisibility = frame.visibility or "visible" + end + if frame._managedSelectBaseDisabled == nil then + frame._managedSelectBaseDisabled = frame.disabled or false + end + frame._managedSelectOwner = element + frame._managedSelectFrame = true +end + +---@param element Element +---@return Element +function Select.getOrCreateManagedAnchor(element) + if element._selectState.selectAnchor then + return element._selectState.selectAnchor + end + + local Element = Select._Element + local anchor = Element.new({ + id = string.format("%s__select_anchor", element.id or "select"), + parent = element, + positioning = Select._utils.enums.Positioning.ABSOLUTE, + left = 0, + top = element:getBorderBoxHeight(), + width = element:getBorderBoxWidth(), + opacity = 1, + visibility = "hidden", + disabled = true, + }) + + anchor._managedSelectAnchor = true + anchor._managedSelectOwner = element + element._selectState.selectAnchor = anchor + return anchor +end + +---@param element Element +---@param frame Element +function Select.applyManagedFrameLayout(element, frame) + local anchor = Select.getOrCreateManagedAnchor(element) + local triggerBorderBoxWidth = element:getBorderBoxWidth() + anchor.left = 0 + anchor.top = element:getBorderBoxHeight() + anchor.width = triggerBorderBoxWidth + anchor.units.left = { value = 0, unit = "px" } + anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" } + anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" } + frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth + + frame.positioning = frame.positioning or Select._utils.enums.Positioning.RELATIVE + frame._explicitlyAbsolute = false + frame.left = nil + frame.top = nil + frame.right = nil + frame.bottom = nil + + if frame.parent ~= anchor then + frame:setParent(anchor) + end + + if frame.autosizing and frame.autosizing.width then + local contentWidth = frame:calculateAutoWidth() + frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right + frame.width = contentWidth + end + + if frame.parent == anchor then + anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth()) + anchor.units.width = { value = anchor.width, unit = "px" } + end + + element._selectState.expectedFrameParent = anchor + element._selectState.frameAdopted = frame.parent == anchor +end + +---@param element Element +---@param frame Element +function Select.adoptSelectFrame(element, frame) + if not element._selectState then + return + end + + if not Select.isValidSelectFrame(frame) then + Select.warnSelectFrame(element, "ELEM_007", { + element = element.id, + property = "selectParent.selectFrame", + got = type(frame), + }) + return + end + + if frame == element then + Select.warnSelectFrame(element, "ELEM_007", { + element = element.id, + property = "selectParent.selectFrame", + reason = "select cannot use itself as its managed frame", + }) + return + end + + local anchor = Select.getOrCreateManagedAnchor(element) + + if frame.parent and frame.parent ~= element and frame.parent ~= anchor then + Select.warnSelectFrame(element, "ELEM_008", { + element = element.id, + frame = frame.id, + parent = frame.parent.id, + }) + end + + Select.trackManagedFrame(element, frame) + Select.applyManagedFrameLayout(element, frame) + Select.syncManagedFrameVisibility(element) + + -- Layout is deferred to endFrame in immediate mode. shouldLayout() + -- encapsulates the mode check (behavior-mode-unification task 11). + if Select._StateManager.shouldLayout() then + anchor:layoutChildren() + element:layoutChildren() + end + + local pendingOptions = {} + for _, child in ipairs(element.children) do + if child ~= frame and child.selectOption then + table.insert(pendingOptions, child) + end + end + + for _, option in ipairs(pendingOptions) do + Select.attachOptionToManagedFrame(option) + end +end + +---@param element Element +function Select.ensureFrameState(element) + if not element._selectState or not element._selectState.selectFrame then + return + end + + local frame = element._selectState.selectFrame + local anchor = element._selectState.selectAnchor + if anchor then + local triggerBorderBoxWidth = element:getBorderBoxWidth() + anchor.left = 0 + anchor.top = element:getBorderBoxHeight() + anchor.width = triggerBorderBoxWidth + anchor.units.left = { value = 0, unit = "px" } + anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" } + anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" } + frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth + if frame.autosizing and frame.autosizing.width then + local contentWidth = frame:calculateAutoWidth() + frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right + frame.width = contentWidth + end + if frame.parent == anchor then + anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth()) + anchor.units.width = { value = anchor.width, unit = "px" } + end + if frame.parent == anchor then + anchor:layoutChildren() + end + elseif frame.parent == element then + Select.applyManagedFrameLayout(element, frame) + end + + local expectedParent = anchor or element._selectState.expectedFrameParent + if frame.parent ~= expectedParent then + Select.warnSelectFrame(element, "ELEM_009", { + element = element.id, + frame = frame.id, + expectedParent = expectedParent and expectedParent.id or nil, + actualParent = frame.parent and frame.parent.id or nil, + }) + element._selectState.expectedFrameParent = frame.parent + element._selectState.frameAdopted = frame.parent == expectedParent + end +end + +---@param element Element +function Select.syncManagedFrameVisibility(element) + if not element._selectState or not element._selectState.selectFrame then + return + end + + local frame = element._selectState.selectFrame + local anchor = element._selectState.selectAnchor + local isOpen = element._selectState.open == true + frame.visibility = isOpen and (frame._managedSelectBaseVisibility or "visible") or "hidden" + frame.opacity = frame._managedSelectBaseOpacity or 1 + if isOpen then + frame.disabled = frame._managedSelectBaseDisabled == true + else + frame.disabled = true + end + if anchor then + anchor.visibility = isOpen and "visible" or "hidden" + anchor.opacity = 1 + anchor.disabled = not isOpen + end +end + +---@param element Element +---@return Element? +function Select.findOwningSelectParent(element) + if element._selectParentHint and element._selectParentHint._selectState then + return element._selectParentHint + end + + local current = element.parent + while current do + if current._selectState then + return current + end + current = current.parent + end + + return nil +end + +---@param element Element +function Select.registerWithSelectParent(element) + if not element.selectOption then + return + end + + local selectParent = Select.findOwningSelectParent(element) + if not selectParent then + return + end + + element._selectParentElement = selectParent + + for _, optionElement in ipairs(selectParent._selectState.options) do + if optionElement == element then + return + end + end + + table.insert(selectParent._selectState.options, element) + Select.rebuildOptionLookup(selectParent) + Select.syncOptionStates(selectParent) +end + +---@param element Element +function Select.attachOptionToManagedFrame(element) + if not element.selectOption then + return + end + + local selectParent = Select.findOwningSelectParent(element) + if not selectParent or not selectParent._selectState or not selectParent._selectState.selectFrame then + return + end + + local selectFrame = selectParent._selectState.selectFrame + if element.parent ~= selectFrame then + element._selectParentHint = selectParent + + if + element._originalPositioning == Select._utils.enums.Positioning.ABSOLUTE + and element._managedSelectOptionUsesFrameLayout == nil + then + element._managedSelectOptionUsesFrameLayout = true + element.positioning = Select._utils.enums.Positioning.RELATIVE + element._originalPositioning = nil + element._explicitlyAbsolute = false + element.left = nil + element.top = nil + element.right = nil + element.bottom = nil + end + + element:setParent(selectFrame) + -- Ensure frame geometry eagerly only in retained mode; deferred to the + -- per-frame update in immediate mode (behavior-mode-unification task 11). + if Select._StateManager.shouldLayout() then + Select.ensureFrameState(selectParent) + end + end +end + +---@param element Element +function Select.unregisterFromSelectParent(element) + if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then + element._selectParentElement = nil + return + end + + local selectParent = element._selectParentElement + for index, optionElement in ipairs(selectParent._selectState.options) do + if optionElement == element then + table.remove(selectParent._selectState.options, index) + break + end + end + + Select.rebuildOptionLookup(selectParent) + Select.syncOptionStates(selectParent) + element._selectParentElement = nil +end + +---@param element Element +function Select.saveStateToStateManager(element) + if not element._selectState then + return + end + if element._stateId and Select._Context.isImmediateMode() and element._stateId ~= "" then + Select._StateManager.updateState(element._stateId, { + _selectOpen = element._selectState.open, + _selectValue = element._selectState.value, + _selectSelectedLabel = element._selectState.selectedLabel, + }) + end +end + +---@param element Element +function Select.openSelect(element) + if not element._selectState then + return + end + + Select.ensureFrameState(element) + element._selectState.open = true + element.ariaExpanded = true + if element.selectParent then + element.selectParent.open = true + end + Select.syncManagedFrameVisibility(element) + Select.saveStateToStateManager(element) +end + +---@param element Element +function Select.closeSelect(element) + if not element._selectState then + return + end + + Select.ensureFrameState(element) + element._selectState.open = false + element.ariaExpanded = false + if element.selectParent then + element.selectParent.open = false + end + Select.syncManagedFrameVisibility(element) + Select.saveStateToStateManager(element) +end + +---@param element Element +function Select.toggleSelect(element) + if not element._selectState then + return + end + + if element.disabled then + return + end + + if element._selectState.open then + Select.closeSelect(element) + else + Select.openSelect(element) + end + + if element.onEvent then + element.onEvent(element, { type = "selecttoggle", open = element._selectState.open }) + end +end + +---@param element Element +---@return boolean +function Select.isSelectOpen(element) + return element._selectState ~= nil and element._selectState.open == true +end + +---@param element Element +---@return any +function Select.getSelectValue(element) + if not element._selectState then + return nil + end + return element._selectState.value +end + +---@param element Element +---@return string? +function Select.getSelectLabel(element) + if not element._selectState then + return nil + end + + local selectedOption = element._selectState.selectedOption + or element._selectState.optionLookup[element._selectState.value] + if selectedOption and selectedOption.selectOption then + return selectedOption.selectOption.label or selectedOption.text + end + + return element._selectState.selectedLabel or element._selectState.placeholder +end + +---@param element Element +---@return boolean +function Select.isSelectedOption(element) + if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then + return false + end + return element._selectParentElement._selectState.value == element.selectOption.value +end + +---@param element Element +---@param value any +---@param optionElement Element? +function Select.setSelectValue(element, value, optionElement) + if not element._selectState then + return + end + + if element.disabled then + return + end + + local didChange = element._selectState.value ~= value + element._selectState.value = value + if element.selectParent then + element.selectParent.value = value + end + + if optionElement and optionElement.selectOption then + element._selectState.selectedLabel = optionElement.selectOption.label or optionElement.text + end + + Select.syncOptionStates(element) + Select.closeSelect(element) + Select.saveStateToStateManager(element) + + if element.onEvent then + element.onEvent(element, { type = "selectchange", value = value, option = optionElement }) + end + + if didChange and element._selectState.onChange then + element._selectState.onChange(element, value, optionElement and optionElement.selectOption or nil) + end +end + +---@param element Element +function Select.handleRelease(element) + if element.disabled then + return + end + + if element.selectOption then + local selectParent = element._selectParentElement or Select.findOwningSelectParent(element) + if not selectParent then + return + end + + if element.selectOption.disabled then + Select.closeSelect(selectParent) + return + end + + Select.setSelectValue(selectParent, element.selectOption.value, element) + return + end + + if element._selectState then + Select.toggleSelect(element) + end +end + +---Save select state for state persistence (called from Element:saveState) +---@param element Element +---@return table? +function Select.saveState(element) + if not element._selectState then + return nil + end + return { + value = element._selectState.value, + open = element._selectState.open, + selectedLabel = element._selectState.selectedLabel, + } +end + +---Restore select state (called from Element:restoreState) +---@param element Element +---@param state table +function Select.restoreState(element, state) + if not element._selectState or not state then + return + end + element._selectState.value = state.value + element._selectState.open = state.open or false + element._selectState.selectedLabel = state.selectedLabel + if element.selectParent then + element.selectParent.value = state.value + element.selectParent.open = state.open or false + end + element.ariaExpanded = element._selectState.open + Select.syncOptionStates(element) +end + +---Clean up select-related resources (called from Element:destroy) +---@param element Element +function Select.cleanupDestroy(element) + if element._selectState then + local frame = element._selectState.selectFrame + local anchor = element._selectState.selectAnchor + if frame then + frame._managedSelectOwner = nil + frame._managedSelectFrame = nil + frame._managedSelectBaseOpacity = nil + frame._managedSelectBaseVisibility = nil + frame._managedSelectBaseDisabled = nil + end + if anchor then + anchor._managedSelectOwner = nil + anchor._managedSelectAnchor = nil + end + element._selectState = nil + end + if element._managedSelectFrame and element._managedSelectOwner then + if element._managedSelectOwner._selectState then + element._managedSelectOwner._selectState.selectFrame = nil + element._managedSelectOwner._selectState.expectedFrameParent = nil + element._managedSelectOwner._selectState.frameAdopted = false + end + element._managedSelectOwner = nil + element._managedSelectFrame = nil + element._managedSelectBaseOpacity = nil + element._managedSelectBaseVisibility = nil + element._managedSelectBaseDisabled = nil + end + if element._managedSelectAnchor and element._managedSelectOwner then + if element._managedSelectOwner._selectState then + element._managedSelectOwner._selectState.selectAnchor = nil + end + element._managedSelectOwner = nil + element._managedSelectAnchor = nil + end + if element.selectParent then + element.selectParent.onChange = nil + end +end + +--- Called when a select parent removes a child: clears frame/anchor refs if the removed child was the +--- select-managed frame or anchor. Keeps select state-mutation logic owned by the Select module. +---@param element Element The select parent whose child was removed. +---@param child Element The removed child. +function Select.handleChildRemoved(element, child) + if not element._selectState then + return + end + if element._selectState.selectFrame == child then + element._selectState.selectFrame = nil + element._selectState.expectedFrameParent = nil + element._selectState.frameAdopted = false + end + if element._selectState.selectAnchor == child then + element._selectState.selectAnchor = nil + end +end + +--- Layout-path hook: adjust an auto-width child's border-box width for a managed-select frame. +--- Invoked from LayoutEngine (via the Element delegate) during vertical-flex auto-width calculation. +---@param element Element The managed-select frame (the dropdown container). +---@param child Element The flex child being measured. +---@param childBorderBoxWidth number Current computed border-box width of `child`. +---@return number Possibly-adjusted border-box width. +function Select.adjustAutoWidthChild(element, child, childBorderBoxWidth) + if + element._managedSelectFrame + and element.autosizing + and element.autosizing.width + and child.units + and child.units.width + and child.units.width.unit == "%" + then + local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right + return math.max(childBorderBoxWidth, intrinsicBorderBoxWidth) + end + return childBorderBoxWidth +end + +return Select diff --git a/libs/flexlove/modules/StateManager.lua b/libs/flexlove/modules/StateManager.lua new file mode 100644 index 00000000..3a7b2af7 --- /dev/null +++ b/libs/flexlove/modules/StateManager.lua @@ -0,0 +1,790 @@ +---@class StateManager +local StateManager = {} + +-- ErrorHandler will be injected via init +local ErrorHandler + +-- State storage: ID -> state table +local stateStore = {} + +-- Frame tracking metadata: ID -> {lastFrame, createdFrame, accessCount} +local stateMetadata = {} + +-- Frame counter +local frameNumber = 0 + +-- Counter to track multiple elements created at the same source location (e.g., in loops) +local callSiteCounters = {} + +-- Stateful element mapping: stateId -> element instance +-- Used in retained mode for cache-through: StateManager resolves id -> element -> field +local statefulElements = {} + +-- Dirty state tracking for flushFrame: set of {id, key} pairs modified this frame +local dirtyState = {} + +-- Immediate mode flag +local _immediateMode = false + +-- Configuration +local config = { + stateRetentionFrames = 2, -- Keep unused state for 2 frames + maxStateEntries = 1000, -- Maximum state entries before forced GC +} + +-- Default state values (sparse storage - don't store these) +local stateDefaults = { + -- Interaction states + hover = false, + pressed = false, + focused = false, + disabled = false, + active = false, + + -- Scrollbar states + scrollbarHoveredVertical = false, + scrollbarHoveredHorizontal = false, + scrollbarDragging = false, + hoveredScrollbar = nil, + scrollbarDragOffset = 0, + dragStartMouseX = 0, + dragStartMouseY = 0, + dragStartScrollX = 0, + dragStartScrollY = 0, + + -- Scroll position + scrollX = 0, + scrollY = 0, + _scrollX = 0, + _scrollY = 0, + + -- Click tracking + _clickCount = 0, + _lastClickTime = nil, + _lastClickButton = nil, + + -- Internal states + _hovered = nil, + _focused = nil, + _cursorPosition = nil, + _selectionStart = nil, + _selectionEnd = nil, + _textBuffer = "", + _cursorBlinkTimer = 0, + _cursorVisible = true, + _cursorBlinkPaused = false, + _cursorBlinkPauseTimer = 0, +} + +--- Check if a value equals the default for a key +---@param key string State key +---@param value any Value to check +---@return boolean isDefault True if value equals default +local function isDefaultValue(key, value) + local defaultVal = stateDefaults[key] + + -- If no default defined, check for common defaults + if defaultVal == nil then + -- Empty tables are default + if type(value) == "table" and next(value) == nil then + return true + end + -- nil values are default + if value == nil then + return true + end + -- Otherwise, not a default value + return false + end + + -- Compare values + if type(value) == "table" then + -- Empty tables are considered default + if next(value) == nil then + return true + end + -- For other tables, compare contents (shallow) + if type(defaultVal) ~= "table" then + return false + end + for k, v in pairs(value) do + if defaultVal[k] ~= v then + return false + end + end + return true + else + return value == defaultVal + end +end + +-- ==================== +-- ID Generation +-- ==================== + +--- Generate a hash from a table of properties +---@param props table +---@param visited table|nil Tracking table to prevent circular references +---@param depth number|nil Current recursion depth +---@return string +local function hashProps(props, visited, depth) + if not props then + return "" + end + + -- Initialize visited table on first call + visited = visited or {} + depth = depth or 0 + + -- Limit recursion depth to prevent deep nesting issues + if depth > 3 then + return "[deep]" + end + + -- Check if we've already visited this table (circular reference) + if visited[props] then + return "[circular]" + end + + -- Mark this table as visited + visited[props] = true + + local parts = {} + local keys = {} + + -- Properties to skip (they cause issues or aren't relevant for ID generation) + local skipKeys = { + onEvent = true, + parent = true, + children = true, + onFocus = true, + onBlur = true, + onTextInput = true, + onTextChange = true, + onEnter = true, + userdata = true, + -- Dynamic input/state properties that should not affect ID stability + text = true, -- Text content changes as user types + placeholder = true, -- Placeholder text is presentational + editable = true, -- Editable state can be toggled dynamically + selectOnFocus = true, -- Input behavior flag + autoGrow = true, -- Auto-grow behavior flag + passwordMode = true, -- Password mode can be toggled + } + + -- Collect and sort keys for consistent ordering + for k in pairs(props) do + if not skipKeys[k] then + table.insert(keys, k) + end + end + table.sort(keys) + + -- Build hash string from sorted key-value pairs + for _, k in ipairs(keys) do + local v = props[k] + local vtype = type(v) + + if vtype == "string" or vtype == "number" or vtype == "boolean" then + table.insert(parts, k .. "=" .. tostring(v)) + elseif vtype == "table" then + table.insert(parts, k .. "={" .. hashProps(v, visited, depth + 1) .. "}") + end + end + + return table.concat(parts, ";") +end + +--- Generate a unique ID from call site and properties +---@param props table|nil Optional properties to include in ID generation +---@param parent table|nil Optional parent element for tree-based ID generation +---@return string +function StateManager.generateID(props, parent) + -- Get call stack information + local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID + + if not info then + -- Fallback to random ID if debug info unavailable + return "auto_" .. tostring(math.random(1000000, 9999999)) + end + + local source = info.source or "unknown" + local line = info.currentline or 0 + + -- Create base location key from source file and line number + local filename = source:match("([^/\\]+)$") or source -- Get filename + filename = filename:gsub("%.lua$", "") -- Remove .lua extension + local locationKey = filename .. "_L" .. line + + -- If we have a parent, use tree-based ID generation for stability + if parent and parent.id and parent.id ~= "" then + -- For child elements, use call-site (file + line) like top-level elements + -- This ensures the same call site always generates the same ID, even when + -- retained children persist in parent.children array + local baseID = parent.id .. "_" .. locationKey + + -- Count how many children have been created at THIS call site + local callSiteKey = parent.id .. "_" .. locationKey + callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1 + local instanceNum = callSiteCounters[callSiteKey] + + if instanceNum > 1 then + baseID = baseID .. "_" .. instanceNum + end + + -- Add property hash if provided (for additional differentiation) + if props then + local propHash = hashProps(props) + if propHash ~= "" then + -- Use first 8 chars of a simple hash + local hash = 0 + for i = 1, #propHash do + hash = (hash * 31 + string.byte(propHash, i)) % 1000000 + end + baseID = baseID .. "_" .. hash + end + end + + return baseID + end + + -- No parent (top-level element): use call-site counter approach + -- Track how many elements have been created at this location + callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1 + local instanceNum = callSiteCounters[locationKey] + + local baseID = locationKey + + -- Add instance number if multiple elements created at same location (e.g., in loops) + if instanceNum > 1 then + baseID = baseID .. "_" .. instanceNum + end + + -- Add property hash if provided (for additional differentiation) + if props then + local propHash = hashProps(props) + if propHash ~= "" then + -- Use first 8 chars of a simple hash + local hash = 0 + for i = 1, #propHash do + hash = (hash * 31 + string.byte(propHash, i)) % 1000000 + end + baseID = baseID .. "_" .. hash + end + end + + return baseID +end + +-- ==================== +-- State Management +-- ==================== + +--- Initialize StateManager with dependencies +---@param deps table Dependencies: { ErrorHandler = ErrorHandler } +function StateManager.init(deps) + if type(deps) == "table" then + ErrorHandler = deps.ErrorHandler + end +end + +--- Get state for an element ID, creating if it doesn't exist +---@param id string Element ID +---@param defaultState table|nil Default state if creating new +---@return table state State table for the element +function StateManager.getState(id, defaultState) + if not id then + ErrorHandler:error("StateManager", "SYS_001", { + parameter = "id", + value = "nil", + }) + end + + -- Create state if it doesn't exist + if not stateStore[id] then + -- Start with empty state (sparse storage) + stateStore[id] = defaultState or {} + + -- Create metadata + stateMetadata[id] = { + lastFrame = frameNumber, + createdFrame = frameNumber, + accessCount = 0, + } + else + -- Update metadata + local meta = stateMetadata[id] + meta.lastFrame = frameNumber + meta.accessCount = meta.accessCount + 1 + end + + return stateStore[id] +end + +--- Set state for an element ID (replaces entire state) +---@param id string Element ID +---@param state table State to store +function StateManager.setState(id, state) + if not id then + ErrorHandler:error("StateManager", "SYS_001", { + parameter = "id", + value = "nil", + }) + end + + -- Create sparse state (remove default values) + local sparseState = {} + for key, value in pairs(state) do + if not isDefaultValue(key, value) then + sparseState[key] = value + end + end + + stateStore[id] = sparseState + + -- Update or create metadata + if not stateMetadata[id] then + stateMetadata[id] = { + lastFrame = frameNumber, + createdFrame = frameNumber, + accessCount = 1, + } + else + stateMetadata[id].lastFrame = frameNumber + end +end + +--- Update state for an element ID (merges with existing state) +---@param id string Element ID +---@param newState table New state values to merge +function StateManager.updateState(id, newState) + local state = StateManager.getState(id) + + -- Merge new state into existing state (with diffing optimization) + local changed = false + for key, value in pairs(newState) do + if state[key] ~= value then + state[key] = value + changed = true + end + end + + -- Only update metadata if something actually changed + if changed then + stateMetadata[id].lastFrame = frameNumber + end +end + +--- Update state only if values have changed (optimized for immediate mode) +---@param id string Element ID +---@param newState table New state values to merge +---@return boolean changed True if any values changed +function StateManager.updateStateIfChanged(id, newState) + local state = StateManager.getState(id) + local changed = false + + for key, value in pairs(newState) do + -- Skip if value hasn't changed (optimization) + if state[key] ~= value then + state[key] = value + changed = true + end + end + + if changed then + stateMetadata[id].lastFrame = frameNumber + end + + return changed +end + +--- Clear state for a specific element ID +---@param id string Element ID +function StateManager.clearState(id) + stateStore[id] = nil + stateMetadata[id] = nil +end + +--- Mark state as used this frame (updates last accessed frame) +---@param id string Element ID +function StateManager.markStateUsed(id) + if stateMetadata[id] then + stateMetadata[id].lastFrame = frameNumber + end +end + +-- ==================== +-- Frame Management +-- ==================== + +--- Increment frame counter (called at frame start) +function StateManager.incrementFrame() + frameNumber = frameNumber + 1 + -- Reset call site counters for new frame + callSiteCounters = {} +end + +--- Get current frame number +---@return number +function StateManager.getFrameNumber() + return frameNumber +end + +-- ==================== +-- Granular State Access (Unified API for both modes) +-- ==================== + +--- Get a single state value by key for a given element ID. +--- Works identically in both modes — the caller does not need to know the mode. +--- +--- Immediate mode: reads from persistent state store. +--- Retained mode: resolves through registered element field (cache-through). +--- +---@param id string Element state ID +---@param key string State key +---@return any value The stored value, or nil if not found +function StateManager.getStateValue(id, key) + if not id or not key then + ErrorHandler:error("StateManager", "SYS_001", { + parameter = "id and key", + value = "missing", + }) + end + + -- Update metadata for access tracking + if stateMetadata[id] then + stateMetadata[id].lastFrame = frameNumber + stateMetadata[id].accessCount = stateMetadata[id].accessCount + 1 + end + + if _immediateMode then + -- Immediate mode: read from persistent state store + local state = stateStore[id] + if state then + return state[key] + end + return nil + else + -- Retained mode: resolve through element field + local element = statefulElements[id] + if element then + return element[key] + end + return nil + end +end + +--- Set a single state value by key for a given element ID. +--- Works identically in both modes — the caller does not need to know the mode. +--- +--- Immediate mode: marks dirty for flushFrame() persistence. +--- Retained mode: writes directly to element field (cache-through). +--- +---@param id string Element state ID +---@param key string State key +---@param value any Value to store +function StateManager.setStateValue(id, key, value) + if not id or not key then + ErrorHandler:error("StateManager", "SYS_001", { + parameter = "id and key", + value = "missing", + }) + end + + -- Update metadata + if not stateMetadata[id] then + stateMetadata[id] = { + lastFrame = frameNumber, + createdFrame = frameNumber, + accessCount = 1, + } + else + stateMetadata[id].lastFrame = frameNumber + end + + if _immediateMode then + -- Immediate mode: mark dirty for flushFrame persistence + local state = StateManager.getState(id) + state[key] = value + dirtyState[id] = dirtyState[id] or {} + dirtyState[id][key] = true + else + -- Retained mode: write directly to element field + local element = statefulElements[id] + if element then + element[key] = value + end + end +end + +-- ==================== +-- Stateful Element Registration (Retained Mode Cache-Through) +-- ==================== + +--- Register an element instance for retained-mode cache-through. +--- After registration, getStateValue/setStateValue will resolve through the element's fields. +--- +--- Called by Element in _construct phase. +--- +---@param id string State ID (typically element.id) +---@param element table Element instance to link +function StateManager.registerStateful(id, element) + if not id or not element then + return + end + statefulElements[id] = element +end + +--- Unregister an element instance. +--- After unregistration, retained-mode access will fall back to nil. +--- +--- Called by Element in _cleanup phase. +--- +---@param id string State ID to unregister +function StateManager.unregisterStateful(id) + if id then + statefulElements[id] = nil + end +end + +-- ==================== +-- Frame Flush (Immediate Mode Dirty State Persistence) +-- ==================== + +--- Flush dirty state to persistent store at end of frame. +--- Called automatically at frame end in immediate mode. +--- Behaviors call setStateValue during update without knowing the mode. +--- +--- In retained mode, this is a no-op (state is written directly to elements). +function StateManager.flushFrame() + if not _immediateMode then + return + end + + -- All dirty writes were already applied to stateStore during setStateValue + -- This method exists for future extensions (e.g., batching, analytics) + -- Reset dirty tracking for next frame + dirtyState = {} +end + +-- ==================== +-- Mode Configuration +-- ==================== + +--- Configure immediate mode state. +--- Called by Context when immediate mode is enabled/disabled. +--- +---@param enabled boolean Whether immediate mode is active +function StateManager.setImmediateMode(enabled) + _immediateMode = enabled +end + +--- Check if immediate mode is active. +---@return boolean +function StateManager.isImmediateMode() + return _immediateMode +end + +--- Whether at-construction layout / eager initialization should run now. +--- Returns true in retained mode (layout eagerly), false in immediate mode +--- (layout is deferred to `FlexLove.endFrame` / FlexLove so it runs once all +--- elements for the frame have been created). This replaces the scattered +--- `if not _immediateMode then layoutChildren()` mode checks with a single +--- mode-aware query (behavior-mode-unification task 11). +---@return boolean +function StateManager.shouldLayout() + return not _immediateMode +end + +-- ==================== +-- Cleanup & Maintenance +-- ==================== + +--- Clean up stale states (not accessed recently) +---@return number count Number of states cleaned up +function StateManager.cleanup() + local cleanedCount = 0 + local retentionFrames = config.stateRetentionFrames + + for id, meta in pairs(stateMetadata) do + local framesSinceAccess = frameNumber - meta.lastFrame + + if framesSinceAccess > retentionFrames then + stateStore[id] = nil + stateMetadata[id] = nil + cleanedCount = cleanedCount + 1 + end + end + + -- Clean up empty states (sparse storage optimization) + for id, state in pairs(stateStore) do + if next(state) == nil then + stateStore[id] = nil + stateMetadata[id] = nil + cleanedCount = cleanedCount + 1 + end + end + + return cleanedCount +end + +--- Force cleanup if state count exceeds maximum +---@return number count Number of states cleaned up +function StateManager.forceCleanupIfNeeded() + local stateCount = StateManager.getStateCount() + + if stateCount > config.maxStateEntries then + -- Clean up states not accessed in last 10 frames (aggressive) + local cleanedCount = 0 + + for id, meta in pairs(stateMetadata) do + local framesSinceAccess = frameNumber - meta.lastFrame + + if framesSinceAccess > 10 then + stateStore[id] = nil + stateMetadata[id] = nil + cleanedCount = cleanedCount + 1 + end + end + + return cleanedCount + end + + return 0 +end + +--- Get total number of stored states +---@return number +function StateManager.getStateCount() + local count = 0 + for _ in pairs(stateStore) do + count = count + 1 + end + return count +end + +--- Clear all states +function StateManager.clearAllStates() + stateStore = {} + stateMetadata = {} +end + +--- Configure state management +---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number} +function StateManager.configure(newConfig) + if newConfig.stateRetentionFrames then + config.stateRetentionFrames = newConfig.stateRetentionFrames + end + if newConfig.maxStateEntries then + config.maxStateEntries = newConfig.maxStateEntries + end +end + +--- Get state statistics for debugging +---@return table stats State usage statistics +function StateManager.getStats() + local stateCount = StateManager.getStateCount() + local oldest = nil + local newest = nil + + for _, meta in pairs(stateMetadata) do + if not oldest or meta.createdFrame < oldest then + oldest = meta.createdFrame + end + if not newest or meta.createdFrame > newest then + newest = meta.createdFrame + end + end + + -- Count callSiteCounters + local callSiteCount = 0 + for _ in pairs(callSiteCounters) do + callSiteCount = callSiteCount + 1 + end + + -- Warn if callSiteCounters is unexpectedly large + if callSiteCount > 1000 then + if ErrorHandler then + ErrorHandler.warn("StateManager", "STATE_001", { + count = callSiteCount, + expected = "near 0", + frameNumber = frameNumber, + }) + end + end + + return { + stateCount = stateCount, + frameNumber = frameNumber, + oldestState = oldest, + newestState = newest, + callSiteCounterCount = callSiteCount, + } +end + +--- Get internal state (for debugging/profiling only) +---@return table internal {stateStore, stateMetadata, callSiteCounters} +function StateManager._getInternalState() + return { + stateStore = stateStore, + stateMetadata = stateMetadata, + callSiteCounters = callSiteCounters, + } +end + +--- Reset the entire state system (for testing) +function StateManager.reset() + stateStore = {} + stateMetadata = {} + frameNumber = 0 + callSiteCounters = {} + statefulElements = {} + dirtyState = {} + _immediateMode = false +end + +-- ==================== +-- Convenience Functions (for backward compatibility) +-- ==================== + +--- Check if an element is currently hovered +---@param id string Element ID +---@return boolean +function StateManager.isHovered(id) + local state = StateManager.getState(id) + return state.hover or false +end + +--- Check if an element is currently pressed +---@param id string Element ID +---@return boolean +function StateManager.isPressed(id) + local state = StateManager.getState(id) + return state.pressed or false +end + +--- Check if an element is currently focused +---@param id string Element ID +---@return boolean +function StateManager.isFocused(id) + local state = StateManager.getState(id) + return state.focused or false +end + +--- Check if an element is disabled +---@param id string Element ID +---@return boolean +function StateManager.isDisabled(id) + local state = StateManager.getState(id) + return state.disabled or false +end + +--- Check if an element is active (e.g., input focused) +---@param id string Element ID +---@return boolean +function StateManager.isActive(id) + local state = StateManager.getState(id) + return state.active or false +end + +return StateManager diff --git a/libs/flexlove/modules/TextEditor.lua b/libs/flexlove/modules/TextEditor.lua new file mode 100644 index 00000000..35af6354 --- /dev/null +++ b/libs/flexlove/modules/TextEditor.lua @@ -0,0 +1,1783 @@ +local UTF8 = require((...):match("(.-)[^%.]+$") .. "UTF8") +local utf8 = UTF8 + +---@class TextEditor +---@field editable boolean +---@field multiline boolean +---@field passwordMode boolean +---@field textWrap boolean|"word"|"char" +---@field maxLines number? +---@field maxLength number? +---@field placeholder string? +---@field inputType "text"|"number"|"email"|"url" +---@field textOverflow "clip"|"ellipsis"|"scroll" +---@field scrollable boolean +---@field autoGrow boolean +---@field selectOnFocus boolean +---@field sanitize boolean +---@field allowNewlines boolean +---@field allowTabs boolean +---@field customSanitizer function? +---@field cursorColor Color? +---@field selectionColor Color? +---@field cursorBlinkRate number +---@field _textBuffer string +---@field _lines table? +---@field _wrappedLines table? +---@field _textDirty boolean +---@field _cursorPosition number +---@field _cursorLine number +---@field _cursorColumn number +---@field _cursorBlinkTimer number +---@field _cursorVisible boolean +---@field _cursorBlinkPaused boolean +---@field _cursorBlinkPauseTimer number +---@field _selectionStart number? +---@field _selectionEnd number? +---@field _selectionAnchor number? +---@field _focused boolean +---@field _textScrollX number +---@field onFocus fun(element:Element)? +---@field onBlur fun(element:Element)? +---@field onTextInput fun(element:Element, text:string)? +---@field onTextChange fun(element:Element, text:string)? +---@field onEnter fun(element:Element)? +---@field onSanitize fun(element:Element, original:string, sanitized:string)? +---@field _Context table +---@field _StateManager table +---@field _Color table +---@field _FONT_CACHE table +---@field _getModifiers function +---@field _utils table +---@field _textDragOccurred boolean? +local TextEditor = {} +TextEditor.__index = TextEditor + +---@class TextEditorConfig +---@field editable boolean -- Whether text is editable +---@field multiline boolean -- Whether multi-line is supported +---@field passwordMode boolean -- Whether to mask text +---@field textWrap boolean|"word"|"char" -- Text wrapping mode +---@field maxLines number? -- Maximum number of lines +---@field maxLength number? -- Maximum text length in characters +---@field placeholder string? -- Placeholder text when empty +---@field inputType "text"|"number"|"email"|"url" -- Input validation type +---@field textOverflow "clip"|"ellipsis"|"scroll" -- Text overflow behavior +---@field scrollable boolean -- Whether text is scrollable +---@field autoGrow boolean -- Whether element auto-grows with text +---@field selectOnFocus boolean -- Whether to select all text on focus +---@field sanitize boolean? -- Whether to sanitize text input (default: true) +---@field allowNewlines boolean? -- Whether to allow newline characters (default: true in multiline) +---@field allowTabs boolean? -- Whether to allow tab characters (default: true) +---@field customSanitizer function? -- Custom sanitization function +---@field cursorColor Color? -- Cursor color +---@field selectionColor Color? -- Selection background color +---@field cursorBlinkRate number -- Cursor blink rate in seconds + +---Create a new TextEditor instance +---@param config TextEditorConfig +---@param deps table Dependencies {Context, StateManager, Color, utils} +---@return table TextEditor instance +function TextEditor.new(config, deps) + local self = setmetatable({}, TextEditor) + + -- Store dependencies + self._Context = deps.Context + self._StateManager = deps.StateManager + self._Color = deps.Color + self._FONT_CACHE = deps.utils.FONT_CACHE + self._getModifiers = deps.utils.getModifiers + self._utils = deps.utils + + -- Store configuration + self.editable = config.editable or false + self.multiline = config.multiline or false + self.passwordMode = config.passwordMode or false + self.textWrap = config.textWrap + self.maxLines = config.maxLines + self.maxLength = config.maxLength + self.placeholder = config.placeholder + self.inputType = config.inputType or "text" + self.textOverflow = config.textOverflow or "clip" + self.scrollable = config.scrollable + self.autoGrow = config.autoGrow + self.selectOnFocus = config.selectOnFocus or false + self.cursorColor = config.cursorColor + self.selectionColor = config.selectionColor + self.cursorBlinkRate = config.cursorBlinkRate or 0.5 + + -- Sanitization configuration + self.sanitize = config.sanitize ~= false -- Default to true + -- If allowNewlines is explicitly set, use that value; otherwise follow multiline setting + if config.allowNewlines ~= nil then + self.allowNewlines = config.allowNewlines + else + self.allowNewlines = self.multiline + end + self.allowTabs = config.allowTabs ~= false -- Default to true + self.customSanitizer = config.customSanitizer + + -- Initialize text buffer state (with sanitization) + local initialText = config.text or "" + self._textBuffer = self:_sanitizeText(initialText) + self._lines = nil + self._wrappedLines = nil + self._textDirty = true + + -- Initialize cursor state + self._cursorPosition = 0 + self._cursorLine = 1 + self._cursorColumn = 0 + self._cursorBlinkTimer = 0 + self._cursorVisible = true + self._cursorBlinkPaused = false + self._cursorBlinkPauseTimer = 0 + + -- Initialize selection state + self._selectionStart = nil + self._selectionEnd = nil + self._selectionAnchor = nil + + -- Initialize focus state + self._focused = false + + -- Initialize scroll state + self._textScrollX = 0 + + -- Store callbacks + self.onFocus = config.onFocus + self.onBlur = config.onBlur + self.onTextInput = config.onTextInput + self.onTextChange = config.onTextChange + self.onEnter = config.onEnter + self.onSanitize = config.onSanitize + + return self +end + +---Internal: Sanitize text input +---@param text string -- Text to sanitize +---@return string -- Sanitized text +function TextEditor:_sanitizeText(text) + if not self.sanitize then + return text + end + + -- Use custom sanitizer if provided + if self.customSanitizer then + return self.customSanitizer(text) or text + end + + local options = { + maxLength = self.maxLength, + allowNewlines = self.allowNewlines, + allowTabs = self.allowTabs, + trimWhitespace = false, -- Preserve whitespace in text editors + } + + local sanitized = self._utils.sanitizeText(text, options) + + return sanitized +end + +---Restore state from StateManager (for immediate mode) +---@param element table The parent Element instance +function TextEditor:restoreState(element) + -- Restore state from StateManager. Mode-aware via Context.isImmediateMode: + -- in retained mode the TextEditor persists between frames so nothing to + -- restore (behavior-mode-unification task 11). + if element._stateId and self._Context.isImmediateMode() then + local state = self._StateManager.getState(element._stateId) + if state then + if state._focused then + self._focused = true + self._Context.setFocused(element) + end + if state._textBuffer and state._textBuffer ~= "" then + self._textBuffer = state._textBuffer + end + if state._cursorPosition then + self._cursorPosition = state._cursorPosition + end + if state._selectionStart then + self._selectionStart = state._selectionStart + end + if state._selectionEnd then + self._selectionEnd = state._selectionEnd + end + if state._cursorBlinkTimer then + self._cursorBlinkTimer = state._cursorBlinkTimer + end + if state._cursorVisible ~= nil then + self._cursorVisible = state._cursorVisible + end + if state._cursorBlinkPaused ~= nil then + self._cursorBlinkPaused = state._cursorBlinkPaused + end + if state._cursorBlinkPauseTimer then + self._cursorBlinkPauseTimer = state._cursorBlinkPauseTimer + end + end + end +end + +-- ==================== +-- Text Buffer Management +-- ==================== + +---Get current text buffer +---@return string +function TextEditor:getText() + return self._textBuffer or "" +end + +---Set text buffer and mark dirty +---@param element Element? The parent element (for state saving) +---@param text string +---@param skipSanitization boolean? -- Skip sanitization (for trusted input) +function TextEditor:setText(element, text, skipSanitization) + text = text or "" + + -- Sanitize text unless explicitly skipped + if not skipSanitization then + local originalText = text + text = self:_sanitizeText(text) + + -- Trigger onSanitize callback if text was sanitized + if text ~= originalText and self.onSanitize and element then + self.onSanitize(element, originalText, text) + end + end + + self._textBuffer = text + self:_markTextDirty() + self:_updateTextIfDirty(element) + self:_validateCursorPosition() + self:_saveState(element) +end + +---Insert text at position +---@param element Element The parent element (for state saving) +---@param text string -- Text to insert +---@param position number? -- Position to insert at (default: cursor position) +---@param skipSanitization boolean? -- Skip sanitization (for internal use) +function TextEditor:insertText(element, text, position, skipSanitization) + position = position or self._cursorPosition + local buffer = self._textBuffer or "" + + -- Sanitize text unless explicitly skipped + if not skipSanitization then + text = self:_sanitizeText(text) + end + + -- Check if text is empty after sanitization + if not text or text == "" then + return + end + + -- Check maxLength constraint before inserting + if self.maxLength then + local currentLength = utf8.len(buffer) or 0 + local textLength = utf8.len(text) or 0 + local newLength = currentLength + textLength + + if newLength > self.maxLength then + -- Truncate text to fit + local remaining = self.maxLength - currentLength + if remaining <= 0 then + return + end + -- Truncate to remaining characters + local truncated = "" + local count = 0 + for _, code in utf8.codes(text) do + if count >= remaining then + break + end + truncated = truncated .. utf8.char(code) + count = count + 1 + end + text = truncated + end + end + + -- Convert character position to byte offset + local byteOffset = utf8.offset(buffer, position + 1) or (#buffer + 1) + + -- Insert text + local before = buffer:sub(1, byteOffset - 1) + local after = buffer:sub(byteOffset) + self._textBuffer = before .. text .. after + + self._cursorPosition = position + utf8.len(text) + + self:_markTextDirty() + self:_updateTextIfDirty(element) + self:_validateCursorPosition() + self:_resetCursorBlink(element, true) + self:_saveState(element) +end + +---Delete text in range +---@param element Element The parent element (for state saving) +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +function TextEditor:deleteText(element, startPos, endPos) + local buffer = self._textBuffer or "" + + -- Ensure valid range + local textLength = utf8.len(buffer) + startPos = math.max(0, math.min(startPos, textLength)) + endPos = math.max(0, math.min(endPos, textLength)) + + if startPos > endPos then + startPos, endPos = endPos, startPos + end + + -- Convert character positions to byte offsets + local startByte = utf8.offset(buffer, startPos + 1) or 1 + local endByte = utf8.offset(buffer, endPos + 1) or (#buffer + 1) + + -- Delete text + local before = buffer:sub(1, startByte - 1) + local after = buffer:sub(endByte) + self._textBuffer = before .. after + + self:_markTextDirty() + self:_updateTextIfDirty(element) + self:_resetCursorBlink(element, true) + self:_saveState(element) +end + +---Replace text in range +---@param element Element The parent element (for state saving) +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +---@param newText string -- Replacement text +function TextEditor:replaceText(element, startPos, endPos, newText) + self:deleteText(element, startPos, endPos) + self:insertText(element, newText, startPos) +end + +---Mark text as dirty (needs recalculation) +function TextEditor:_markTextDirty() + self._textDirty = true +end + +---Update text if dirty (recalculate lines and wrapping) +---@param element Element? The parent element (for wrapping calculations) +function TextEditor:_updateTextIfDirty(element) + if not self._textDirty then + return + end + + self:_splitLines() + self:_calculateWrapping(element) + self:_validateCursorPosition() + self._textDirty = false +end + +-- ==================== +-- Line Splitting and Wrapping +-- ==================== + +---Split text into lines (for multi-line text) +function TextEditor:_splitLines() + if not self.multiline then + self._lines = { self._textBuffer or "" } + return + end + + self._lines = {} + local text = self._textBuffer or "" + + -- Split on newlines + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(self._lines, line) + end + + -- Ensure at least one line + if #self._lines == 0 then + self._lines = { "" } + end +end + +---Calculate text wrapping +---@param element Element? The parent element +function TextEditor:_calculateWrapping(element) + if not self.textWrap or not element then + self._wrappedLines = nil + return + end + + self._wrappedLines = {} + local availableWidth = element.width - element.padding.left - element.padding.right + + for lineNum, line in ipairs(self._lines or {}) do + if line == "" then + table.insert(self._wrappedLines, { + text = "", + startIdx = 0, + endIdx = 0, + lineNum = lineNum, + }) + else + local wrappedParts = self:_wrapLine(element, line, availableWidth) + for _, part in ipairs(wrappedParts) do + part.lineNum = lineNum + table.insert(self._wrappedLines, part) + end + end + end +end + +---Wrap a single line of text +---@param element Element The parent element +---@param line string -- Line to wrap +---@param maxWidth number -- Maximum width in pixels +---@return table -- Array of wrapped line parts +function TextEditor:_wrapLine(element, line, maxWidth) + if not element then + return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } + end + + -- Delegate to Renderer + return element._renderer:wrapLine(element, line, maxWidth) +end + +-- ==================== +-- Cursor Management +-- ==================== + +---Set cursor position +---@param element Element? The parent element (for scroll updates) +---@param position number -- Character index (0-based) +function TextEditor:setCursorPosition(element, position) + self._cursorPosition = position + self:_validateCursorPosition() + self:_resetCursorBlink(element) +end + +---Get cursor position +---@return number -- Character index (0-based) +function TextEditor:getCursorPosition() + return self._cursorPosition +end + +---Move cursor by delta characters +---@param element Element? The parent element (for scroll updates) +---@param delta number -- Number of characters to move (positive or negative) +function TextEditor:moveCursorBy(element, delta) + self._cursorPosition = self._cursorPosition + delta + self:_validateCursorPosition() + self:_resetCursorBlink(element) +end + +---Move cursor to start of text +---@param element Element? The parent element (for scroll updates) +function TextEditor:moveCursorToStart(element) + self._cursorPosition = 0 + self:_resetCursorBlink(element) +end + +---Move cursor to end of text +---@param element Element? The parent element (for scroll updates) +function TextEditor:moveCursorToEnd(element) + local textLength = utf8.len(self._textBuffer or "") + self._cursorPosition = textLength + self:_resetCursorBlink(element) +end + +---Move cursor to start of current line +---@param element Element? The parent element (for scroll updates) +function TextEditor:moveCursorToLineStart(element) + -- For now, just move to start (will be enhanced for multi-line) + self:moveCursorToStart(element) +end + +---Move cursor to end of current line +---@param element Element? The parent element (for scroll updates) +function TextEditor:moveCursorToLineEnd(element) + -- For now, just move to end (will be enhanced for multi-line) + self:moveCursorToEnd(element) +end + +---Move cursor to start of previous word +function TextEditor:moveCursorToPreviousWord() + if not self._textBuffer then + return + end + + local text = self._textBuffer + local pos = self._cursorPosition + + if pos <= 0 then + return + end + + -- Helper function to get character at position + local function getCharAt(p) + if p < 0 or p >= utf8.len(text) then + return nil + end + local offset1 = utf8.offset(text, p + 1) + local offset2 = utf8.offset(text, p + 2) + if not offset1 then + return nil + end + if not offset2 then + return text:sub(offset1) + end + return text:sub(offset1, offset2 - 1) + end + + -- Skip any whitespace/punctuation before current position + while pos > 0 do + local char = getCharAt(pos - 1) + if char and char:match("[%w]") then + break + end + pos = pos - 1 + end + + -- Move to start of current word + while pos > 0 do + local char = getCharAt(pos - 1) + if not char or not char:match("[%w]") then + break + end + pos = pos - 1 + end + + self._cursorPosition = pos + self:_validateCursorPosition() +end + +---Move cursor to start of next word +function TextEditor:moveCursorToNextWord() + if not self._textBuffer then + return + end + + local text = self._textBuffer + local textLength = utf8.len(text) or 0 + local pos = self._cursorPosition + + if pos >= textLength then + return + end + + -- Helper function to get character at position + local function getCharAt(p) + if p < 0 or p >= textLength then + return nil + end + local offset1 = utf8.offset(text, p + 1) + local offset2 = utf8.offset(text, p + 2) + if not offset1 then + return nil + end + if not offset2 then + return text:sub(offset1) + end + return text:sub(offset1, offset2 - 1) + end + + -- Skip current word + while pos < textLength do + local char = getCharAt(pos) + if not char or not char:match("[%w]") then + break + end + pos = pos + 1 + end + + -- Skip any whitespace/punctuation + while pos < textLength do + local char = getCharAt(pos) + if char and char:match("[%w]") then + break + end + pos = pos + 1 + end + + self._cursorPosition = pos + self:_validateCursorPosition() +end + +---Validate cursor position (ensure it's within text bounds) +function TextEditor:_validateCursorPosition() + local textLength = utf8.len(self._textBuffer or "") or 0 + local cursorPos = tonumber(self._cursorPosition) or 0 + self._cursorPosition = math.max(0, math.min(cursorPos, textLength)) +end + +---Reset cursor blink (show cursor immediately) +---@param element Element? The parent element (for scroll updates) +---@param pauseBlink boolean|nil -- Whether to pause blinking (for typing) +function TextEditor:_resetCursorBlink(element, pauseBlink) + self._cursorBlinkTimer = 0 + self._cursorVisible = true + + if pauseBlink then + self._cursorBlinkPaused = true + self._cursorBlinkPauseTimer = 0 + end + + self:_updateTextScroll(element) +end + +---Update text scroll offset to keep cursor visible +---@param element Element? The parent element +function TextEditor:_updateTextScroll(element) + if not element or self.multiline then + return + end + + local font = self:_getFont(element) + if not font then + return + end + + -- Calculate cursor X position in text coordinates + local cursorText = "" + if self._textBuffer and self._textBuffer ~= "" and self._cursorPosition > 0 then + local byteOffset = utf8.offset(self._textBuffer, self._cursorPosition + 1) + if byteOffset then + cursorText = self._textBuffer:sub(1, byteOffset - 1) + end + end + local cursorX = font:getWidth(cursorText) + + -- Get available text area width + local textAreaWidth = element.width + local scaledContentPadding = element:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Add some padding on the right for the cursor + local cursorPadding = 4 + local visibleWidth = textAreaWidth - cursorPadding + + -- Adjust scroll to keep cursor visible + if cursorX - self._textScrollX < 0 then + self._textScrollX = cursorX + elseif cursorX - self._textScrollX > visibleWidth then + self._textScrollX = cursorX - visibleWidth + end + + -- Ensure we don't scroll past the beginning + self._textScrollX = math.max(0, self._textScrollX) +end + +---Get cursor screen position for rendering (handles multiline text) +---@param element Element? The parent element +---@return number, number -- Cursor X and Y position relative to content area +function TextEditor:_getCursorScreenPosition(element) + local font = self:_getFont(element) + if not font then + return 0, 0 + end + + local text = self._textBuffer or "" + local cursorPos = self._cursorPosition or 0 + + -- Apply password masking for cursor position calculation + local textForMeasurement = text + if self.passwordMode and text ~= "" then + textForMeasurement = string.rep("•", utf8.len(text)) + end + + -- For single-line text, calculate simple X position + if not self.multiline then + local cursorText = "" + if textForMeasurement ~= "" and cursorPos > 0 then + local byteOffset = utf8.offset(textForMeasurement, cursorPos + 1) + if byteOffset then + cursorText = textForMeasurement:sub(1, byteOffset - 1) + end + end + return font:getWidth(cursorText), 0 + end + + -- For multiline text, we need to find which wrapped line the cursor is on + self:_updateTextIfDirty(element) + + if not element then + return 0, 0 + end + + -- Get text area width for wrapping + local textAreaWidth = element.width + local scaledContentPadding = element:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Split text by actual newlines first + local lines = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines == 0 then + lines = { "" } + end + + -- Track character position as we iterate through lines + local charCount = 0 + local cursorX = 0 + local cursorY = 0 + local lineHeight = font:getHeight() + + for lineNum, line in ipairs(lines) do + local lineLength = utf8.len(line) or 0 + + -- Check if cursor is on this line + if cursorPos <= charCount + lineLength then + local posInLine = cursorPos - charCount + + -- If text wrapping is enabled, find which wrapped segment + if self.textWrap and textAreaWidth > 0 then + local wrappedSegments = self:_wrapLine(element, line, textAreaWidth) + + for segmentIdx, segment in ipairs(wrappedSegments) do + if posInLine >= segment.startIdx and posInLine <= segment.endIdx then + local posInSegment = posInLine - segment.startIdx + local segmentText = "" + if posInSegment > 0 and segment.text ~= "" then + local endByte = utf8.offset(segment.text, posInSegment + 1) + if endByte then + segmentText = segment.text:sub(1, endByte - 1) + else + segmentText = segment.text + end + end + cursorX = font:getWidth(segmentText) + cursorY = (lineNum - 1) * lineHeight + (segmentIdx - 1) * lineHeight + + return cursorX, cursorY + end + end + else + -- No wrapping, simple calculation + local lineText = "" + if posInLine > 0 then + local endByte = utf8.offset(line, posInLine + 1) + if endByte then + lineText = line:sub(1, endByte - 1) + else + lineText = line + end + end + cursorX = font:getWidth(lineText) + cursorY = (lineNum - 1) * lineHeight + return cursorX, cursorY + end + end + + charCount = charCount + lineLength + 1 + end + + -- Cursor is at the very end + return 0, #lines * lineHeight +end + +-- ==================== +-- Selection Management +-- ==================== + +---Set selection range +---@param element Element? The parent element (for scroll updates) +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +function TextEditor:setSelection(element, startPos, endPos) + local textLength = utf8.len(self._textBuffer or "") + self._selectionStart = math.max(0, math.min(startPos, textLength)) + self._selectionEnd = math.max(0, math.min(endPos, textLength)) + + -- Ensure start <= end + if self._selectionStart > self._selectionEnd then + self._selectionStart, self._selectionEnd = self._selectionEnd, self._selectionStart + end + + self:_resetCursorBlink(element) +end + +---Get selection range +---@return number?, number? -- Start and end positions, or nil if no selection +function TextEditor:getSelection() + if not self:hasSelection() then + return nil, nil + end + return self._selectionStart, self._selectionEnd +end + +---Check if there is an active selection +---@return boolean +function TextEditor:hasSelection() + return self._selectionStart ~= nil and self._selectionEnd ~= nil and self._selectionStart ~= self._selectionEnd +end + +---Clear selection +function TextEditor:clearSelection() + self._selectionStart = nil + self._selectionEnd = nil + self._selectionAnchor = nil +end + +---Select all text +---@param element Element? The parent element (for scroll updates) +function TextEditor:selectAll(element) + local textLength = utf8.len(self._textBuffer or "") + self._selectionStart = 0 + self._selectionEnd = textLength + self:_resetCursorBlink(element) +end + +---Get selected text +---@return string? -- Selected text or nil if no selection +function TextEditor:getSelectedText() + if not self:hasSelection() then + return nil + end + + local startPos, endPos = self:getSelection() + if not startPos or not endPos then + return nil + end + + -- Convert character indices to byte offsets + local text = self._textBuffer or "" + local startByte = utf8.offset(text, startPos + 1) + local endByte = utf8.offset(text, endPos + 1) + + if not startByte then + return "" + end + + if endByte then + endByte = endByte - 1 + end + + return string.sub(text, startByte, endByte) +end + +---Delete selected text +---@param element Element The parent element (for state saving) +---@return boolean -- True if text was deleted +function TextEditor:deleteSelection(element) + if not self:hasSelection() then + return false + end + + local startPos, endPos = self:getSelection() + if not startPos or not endPos then + return false + end + + self:deleteText(element, startPos, endPos) + self:clearSelection() + self._cursorPosition = startPos + self:_validateCursorPosition() + self:_saveState(element) + + -- Sync display text and auto-grow height on the owning element + if element then + element.text = self:getText() + self:updateAutoGrowHeight(element) + end + + return true +end + +---Get selection rectangles for rendering +---@param element Element The parent element +---@param selStart number -- Selection start position +---@param selEnd number -- Selection end position +---@return table -- Array of rectangles {x, y, width, height} +function TextEditor:_getSelectionRects(element, selStart, selEnd) + local font = self:_getFont(element) + if not font or not element then + return {} + end + + local text = self._textBuffer or "" + local rects = {} + + -- Apply password masking + local textForMeasurement = text + if self.passwordMode and text ~= "" then + textForMeasurement = string.rep("•", utf8.len(text)) + end + + -- For single-line text, calculate simple rectangle + if not self.multiline then + local startByte = utf8.offset(textForMeasurement, selStart + 1) + local endByte = utf8.offset(textForMeasurement, selEnd + 1) + + if startByte and endByte then + local beforeSelection = textForMeasurement:sub(1, startByte - 1) + local selectedText = textForMeasurement:sub(startByte, endByte - 1) + local selX = font:getWidth(beforeSelection) + local selWidth = font:getWidth(selectedText) + local selY = 0 + local selHeight = font:getHeight() + + table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight }) + end + + return rects + end + + -- For multiline text, handle line wrapping + self:_updateTextIfDirty(element) + + -- Get text area width for wrapping + local textAreaWidth = element.width + local scaledContentPadding = element:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Split text by actual newlines + local lines = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines == 0 then + lines = { "" } + end + + local lineHeight = font:getHeight() + local charCount = 0 + local visualLineNum = 0 + + for lineNum, line in ipairs(lines) do + local lineLength = utf8.len(line) or 0 + local lineStartChar = charCount + local lineEndChar = charCount + lineLength + + if selEnd > lineStartChar and selStart <= lineEndChar then + local selStartInLine = math.max(0, selStart - charCount) + local selEndInLine = math.min(lineLength, selEnd - charCount) + + if self.textWrap and textAreaWidth > 0 then + local wrappedSegments = self:_wrapLine(element, line, textAreaWidth) + + for segmentIdx, segment in ipairs(wrappedSegments) do + if selEndInLine > segment.startIdx and selStartInLine <= segment.endIdx then + local segSelStart = math.max(segment.startIdx, selStartInLine) + local segSelEnd = math.min(segment.endIdx, selEndInLine) + + local beforeText = "" + local selectedText = "" + + if segSelStart > segment.startIdx then + local startByte = utf8.offset(segment.text, segSelStart - segment.startIdx + 1) + if startByte then + beforeText = segment.text:sub(1, startByte - 1) + end + end + + local selStartByte = utf8.offset(segment.text, segSelStart - segment.startIdx + 1) + local selEndByte = utf8.offset(segment.text, segSelEnd - segment.startIdx + 1) + if selStartByte and selEndByte then + selectedText = segment.text:sub(selStartByte, selEndByte - 1) + end + + local selX = font:getWidth(beforeText) + local selWidth = font:getWidth(selectedText) + local selY = visualLineNum * lineHeight + local selHeight = lineHeight + + table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight }) + end + + visualLineNum = visualLineNum + 1 + end + else + -- No wrapping + local beforeText = "" + local selectedText = "" + + if selStartInLine > 0 then + local startByte = utf8.offset(line, selStartInLine + 1) + if startByte then + beforeText = line:sub(1, startByte - 1) + end + end + + local selStartByte = utf8.offset(line, selStartInLine + 1) + local selEndByte = utf8.offset(line, selEndInLine + 1) + if selStartByte and selEndByte then + selectedText = line:sub(selStartByte, selEndByte - 1) + end + + local selX = font:getWidth(beforeText) + local selWidth = font:getWidth(selectedText) + local selY = visualLineNum * lineHeight + local selHeight = lineHeight + + table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight }) + visualLineNum = visualLineNum + 1 + end + else + -- Selection doesn't intersect, but count visual lines + if self.textWrap and textAreaWidth > 0 then + local wrappedSegments = self:_wrapLine(element, line, textAreaWidth) + visualLineNum = visualLineNum + #wrappedSegments + else + visualLineNum = visualLineNum + 1 + end + end + + charCount = charCount + lineLength + 1 + end + + return rects +end + +-- ==================== +-- Focus Management +-- ==================== + +---Focus this element for keyboard input +---@param element Element The parent element +function TextEditor:focus(element) + if not element then + return + end + + -- Use centralized Context focus management + self._Context.setFocused(element) + self._focused = true + + self:_resetCursorBlink(element) + + if self.selectOnFocus then + self:selectAll(element) + else + self:moveCursorToEnd(element) + end + + if self.onFocus then + self.onFocus(element) + end + + self:_saveState(element) +end + +---Remove focus from this element +---@param element Element The parent element +function TextEditor:blur(element) + if not element then + return + end + + self._focused = false + + -- Clear focused element in Context if this element is currently focused + -- Use direct assignment to avoid circular call back to blur() + if self._Context.getFocused() == element then + self._Context._focusedElement = nil + end + + if self.onBlur then + self.onBlur(element) + end + + self:_saveState(element) +end + +---Check if this element is focused +---@return boolean +function TextEditor:isFocused() + return self._focused == true +end + +-- ==================== +-- Input Handling +-- ==================== + +---Handle text input (character insertion) +---@param element Element The parent element +---@param text string +function TextEditor:handleTextInput(element, text) + if not self._focused then + return + end + + -- Trigger onTextInput callback if defined + if self.onTextInput then + local result = self.onTextInput(element, text) + if result == false then + return + end + end + + local oldText = self._textBuffer + + -- Delete selection if exists + if self:hasSelection() then + self:deleteSelection(element) + end + + -- Insert text at cursor position + self:insertText(element, text) + -- Trigger onTextChange callback + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(element, self._textBuffer, oldText) + end + + self:_saveState(element) +end + +---Handle key press (special keys) +---@param element Element The parent element +---@param key string -- Key name +---@param scancode string -- Scancode +---@param isrepeat boolean -- Whether this is a key repeat +function TextEditor:handleKeyPress(element, key, scancode, isrepeat) + if not self._focused then + return + end + + local modifiers = self._getModifiers() + local ctrl = modifiers.ctrl or modifiers.super + + -- Handle cursor movement with selection + if key == "left" or key == "right" or key == "home" or key == "end" or key == "up" or key == "down" then + if modifiers.shift and not self._selectionAnchor then + self._selectionAnchor = self._cursorPosition + end + + if key == "left" then + if modifiers.super then + self:moveCursorToStart(element) + if not modifiers.shift then + self:clearSelection() + end + elseif modifiers.alt then + self:moveCursorToPreviousWord() + elseif self:hasSelection() and not modifiers.shift then + local startPos, _ = self:getSelection() + self._cursorPosition = startPos + self:clearSelection() + else + self:moveCursorBy(element, -1) + end + elseif key == "right" then + if modifiers.super then + self:moveCursorToEnd(element) + if not modifiers.shift then + self:clearSelection() + end + elseif modifiers.alt then + self:moveCursorToNextWord() + elseif self:hasSelection() and not modifiers.shift then + local _, endPos = self:getSelection() + self._cursorPosition = endPos + self:clearSelection() + else + self:moveCursorBy(element, 1) + end + elseif key == "home" then + if not self.multiline then + self:moveCursorToStart(element) + else + self:moveCursorToLineStart(element) + end + if not modifiers.shift then + self:clearSelection() + end + elseif key == "end" then + if not self.multiline then + self:moveCursorToEnd(element) + else + self:moveCursorToLineEnd(element) + end + if not modifiers.shift then + self:clearSelection() + end + elseif key == "up" or key == "down" then + if not modifiers.shift then + self:clearSelection() + end + end + + -- Update selection if Shift is pressed + if modifiers.shift and self._selectionAnchor then + self:setSelection(element, self._selectionAnchor, self._cursorPosition) + elseif not modifiers.shift then + self._selectionAnchor = nil + end + + self:_resetCursorBlink(element) + + -- Handle backspace and delete + elseif key == "backspace" then + local oldText = self._textBuffer + if self:hasSelection() then + self:deleteSelection(element) + elseif ctrl then + if self._cursorPosition > 0 then + self:deleteText(element, 0, self._cursorPosition) + self._cursorPosition = 0 + self:_validateCursorPosition() + end + elseif self._cursorPosition > 0 then + local deleteStart = self._cursorPosition - 1 + local deleteEnd = self._cursorPosition + self._cursorPosition = deleteStart + self:deleteText(element, deleteStart, deleteEnd) + self:_validateCursorPosition() + end + + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(element, self._textBuffer, oldText) + end + self:_resetCursorBlink(element, true) + elseif key == "delete" then + local oldText = self._textBuffer + if self:hasSelection() then + self:deleteSelection(element) + else + local textLength = utf8.len(self._textBuffer or "") + if self._cursorPosition < textLength then + self:deleteText(element, self._cursorPosition, self._cursorPosition + 1) + end + end + + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(element, self._textBuffer, oldText) + end + self:_resetCursorBlink(element, true) + + -- Handle return/enter + elseif key == "return" or key == "kpenter" then + if self.multiline then + local oldText = self._textBuffer + if self:hasSelection() then + self:deleteSelection(element) + end + self:insertText(element, "\n") + + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(element, self._textBuffer, oldText) + end + else + if self.onEnter then + self.onEnter(element) + end + end + self:_resetCursorBlink(element, true) + + -- Handle Ctrl/Cmd+A (select all) + elseif ctrl and key == "a" then + self:selectAll(element) + self:_resetCursorBlink(element) + + -- Handle Ctrl/Cmd+C (copy) + elseif ctrl and key == "c" then + if self:hasSelection() then + local selectedText = self:getSelectedText() + if selectedText then + love.system.setClipboardText(selectedText) + end + end + self:_resetCursorBlink(element) + + -- Handle Ctrl/Cmd+X (cut) + elseif ctrl and key == "x" then + if self:hasSelection() then + local selectedText = self:getSelectedText() + if selectedText then + love.system.setClipboardText(selectedText) + + local oldText = self._textBuffer + self:deleteSelection(element) + + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(element, self._textBuffer, oldText) + end + end + end + self:_resetCursorBlink(element, true) + + -- Handle Ctrl/Cmd+V (paste) + elseif ctrl and key == "v" then + local clipboardText = love.system.getClipboardText() + if clipboardText and clipboardText ~= "" then + local oldText = self._textBuffer + + if self:hasSelection() then + self:deleteSelection(element) + end + + self:insertText(element, clipboardText) + + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(element, self._textBuffer, oldText) + end + end + self:_resetCursorBlink(element, true) + + -- Handle Escape + elseif key == "escape" then + if self:hasSelection() then + self:clearSelection() + else + self:blur(element) + end + self:_resetCursorBlink(element) + end + + self:_saveState(element) +end + +-- ==================== +-- Mouse Input +-- ==================== + +---Convert mouse coordinates to cursor position in text +---@param element Element The parent element +---@param mouseX number -- Mouse X coordinate (absolute) +---@param mouseY number -- Mouse Y coordinate (absolute) +---@return number -- Cursor position (character index) +function TextEditor:mouseToTextPosition(element, mouseX, mouseY) + if not element or not self._textBuffer then + return 0 + end + + local font = self:_getFont(element) + if not font then + return 0 + end + + -- Get content area bounds + local contentX = (element._absoluteX or element.x) + element.padding.left + local contentY = (element._absoluteY or element.y) + element.padding.top + + -- Calculate relative position + local relativeX = mouseX - contentX + local relativeY = mouseY - contentY + + local text = self._textBuffer + local textLength = utf8.len(text) or 0 + + -- Single-line handling + if not self.multiline then + if self._textScrollX then + relativeX = relativeX + self._textScrollX + end + + local closestPos = 0 + local closestDist = math.huge + + for i = 0, textLength do + local offset = utf8.offset(text, i + 1) + local beforeText = offset and text:sub(1, offset - 1) or text + local textWidth = font:getWidth(beforeText) + local dist = math.abs(relativeX - textWidth) + + if dist < closestDist then + closestDist = dist + closestPos = i + end + end + + return closestPos + end + + -- Multiline handling + self:_updateTextIfDirty(element) + + -- Split text into lines + local lines = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines == 0 then + lines = { "" } + end + + local lineHeight = font:getHeight() + + -- Get text area width + local textAreaWidth = element.width + local scaledContentPadding = element:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Determine which line was clicked + local clickedLineNum = math.floor(relativeY / lineHeight) + 1 + clickedLineNum = math.max(1, math.min(clickedLineNum, #lines)) + + -- Calculate character offset for lines before clicked line + local charOffset = 0 + for i = 1, clickedLineNum - 1 do + local lineLen = utf8.len(lines[i]) or 0 + charOffset = charOffset + lineLen + 1 + end + + local clickedLine = lines[clickedLineNum] + local lineLen = utf8.len(clickedLine) or 0 + + -- Handle wrapped segments + if self.textWrap and textAreaWidth > 0 then + local wrappedSegments = self:_wrapLine(element, clickedLine, textAreaWidth) + local lineYOffset = (clickedLineNum - 1) * lineHeight + local segmentNum = math.floor((relativeY - lineYOffset) / lineHeight) + 1 + segmentNum = math.max(1, math.min(segmentNum, #wrappedSegments)) + + local segment = wrappedSegments[segmentNum] + local segmentText = segment.text + local segmentLen = utf8.len(segmentText) or 0 + local closestPos = segment.startIdx + local closestDist = math.huge + + for i = 0, segmentLen do + local offset = utf8.offset(segmentText, i + 1) + local beforeText = offset and segmentText:sub(1, offset - 1) or segmentText + local textWidth = font:getWidth(beforeText) + local dist = math.abs(relativeX - textWidth) + + if dist < closestDist then + closestDist = dist + closestPos = segment.startIdx + i + end + end + + return charOffset + closestPos + end + + -- No wrapping + local closestPos = 0 + local closestDist = math.huge + + for i = 0, lineLen do + local offset = utf8.offset(clickedLine, i + 1) + local beforeText = offset and clickedLine:sub(1, offset - 1) or clickedLine + local textWidth = font:getWidth(beforeText) + local dist = math.abs(relativeX - textWidth) + + if dist < closestDist then + closestDist = dist + closestPos = i + end + end + + return charOffset + closestPos +end + +---Handle mouse click on text +---@param element Element The parent element +---@param mouseX number +---@param mouseY number +---@param clickCount number -- 1=single, 2=double, 3=triple +function TextEditor:handleTextClick(element, mouseX, mouseY, clickCount) + if not self._focused then + return + end + + if clickCount == 1 then + local pos = self:mouseToTextPosition(element, mouseX, mouseY) + self:setCursorPosition(element, pos) + self:clearSelection() + self._mouseDownPosition = pos + elseif clickCount == 2 then + self:_selectWordAtPosition(element, self:mouseToTextPosition(element, mouseX, mouseY)) + elseif clickCount >= 3 then + self:selectAll(element) + end + + self:_resetCursorBlink(element) +end + +---Handle mouse drag for text selection +---@param element Element The parent element +---@param mouseX number +---@param mouseY number +function TextEditor:handleTextDrag(element, mouseX, mouseY) + if not self._focused or not element._mouseDownPosition then + return + end + + local currentPos = self:mouseToTextPosition(element, mouseX, mouseY) + + if currentPos ~= element._mouseDownPosition then + self:setSelection(element, element._mouseDownPosition, currentPos) + self._cursorPosition = currentPos + self._textDragOccurred = true + else + self:clearSelection() + end + + self:_resetCursorBlink(element) +end + +---Select word at given position +---@param element Element? The parent element (for scroll updates) +---@param position number +function TextEditor:_selectWordAtPosition(element, position) + if not self._textBuffer then + return + end + + local text = self._textBuffer + local textLength = utf8.len(text) or 0 + + if textLength == 0 then + return + end + + -- Helper to get character at position + local function getCharAt(p) + if p < 0 or p >= textLength then + return nil + end + local offset1 = utf8.offset(text, p + 1) + local offset2 = utf8.offset(text, p + 2) + if not offset1 then + return nil + end + if not offset2 then + return text:sub(offset1) + end + return text:sub(offset1, offset2 - 1) + end + + -- Find word boundaries + local startPos = position + local endPos = position + + -- Expand left to start of word + while startPos > 0 do + local char = getCharAt(startPos - 1) + if not char or not char:match("[%w]") then + break + end + startPos = startPos - 1 + end + + -- Expand right to end of word + while endPos < textLength do + local char = getCharAt(endPos) + if not char or not char:match("[%w]") then + break + end + endPos = endPos + 1 + end + + self:setSelection(element, startPos, endPos) + self._cursorPosition = endPos +end + +-- ==================== +-- Update and Rendering +-- ==================== + +---Update cursor blink animation +---@param element Element The parent element +---@param dt number -- Delta time +function TextEditor:update(element, dt) + if not self._focused then + return + end + + -- Update cursor blink + if self._cursorBlinkPaused then + self._cursorBlinkPauseTimer = (self._cursorBlinkPauseTimer or 0) + dt + if self._cursorBlinkPauseTimer >= 0.5 then + self._cursorBlinkPaused = false + self._cursorBlinkPauseTimer = 0 + end + else + self._cursorBlinkTimer = self._cursorBlinkTimer + dt + if self._cursorBlinkTimer >= self.cursorBlinkRate then + self._cursorBlinkTimer = 0 + self._cursorVisible = not self._cursorVisible + end + end + + -- Save state for immediate mode (cursor blink timer changes need to persist) + self:_saveState(element) +end + +---Update element height based on text content (for autoGrow) +---@param element Element The parent element +function TextEditor:updateAutoGrowHeight(element) + if not self.multiline or not self.autoGrow or not element then + return + end + + local font = self:_getFont(element) + if not font then + return + end + + local text = self._textBuffer or "" + local lineHeight = font:getHeight() + + -- Get text area width + local textAreaWidth = element.width + local scaledContentPadding = element:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Split text by newlines + local lines = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines == 0 then + lines = { "" } + end + + -- Count total wrapped lines + local totalWrappedLines = 0 + if self.textWrap and textAreaWidth > 0 then + for _, line in ipairs(lines) do + if line == "" then + totalWrappedLines = totalWrappedLines + 1 + else + local wrappedSegments = self:_wrapLine(element, line, textAreaWidth) + totalWrappedLines = totalWrappedLines + #wrappedSegments + end + end + else + totalWrappedLines = #lines + end + + totalWrappedLines = math.max(1, totalWrappedLines) + local newContentHeight = totalWrappedLines * lineHeight + + if element.height ~= newContentHeight then + element.height = newContentHeight + element._borderBoxHeight = element.height + element.padding.top + element.padding.bottom + if element.parent and not element._explicitlyAbsolute then + element.parent:layoutChildren() + end + end +end + +-- ==================== +-- Helper Methods +-- ==================== + +---Get font for text rendering +---@param element Element? The parent element +---@return love.Font? +function TextEditor:_getFont(element) + if not element then + return nil + end + + -- Delegate to Renderer + return element._renderer:getFont(element) +end + +--- Get current state for persistence +---@return table state TextEditor state snapshot +function TextEditor:getState() + return { + _cursorPosition = self._cursorPosition, + _selectionStart = self._selectionStart, + _selectionEnd = self._selectionEnd, + _textBuffer = self._textBuffer, + _cursorBlinkTimer = self._cursorBlinkTimer, + _cursorVisible = self._cursorVisible, + _cursorBlinkPaused = self._cursorBlinkPaused, + _cursorBlinkPauseTimer = self._cursorBlinkPauseTimer, + _focused = self._focused, + } +end + +--- Restore state from persistence +---@param state table State to restore +---@param element Element? The parent element (needed for focus restoration) +function TextEditor:setState(state, element) + if not state then + return + end + + if state._cursorPosition ~= nil then + self._cursorPosition = state._cursorPosition + end + + if state._selectionStart ~= nil then + self._selectionStart = state._selectionStart + end + + if state._selectionEnd ~= nil then + self._selectionEnd = state._selectionEnd + end + + if state._textBuffer ~= nil then + self._textBuffer = state._textBuffer + end + + if state._cursorBlinkTimer ~= nil then + self._cursorBlinkTimer = state._cursorBlinkTimer + end + + if state._cursorVisible ~= nil then + self._cursorVisible = state._cursorVisible + end + + if state._cursorBlinkPaused ~= nil then + self._cursorBlinkPaused = state._cursorBlinkPaused + end + + if state._cursorBlinkPauseTimer ~= nil then + self._cursorBlinkPauseTimer = state._cursorBlinkPauseTimer + end + + if state._focused ~= nil then + self._focused = state._focused + -- Restore focused element in Context if this element was focused + if self._focused and element then + self._Context.setFocused(element) + end + end +end + +---Save state to StateManager (for immediate mode) +---@param element Element? The parent element +function TextEditor:_saveState(element) + -- Mode-aware guard: in retained mode the TextEditor persists, so state only + -- needs persisting to StateManager in immediate mode. Routed through + -- Context.isImmediateMode (behavior-mode-unification task 11). + if not element or not element._stateId or not self._Context.isImmediateMode() then + return + end + + -- Get current state (may have other sub-modules like eventHandler, scrollManager) + local currentState = self._StateManager.getState(element._stateId) or {} + + -- Update only the textEditor sub-table to match the nested structure + -- used by element:saveState() at endFrame + currentState.textEditor = { + _focused = self._focused, + _textBuffer = self._textBuffer, + _cursorPosition = self._cursorPosition, + _selectionStart = self._selectionStart, + _selectionEnd = self._selectionEnd, + _cursorBlinkTimer = self._cursorBlinkTimer, + _cursorVisible = self._cursorVisible, + _cursorBlinkPaused = self._cursorBlinkPaused, + _cursorBlinkPauseTimer = self._cursorBlinkPauseTimer, + } + + self._StateManager.updateState(element._stateId, currentState) +end + +return TextEditor diff --git a/libs/flexlove/modules/TextSanitizer.lua b/libs/flexlove/modules/TextSanitizer.lua new file mode 100644 index 00000000..e7e57d12 --- /dev/null +++ b/libs/flexlove/modules/TextSanitizer.lua @@ -0,0 +1,183 @@ +local modulePath = (...):match("(.-)[^%.]+$") +local function req(name) + return require(modulePath .. name) +end + +-- Text sanitization, escaping, and input validation utilities. + +-- ErrorHandler is injected via init() for truncation warnings. +local ErrorHandler = nil + +--- Initialize dependencies +---@param deps table Dependencies: { ErrorHandler = ErrorHandler } +local function init(deps) + if type(deps) == "table" then + ErrorHandler = deps.ErrorHandler + end +end + +--- Sanitize text to prevent security vulnerabilities +--- @param text string? Text to sanitize +--- @param options table? Sanitization options +--- @return string Sanitized text +local function sanitizeText(text, options) + local utf8 = require("utf8") + -- Handle nil or non-string inputs + if text == nil then + return "" + end + if type(text) ~= "string" then + text = tostring(text) + end + + -- Default options + options = options or {} + local maxLength = options.maxLength or 10000 + local allowNewlines = options.allowNewlines ~= false -- default true + local allowTabs = options.allowTabs ~= false -- default true + local stripControls = options.stripControls ~= false -- default true + local trimWhitespace = options.trimWhitespace ~= false -- default true + + -- Remove null bytes (critical security risk) + text = text:gsub("%z", "") + + -- Strip control characters except allowed ones + if stripControls then + local pattern = "[\1-\31\127]" -- All control characters + if allowNewlines and allowTabs then + pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13) + elseif allowNewlines then + pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13) + elseif allowTabs then + pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9) + end + text = text:gsub(pattern, "") + end + + -- Trim leading/trailing whitespace + if trimWhitespace then + text = text:match("^%s*(.-)%s*$") or "" + end + + -- Limit string length (use UTF-8 character count, not byte count) + local charCount = utf8.len(text) + if charCount and charCount > maxLength then + if ErrorHandler then + ErrorHandler:warn("utils", "UTIL_001", { + original = charCount, + truncated = maxLength, + }) + end + -- Truncate to maxLength UTF-8 characters + local bytePos = utf8.offset(text, maxLength + 1) + if bytePos then + text = text:sub(1, bytePos - 1) + end + if ErrorHandler then + ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", charCount, maxLength)) + end + end + + return text +end + +--- Validate text input against rules +--- @param text string Text to validate +--- @param rules table Validation rules +--- @return boolean, string? Returns true if valid, or false with error message +local function validateTextInput(text, rules) + rules = rules or {} + + -- Check minimum length + if rules.minLength and #text < rules.minLength then + return false, string.format("Text must be at least %d characters", rules.minLength) + end + + -- Check maximum length + if rules.maxLength and #text > rules.maxLength then + return false, string.format("Text must be at most %d characters", rules.maxLength) + end + + -- Check pattern match + if rules.pattern and not text:match(rules.pattern) then + return false, rules.patternError or "Text does not match required pattern" + end + + -- Check character whitelist + if rules.allowedChars then + local pattern = "[^" .. rules.allowedChars .. "]" + if text:match(pattern) then + return false, "Text contains invalid characters" + end + end + + -- Check character blacklist + if rules.forbiddenChars then + local pattern = "[" .. rules.forbiddenChars .. "]" + if text:match(pattern) then + return false, "Text contains forbidden characters" + end + end + + return true, nil +end + +--- Validate text against range/length rules (alias of validateTextInput) +--- @param text string Text to validate +--- @param rules table Validation rules (minLength, maxLength, pattern, etc.) +--- @return boolean, string? Returns true if valid, or false with error message +local function validateTextRange(text, rules) + return validateTextInput(text, rules) +end + +--- Escape HTML special characters +--- @param text string Text to escape +--- @return string Escaped text +local function escapeHtml(text) + if text == nil then + return "" + end + text = tostring(text) + text = text:gsub("&", "&") + text = text:gsub("<", "<") + text = text:gsub(">", ">") + text = text:gsub('"', """) + text = text:gsub("'", "'") + return text +end + +--- Escape Lua pattern special characters +--- @param text string Text to escape +--- @return string Escaped text +local function escapeLuaPattern(text) + if text == nil then + return "" + end + text = tostring(text) + -- Escape all Lua pattern special characters + text = text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") + return text +end + +--- Strip all non-printable characters from text +--- @param text string Text to clean +--- @return string Cleaned text +local function stripNonPrintable(text) + if text == nil then + return "" + end + text = tostring(text) + -- Keep printable ASCII (32-126), newline (10), tab (9), and carriage return (13) + text = text:gsub("[^\9\10\13\32-\126]", "") + return text +end + +return { + init = init, + sanitizeText = sanitizeText, + validateTextInput = validateTextInput, + validateTextRange = validateTextRange, + escapeHtml = escapeHtml, + escapeLuaPattern = escapeLuaPattern, + stripNonPrintable = stripNonPrintable, +} diff --git a/libs/flexlove/modules/Theme.lua b/libs/flexlove/modules/Theme.lua new file mode 100644 index 00000000..f6e95bfe --- /dev/null +++ b/libs/flexlove/modules/Theme.lua @@ -0,0 +1,1655 @@ +--- Auto-detect the base path where FlexLove is located +---@return string modulePath, string filesystemPath +local function getFlexLoveBasePath() + -- Get debug info to find where this file is loaded from + local info = debug.getinfo(1, "S") + if info and info.source then + local source = info.source + -- Remove leading @ if present + if source:sub(1, 1) == "@" then + source = source:sub(2) + end + + -- Extract the directory path (remove Theme.lua and modules/) + local filesystemPath = source:match("(.*/)") + if filesystemPath then + -- Store the original filesystem path for loading assets + local fsPath = filesystemPath + -- Remove leading ./ if present + fsPath = fsPath:gsub("^%./", "") + -- Remove trailing / + fsPath = fsPath:gsub("/$", "") + -- Remove the flexlove subdirectory to get back to base + fsPath = fsPath:gsub("/modules$", "") + + -- Convert filesystem path to Lua module path + local modulePath = fsPath:gsub("/", ".") + + return modulePath, fsPath + end + end + + -- Fallback: try a common path + return "libs", "libs" +end + +-- Store the base paths when module loads +local FLEXLOVE_BASE_PATH, FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath() + +--- Validate theme definition structure +---@param definition ThemeDefinition +---@return boolean, string? -- Returns true if valid, or false with error message +local function validateThemeDefinition(definition) + if not definition then + return false, "Theme definition is nil" + end + + if type(definition) ~= "table" then + return false, "Theme definition must be a table" + end + + if not definition.name or type(definition.name) ~= "string" then + return false, "Theme must have a 'name' field (string)" + end + + if definition.components and type(definition.components) ~= "table" then + return false, "Theme 'components' must be a table" + end + + if definition.colors and type(definition.colors) ~= "table" then + return false, "Theme 'colors' must be a table" + end + + if definition.fonts and type(definition.fonts) ~= "table" then + return false, "Theme 'fonts' must be a table" + end + + if definition.scrollbars and type(definition.scrollbars) ~= "table" then + return false, "Theme 'scrollbars' must be a table" + end + + return true, nil +end + +--- Load image data from a file path +---@param imagePath string +---@return love.ImageData +local function loadImageData(imagePath) + if not imagePath then + error("Image path cannot be nil") + end + + local success, result = pcall(function() + return love.image.newImageData(imagePath) + end) + + if not success then + error("Failed to load image data from '" .. imagePath .. "': " .. tostring(result)) + end + + return result +end + +--- Extract all pixels from a specific row +---@param imageData love.ImageData +---@param rowIndex number 0-based row index +---@return table Array of {r, g, b, a} values (0-255 range) +local function getRow(imageData, rowIndex) + if not imageData then + error("ImageData cannot be nil") + end + + local width = imageData:getWidth() + local height = imageData:getHeight() + + if rowIndex < 0 or rowIndex >= height then + error(string.format("Row index %d out of bounds (height: %d)", rowIndex, height)) + end + + local pixels = {} + for x = 0, width - 1 do + local r, g, b, a = imageData:getPixel(x, rowIndex) + table.insert(pixels, { + r = math.floor(r * 255 + 0.5), + g = math.floor(g * 255 + 0.5), + b = math.floor(b * 255 + 0.5), + a = math.floor(a * 255 + 0.5), + }) + end + + return pixels +end + +--- Extract all pixels from a specific column +---@param imageData love.ImageData +---@param colIndex number 0-based column index +---@return table Array of {r, g, b, a} values (0-255 range) +local function getColumn(imageData, colIndex) + if not imageData then + error("ImageData cannot be nil") + end + + local width = imageData:getWidth() + local height = imageData:getHeight() + + if colIndex < 0 or colIndex >= width then + error(string.format("Column index %d out of bounds (width: %d)", colIndex, width)) + end + + local pixels = {} + for y = 0, height - 1 do + local r, g, b, a = imageData:getPixel(colIndex, y) + table.insert(pixels, { + r = math.floor(r * 255 + 0.5), + g = math.floor(g * 255 + 0.5), + b = math.floor(b * 255 + 0.5), + a = math.floor(a * 255 + 0.5), + }) + end + + return pixels +end + +--- Check if a pixel is black with full alpha (9-patch marker) +---@param r number Red (0-255) +---@param g number Green (0-255) +---@param b number Blue (0-255) +---@param a number Alpha (0-255) +---@return boolean +local function isBlackPixel(r, g, b, a) + return r == 0 and g == 0 and b == 0 and a == 255 +end + +--- Find all continuous runs of black pixels in a pixel array +---@param pixels table Array of {r, g, b, a} pixel values +---@return table Array of {start, end} pairs (1-based indices, inclusive) +local function findBlackPixelRuns(pixels) + local runs = {} + local inRun = false + local runStart = nil + + for i = 1, #pixels do + local pixel = pixels[i] + local isBlack = isBlackPixel(pixel.r, pixel.g, pixel.b, pixel.a) + + if isBlack and not inRun then + -- Start of a new run + inRun = true + runStart = i + elseif not isBlack and inRun then + -- End of current run + table.insert(runs, { start = runStart, ["end"] = i - 1 }) + inRun = false + runStart = nil + end + end + + -- Handle case where run extends to end of array + if inRun then + table.insert(runs, { start = runStart, ["end"] = #pixels }) + end + + return runs +end + +--- Parse a 9-patch PNG image to extract stretch regions and content padding +---@param imagePath string Path to the 9-patch image file +---@return table|nil, string|nil Returns {insets, stretchX, stretchY} or nil, error message +local function parseNinePatch(imagePath) + if not imagePath then + return nil, "Image path cannot be nil" + end + + local success, imageData = pcall(function() + return loadImageData(imagePath) + end) + + if not success then + return nil, "Failed to load image data: " .. tostring(imageData) + end + + local width = imageData:getWidth() + local height = imageData:getHeight() + + -- Validate minimum size (must be at least 3x3 with 1px border) + if width < 3 or height < 3 then + return nil, string.format("Invalid 9-patch dimensions: %dx%d (minimum 3x3)", width, height) + end + + -- Extract border pixels (0-based indexing, but we convert to 1-based for processing) + local topBorder = getRow(imageData, 0) + local leftBorder = getColumn(imageData, 0) + local bottomBorder = getRow(imageData, height - 1) + local rightBorder = getColumn(imageData, width - 1) + + -- Remove corner pixels from borders (they're not part of the stretch/content markers) + -- Top and bottom borders: remove first and last pixel + local topStretchPixels = {} + local bottomContentPixels = {} + for i = 2, #topBorder - 1 do + table.insert(topStretchPixels, topBorder[i]) + end + for i = 2, #bottomBorder - 1 do + table.insert(bottomContentPixels, bottomBorder[i]) + end + + -- Left and right borders: remove first and last pixel + local leftStretchPixels = {} + local rightContentPixels = {} + for i = 2, #leftBorder - 1 do + table.insert(leftStretchPixels, leftBorder[i]) + end + for i = 2, #rightBorder - 1 do + table.insert(rightContentPixels, rightBorder[i]) + end + + -- Find stretch regions (top and left borders) + local stretchX = findBlackPixelRuns(topStretchPixels) + local stretchY = findBlackPixelRuns(leftStretchPixels) + + -- Find content padding regions (bottom and right borders) + local contentX = findBlackPixelRuns(bottomContentPixels) + local contentY = findBlackPixelRuns(rightContentPixels) + + -- Validate that we have at least one stretch region + if #stretchX == 0 or #stretchY == 0 then + return nil, "No stretch regions found (top or left border has no black pixels)" + end + + -- Calculate stretch insets from stretch regions (top/left guides) + -- Use the first stretch region's start and last stretch region's end + local firstStretchX = stretchX[1] + local lastStretchX = stretchX[#stretchX] + local firstStretchY = stretchY[1] + local lastStretchY = stretchY[#stretchY] + + -- Stretch insets define the 9-patch regions + local stretchLeft = firstStretchX.start + local stretchRight = #topStretchPixels - lastStretchX["end"] + local stretchTop = firstStretchY.start + local stretchBottom = #leftStretchPixels - lastStretchY["end"] + + -- Calculate content padding from content guides (bottom/right guides) + -- If content padding is defined, use it; otherwise use stretch regions + local contentLeft, contentRight, contentTop, contentBottom + + if #contentX > 0 then + contentLeft = contentX[1].start + contentRight = #topStretchPixels - contentX[#contentX]["end"] + else + contentLeft = stretchLeft + contentRight = stretchRight + end + + if #contentY > 0 then + contentTop = contentY[1].start + contentBottom = #leftStretchPixels - contentY[#contentY]["end"] + else + contentTop = stretchTop + contentBottom = stretchBottom + end + + return { + insets = { + left = stretchLeft, + top = stretchTop, + right = stretchRight, + bottom = stretchBottom, + }, + contentPadding = { + left = contentLeft, + top = contentTop, + right = contentRight, + bottom = contentBottom, + }, + stretchX = stretchX, + stretchY = stretchY, + } +end + +---@class Theme +local Theme = {} +Theme.__index = Theme + +--- Initialize module with shared dependencies +---@param deps table Dependencies {ErrorHandler, Color, utils} +function Theme.init(deps) + if type(deps) == "table" then + Theme._ErrorHandler = deps.ErrorHandler + Theme._Color = deps.Color + Theme._utils = deps.utils + end +end + +-- Global theme registry +local themes = {} +local activeTheme = nil + +--- Create reusable design systems with consistent styling, 9-patch assets, and component states +--- Use this to build professional-looking UIs with minimal per-element configuration +---@param definition ThemeDefinition Theme definition table +---@return Theme theme The new theme instance +function Theme.new(definition) + -- Validate input type first + if type(definition) ~= "table" then + Theme._ErrorHandler:warn("Theme", "THM_001", { + error = "Theme definition must be a table, got " .. type(definition), + }) + return Theme.new({ name = "fallback", components = {}, colors = {}, fonts = {} }) + end + + -- Validate theme definition + local valid, err = validateThemeDefinition(definition) + if not valid then + Theme._ErrorHandler:warn("Theme", "THM_001", { + error = tostring(err), + }) + return Theme.new({ name = "fallback", components = {}, colors = {}, fonts = {} }) + end + + local self = setmetatable({}, Theme) + self.name = definition.name + + -- Load global atlas if it's a string path + if definition.atlas then + if type(definition.atlas) == "string" then + local resolvedPath = Theme._utils.resolveImagePath(definition.atlas) + local image, imageData, loaderr = Theme._utils.safeLoadImage(resolvedPath) + if image then + self.atlas = image + self.atlasData = imageData + else + Theme._ErrorHandler:warn("Theme", "RES_001", { + theme = definition.name, + path = resolvedPath, + error = loaderr, + }) + end + else + self.atlas = definition.atlas + end + end + + self.components = definition.components or {} + self.scrollbars = definition.scrollbars or {} + self.colors = definition.colors or {} + self.fonts = definition.fonts or {} + self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil + + -- Helper function to strip 1-pixel guide border from 9-patch ImageData + ---@param sourceImageData love.ImageData + ---@return love.ImageData -- New ImageData without guide border + local function stripNinePatchBorder(sourceImageData) + local srcWidth = sourceImageData:getWidth() + local srcHeight = sourceImageData:getHeight() + + -- Content dimensions (excluding 1px border on all sides) + local contentWidth = srcWidth - 2 + local contentHeight = srcHeight - 2 + + if contentWidth <= 0 or contentHeight <= 0 then + Theme._ErrorHandler:warn("Theme", "RES_002", { + width = srcWidth, + height = srcHeight, + reason = "Image must be larger than 2x2 pixels to have content after stripping 1px border", + }) + return nil + end + + -- Create new ImageData for content only + local strippedImageData = love.image.newImageData(contentWidth, contentHeight) + + -- Copy pixels from source (1,1) to (width-2, height-2) + for y = 0, contentHeight - 1 do + for x = 0, contentWidth - 1 do + local r, g, b, a = sourceImageData:getPixel(x + 1, y + 1) + strippedImageData:setPixel(x, y, r, g, b, a) + end + end + + return strippedImageData + end + + -- Helper function to load atlas with 9-patch support + local function loadAtlasWithNinePatch(comp, atlasPath, errorContext) + ---@diagnostic disable-next-line + local resolvedPath = Theme._utils.resolveImagePath(atlasPath) + ---@diagnostic disable-next-line + local is9Patch = not comp.insets and atlasPath:match("%.9%.png$") + + if is9Patch then + local parseResult, parseErr = parseNinePatch(resolvedPath) + if parseResult then + comp.insets = parseResult.insets + comp._ninePatchData = parseResult + else + Theme._ErrorHandler:warn("Theme", "RES_003", { + context = errorContext, + path = resolvedPath, + error = tostring(parseErr), + }) + end + end + + local image, imageData, loaderr = Theme._utils.safeLoadImage(resolvedPath) + if image then + -- Strip guide border for 9-patch images + if is9Patch and imageData then + local strippedImageData = stripNinePatchBorder(imageData) + local strippedImage = love.graphics.newImage(strippedImageData) + comp._loadedAtlas = strippedImage + comp._loadedAtlasData = strippedImageData + else + comp._loadedAtlas = image + comp._loadedAtlasData = imageData + end + else + Theme._ErrorHandler:warn("Theme", "RES_001", { + context = errorContext, + path = resolvedPath, + error = tostring(loaderr), + }) + end + end + + -- Helper function to create regions from insets + local function createRegionsFromInsets(comp, fallbackAtlas) + local atlasImage = comp._loadedAtlas or fallbackAtlas + if not atlasImage or type(atlasImage) == "string" then + return + end + + local imgWidth, imgHeight = atlasImage:getDimensions() + local left = comp.insets.left or 0 + local top = comp.insets.top or 0 + local right = comp.insets.right or 0 + local bottom = comp.insets.bottom or 0 + + -- No offsets needed - guide border has been stripped for 9-patch images + local centerWidth = imgWidth - left - right + local centerHeight = imgHeight - top - bottom + + comp.regions = { + topLeft = { x = 0, y = 0, w = left, h = top }, + topCenter = { x = left, y = 0, w = centerWidth, h = top }, + topRight = { x = left + centerWidth, y = 0, w = right, h = top }, + middleLeft = { x = 0, y = top, w = left, h = centerHeight }, + middleCenter = { x = left, y = top, w = centerWidth, h = centerHeight }, + middleRight = { x = left + centerWidth, y = top, w = right, h = centerHeight }, + bottomLeft = { x = 0, y = top + centerHeight, w = left, h = bottom }, + bottomCenter = { x = left, y = top + centerHeight, w = centerWidth, h = bottom }, + bottomRight = { x = left + centerWidth, y = top + centerHeight, w = right, h = bottom }, + } + end + + -- Load component-specific atlases and process 9-patch definitions + for componentName, component in pairs(self.components) do + if component.atlas then + if type(component.atlas) == "string" then + loadAtlasWithNinePatch(component, component.atlas, "for component '" .. componentName .. "'") + else + -- Direct Image object (no ImageData available - scaleCorners won't work) + component._loadedAtlas = component.atlas + end + end + + if component.insets then + createRegionsFromInsets(component, self.atlas) + end + + if component.states then + for stateName, stateComponent in pairs(component.states) do + if stateComponent.atlas then + if type(stateComponent.atlas) == "string" then + loadAtlasWithNinePatch(stateComponent, stateComponent.atlas, "for state '" .. stateName .. "'") + else + -- Direct Image object (no ImageData available - scaleCorners won't work) + stateComponent._loadedAtlas = stateComponent.atlas + end + end + + if stateComponent.insets then + createRegionsFromInsets(stateComponent, component._loadedAtlas or self.atlas) + end + end + end + end + + -- Load scrollbar-specific atlases and process 9-patch definitions + -- Scrollbars can have 'bar' and 'frame' subcomponents + for scrollbarName, scrollbarDef in pairs(self.scrollbars) do + -- Handle scrollbar definitions with bar/frame subcomponents + if scrollbarDef.bar or scrollbarDef.frame then + -- Process 'bar' subcomponent + if scrollbarDef.bar then + if type(scrollbarDef.bar) == "string" then + -- Convert string path to ThemeComponent structure + local barComponent = { atlas = scrollbarDef.bar } + -- Copy knobOffset from parent scrollbarDef if it exists + if scrollbarDef.knobOffset then + barComponent.knobOffset = scrollbarDef.knobOffset + end + loadAtlasWithNinePatch(barComponent, scrollbarDef.bar, "for scrollbar '" .. scrollbarName .. ".bar'") + if barComponent.insets then + createRegionsFromInsets(barComponent, barComponent._loadedAtlas or self.atlas) + end + scrollbarDef.bar = barComponent + elseif type(scrollbarDef.bar) == "table" then + -- Already a ThemeComponent structure, process it + -- Copy knobOffset from parent if bar component doesn't have one + if scrollbarDef.knobOffset and not scrollbarDef.bar.knobOffset then + scrollbarDef.bar.knobOffset = scrollbarDef.knobOffset + end + if scrollbarDef.bar.atlas and type(scrollbarDef.bar.atlas) == "string" then + loadAtlasWithNinePatch( + scrollbarDef.bar, + scrollbarDef.bar.atlas, + "for scrollbar '" .. scrollbarName .. ".bar'" + ) + end + if scrollbarDef.bar.insets then + createRegionsFromInsets(scrollbarDef.bar, scrollbarDef.bar._loadedAtlas or self.atlas) + end + end + end + + -- Process 'frame' subcomponent + if scrollbarDef.frame then + if type(scrollbarDef.frame) == "string" then + -- Convert string path to ThemeComponent structure + local frameComponent = { atlas = scrollbarDef.frame } + loadAtlasWithNinePatch(frameComponent, scrollbarDef.frame, "for scrollbar '" .. scrollbarName .. ".frame'") + if frameComponent.insets then + createRegionsFromInsets(frameComponent, frameComponent._loadedAtlas or self.atlas) + end + scrollbarDef.frame = frameComponent + elseif type(scrollbarDef.frame) == "table" then + -- Already a ThemeComponent structure, process it + if scrollbarDef.frame.atlas and type(scrollbarDef.frame.atlas) == "string" then + loadAtlasWithNinePatch( + scrollbarDef.frame, + scrollbarDef.frame.atlas, + "for scrollbar '" .. scrollbarName .. ".frame'" + ) + end + if scrollbarDef.frame.insets then + createRegionsFromInsets(scrollbarDef.frame, scrollbarDef.frame._loadedAtlas or self.atlas) + end + end + end + else + -- Treat as a single ThemeComponent (no bar/frame split) + if scrollbarDef.atlas then + if type(scrollbarDef.atlas) == "string" then + loadAtlasWithNinePatch(scrollbarDef, scrollbarDef.atlas, "for scrollbar '" .. scrollbarName .. "'") + else + scrollbarDef._loadedAtlas = scrollbarDef.atlas + end + end + + if scrollbarDef.insets then + createRegionsFromInsets(scrollbarDef, self.atlas) + end + + if scrollbarDef.states then + for stateName, stateComponent in pairs(scrollbarDef.states) do + if stateComponent.atlas then + if type(stateComponent.atlas) == "string" then + loadAtlasWithNinePatch( + stateComponent, + stateComponent.atlas, + "for scrollbar '" .. scrollbarName .. "' state '" .. stateName .. "'" + ) + else + stateComponent._loadedAtlas = stateComponent.atlas + end + end + + if stateComponent.insets then + createRegionsFromInsets(stateComponent, scrollbarDef._loadedAtlas or self.atlas) + end + end + end + end + end + + return self +end + +--- Import a theme definition from a file to enable hot-reloading and modular design systems +--- Use this to load bundled or user-created themes dynamically +---@param path string Path to theme definition file (e.g., "space" or "mytheme") +---@return Theme? theme The loaded theme, or nil on error +function Theme.load(path) + local definition + local themePath = FLEXLOVE_BASE_PATH .. ".themes." .. path + + local success, result = pcall(function() + return require(themePath) + end) + if success then + definition = result + else + success, result = pcall(function() + return require(path) + end) + if success then + definition = result + else + Theme._ErrorHandler:warn("Theme", "RES_004", { + theme = path, + tried = themePath, + error = tostring(result), + fallback = "nil (no theme loaded)", + }) + return nil + end + end + + local theme = Theme.new(definition) + themes[theme.name] = theme + themes[path] = theme + + return theme +end + +--- Switch the global theme to instantly restyle all themed UI elements +--- Use this to implement light/dark mode toggles or user-selectable skins +---@param themeOrName Theme|string Theme instance or theme name to activate +function Theme.setActive(themeOrName) + if type(themeOrName) == "string" then + -- Try to load if not already loaded + if not themes[themeOrName] then + Theme.load(themeOrName) + end + activeTheme = themes[themeOrName] + else + activeTheme = themeOrName + end + + if not activeTheme then + Theme._ErrorHandler:warn("Theme", "THM_002", { + theme = tostring(themeOrName), + reason = "Theme not found or not loaded", + fallback = "current theme unchanged", + }) + -- Keep current activeTheme unchanged (fallback behavior) + end +end + +--- Access the current theme to query colors, fonts, or create theme-aware components +--- Use this to build UI that adapts to the active design system +---@return Theme? theme The active theme, or nil if none is active +function Theme.getActive() + return activeTheme +end + +--- Retrieve pre-configured visual styles for UI components to maintain consistency +--- Use this to apply theme definitions to custom elements +---@param componentName string Name of the component (e.g., "button", "panel") +---@param state string? Optional state (e.g., "hover", "pressed", "disabled") +---@return ThemeComponent? component Returns component or nil if not found +function Theme.getComponent(componentName, state) + if not activeTheme then + return nil + end + + local component = activeTheme.components[componentName] + if not component then + return nil + end + + -- Check for state-specific override + if state and component.states and component.states[state] then + return component.states[state] + end + + return component +end + +--- Get the first (default) scrollbar from the active theme +--- Returns the first scrollbar component in insertion order +---@return ThemeComponent? scrollbar Returns first scrollbar component or nil if no scrollbars defined +function Theme.getDefaultScrollbar() + if not activeTheme or not activeTheme.scrollbars then + return nil + end + + local _, scrollbar = next(activeTheme.scrollbars) + return scrollbar +end + +--- Retrieve themed scrollbar components for consistent scrollbar styling +--- Use this to apply theme-based scrollbar appearance to scrollable elements +---@param scrollbarName string? Name of the scrollbar style (e.g., "v1", "v2"). If nil, returns default (first) scrollbar +---@param state string? Optional state name (e.g., "hover", "pressed") - currently unused for scrollbars +---@return ThemeComponent? scrollbar Returns scrollbar component or nil if not found +function Theme.getScrollbar(scrollbarName, state) + if not activeTheme or not activeTheme.scrollbars then + return nil + end + + -- If no scrollbarName specified, return default (first) scrollbar + if not scrollbarName then + return Theme.getDefaultScrollbar() + end + + local scrollbar = activeTheme.scrollbars[scrollbarName] + if not scrollbar then + return nil + end + + -- Check for state-specific override (if scrollbar supports states in the future) + if state and scrollbar.states and scrollbar.states[state] then + return scrollbar.states[state] + end + + return scrollbar +end + +--- Access theme-defined fonts for consistent typography across your UI +--- Use this to load fonts specified in your theme definition +---@param fontName string Name of the font family (e.g., "default", "heading") +---@return string? fontPath Returns font path or nil if not found +function Theme.getFont(fontName) + if not activeTheme then + return nil + end + + return activeTheme.fonts and activeTheme.fonts[fontName] +end + +--- Retrieve semantic colors from the theme palette for consistent brand identity +--- Use this instead of hardcoding colors to support themeing and color scheme switches +---@param colorName string Name of the color (e.g., "primary", "secondary") +---@return Color? color Returns Color instance or nil if not found +function Theme.getColor(colorName) + if not activeTheme then + return nil + end + + return activeTheme.colors and activeTheme.colors[colorName] +end + +--- Check if a theme is currently active +---@return boolean active Returns true if a theme is active +function Theme.hasActive() + return activeTheme ~= nil +end + +--- Get all registered theme names +---@return string[] themeNames Array of theme names +function Theme.getRegisteredThemes() + local themeNames = {} + for name, _ in pairs(themes) do + table.insert(themeNames, name) + end + return themeNames +end + +--- Get all available color names from the active theme +---@return string[]? colorNames Array of color names, or nil if no theme active +function Theme.getColorNames() + if not activeTheme or not activeTheme.colors then + return nil + end + + local colorNames = {} + for name, _ in pairs(activeTheme.colors) do + table.insert(colorNames, name) + end + return colorNames +end + +--- Get all colors from the active theme +---@return table? colors Table of all colors, or nil if no theme active +function Theme.getAllColors() + if not activeTheme then + return nil + end + + return activeTheme.colors +end + +--- Safely get theme colors with guaranteed fallbacks to prevent missing color errors +--- Use this when you need a color value no matter what +---@param colorName string Name of the color to retrieve +---@param fallback Color? Fallback color if not found (default: white) +---@return Color color The color or fallback (guaranteed non-nil) +function Theme.getColorOrDefault(colorName, fallback) + local color = Theme.getColor(colorName) + if color then + return color + end + + return fallback or Theme._Color.new(1, 1, 1, 1) +end + +--- Get a theme by name +---@param themeName string Name of the theme +---@return Theme? theme Returns theme or nil if not found +function Theme.get(themeName) + return themes[themeName] +end + +-------------------------------------------------------------------------------- +-- ThemeManager: Instance-level theme state management +-------------------------------------------------------------------------------- + +---@class ThemeManager +local ThemeManager = {} +ThemeManager.__index = ThemeManager + +---Create a new ThemeManager instance +---@param config table Configuration options {theme: string?, themeComponent: string?, disabled: boolean?, active: boolean?, disableHighlight: boolean?, themeStateLock: boolean|string?, themeComponentDisabledStates: string[]?, scaleCorners: number?, scalingAlgorithm: string?} +---@return ThemeManager manager The new ThemeManager instance +function ThemeManager.new(config) + local self = setmetatable({}, ThemeManager) + + self.theme = config.theme + self.themeComponent = config.themeComponent + self.disabled = config.disabled or false + self.active = config.active or false + self.disableHighlight = config.disableHighlight + self.themeStateLock = config.themeStateLock or false + self.scaleCorners = config.scaleCorners + self.scalingAlgorithm = config.scalingAlgorithm + + -- Normalize themeComponentDisabledStates to a lookup set for O(1) checks + self.themeComponentDisabledStates = {} + if config.themeComponentDisabledStates then + for _, state in ipairs(config.themeComponentDisabledStates) do + if type(state) == "string" then + self.themeComponentDisabledStates[state] = true + end + end + end + + -- Set initial state based on themeStateLock + if self.themeStateLock == true or self.themeStateLock == "default" then + self._themeState = "normal" + elseif type(self.themeStateLock) == "string" then + self._themeState = self.themeStateLock + else + self._themeState = "normal" + end + + return self +end + +---Update the theme state based on element interaction state +---@param isHovered boolean Whether element is hovered +---@param isPressed boolean Whether element is pressed +---@param isFocused boolean Whether element is focused (keyboard focus) +---@param isDisabled boolean Whether element is disabled +---@return string state The new theme state ("normal", "hover", "pressed", "active", "disabled") +function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled) + -- If themeStateLock is set (and not false), use the locked state + if self.themeStateLock ~= false and self.themeStateLock ~= nil then + local lockedState + + if self.themeStateLock == true or self.themeStateLock == "default" then + -- true or "default" means lock to "normal" (base state) + lockedState = "normal" + elseif type(self.themeStateLock) == "string" then + -- String means lock to specific state + lockedState = self.themeStateLock + + -- Validate the locked state exists in the theme component (will be done during initialization) + -- For now, just use the string value + else + -- Invalid themeStateLock value, fall back to normal behavior + lockedState = nil + end + + if lockedState then + self._themeState = lockedState + return lockedState + end + end + + -- Normal behavior: calculate state based on interaction + -- Keyboard focus reuses the hover state so themes only need one visual variant. + -- Priority: disabled > active > pressed > hover/focus > normal + -- If a state is in themeComponentDisabledStates, fall through to the next lower-priority state. + local candidates = { + { state = "disabled", condition = isDisabled or self.disabled }, + { state = "active", condition = self.active }, + { state = "pressed", condition = isPressed }, + { state = "hover", condition = isHovered or isFocused }, + } + + local newState = "normal" + for _, candidate in ipairs(candidates) do + if candidate.condition and not self.themeComponentDisabledStates[candidate.state] then + newState = candidate.state + break + end + end + + self._themeState = newState + return newState +end + +---Get the current theme state +---@return string state The current theme state +function ThemeManager:getState() + return self._themeState +end + +---Set the theme state explicitly +---@param state string The theme state to set ("normal", "hover", "pressed", "active", "disabled") +function ThemeManager:setState(state) + if type(state) ~= "string" then + return + end + self._themeState = state +end + +---Check if a theme component is set +---@return boolean hasComponent True if a theme component is set +function ThemeManager:hasThemeComponent() + return self.themeComponent ~= nil +end + +---Get the theme (either instance-specific or active theme) +---@return Theme? theme The theme instance, or nil if not found +function ThemeManager:getTheme() + if self.theme then + return Theme.get(self.theme) + end + return Theme.getActive() +end + +---Get the base theme component +---@return ThemeComponent? component The theme component, or nil if not found +function ThemeManager:getComponent() + if not self.themeComponent then + return nil + end + + local themeToUse = self:getTheme() + if not themeToUse or not themeToUse.components or type(themeToUse.components) ~= "table" then + return nil + end + + if not themeToUse.components[self.themeComponent] then + return nil + end + + return themeToUse.components[self.themeComponent] +end + +---Get the theme component for the current state +---@return ThemeComponent? component The state-specific component, or base component, or nil +function ThemeManager:getStateComponent() + local component = self:getComponent() + if not component then + return nil + end + + local state = self._themeState + if + state + and state ~= "normal" + and component.states + and type(component.states) == "table" + and component.states[state] + then + return component.states[state] + end + + return component +end + +---Get a scrollbar component from the theme +---@param scrollbarName string? The scrollbar style name (e.g., "v1", "v2"). If nil, returns default (first) scrollbar +---@return ThemeComponent? scrollbar The scrollbar component, or nil if not found +function ThemeManager:getScrollbarComponent(scrollbarName) + local themeToUse = self:getTheme() + if not themeToUse or not themeToUse.scrollbars or type(themeToUse.scrollbars) ~= "table" then + return nil + end + + if not scrollbarName then + local _, scrollbar = next(themeToUse.scrollbars) + return scrollbar + end + + return themeToUse.scrollbars[scrollbarName] +end + +---Get a style property from the current state component +---@param property string The property name +---@return any? value The property value, or nil if not found +function ThemeManager:getStyle(property) + if type(property) ~= "string" then + return nil + end + + local stateComponent = self:getStateComponent() + if not stateComponent or type(stateComponent) ~= "table" then + return nil + end + + return stateComponent[property] +end + +---Get scaled content padding based on border box dimensions +---@param borderBoxWidth number The border box width +---@param borderBoxHeight number The border box height +---@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding +function ThemeManager:_getScaledContentPaddingForComponent(component, borderBoxWidth, borderBoxHeight) + if not component or not component._ninePatchData or not component._ninePatchData.contentPadding then + return nil + end + + local contentPadding = component._ninePatchData.contentPadding + local themeToUse = self:getTheme() + local atlasImage = component._loadedAtlas or (themeToUse and themeToUse.atlas) + + if atlasImage and type(atlasImage) ~= "string" then + local originalWidth, originalHeight = atlasImage:getDimensions() + + local insets = component.insets + if insets and type(insets) == "table" then + local cornerScale = self.scaleCorners + if cornerScale == nil then + cornerScale = component.scaleCorners + end + if type(cornerScale) ~= "number" or cornerScale <= 0 then + cornerScale = 1 + end + + local function mapDistanceFromStart(sourceDistance, sourceSize, targetSize, sourceStartInset, sourceEndInset) + local sourceStart = sourceStartInset or 0 + local sourceEnd = sourceEndInset or 0 + + local sourceCenter = math.max(0, sourceSize - sourceStart - sourceEnd) + local targetStart = sourceStart * cornerScale + local targetEnd = sourceEnd * cornerScale + local targetCenter = math.max(0, targetSize - targetStart - targetEnd) + + if sourceDistance <= sourceStart then + return sourceDistance * cornerScale + end + + if sourceDistance >= (sourceSize - sourceEnd) then + local distanceFromEnd = sourceSize - sourceDistance + return targetSize - (distanceFromEnd * cornerScale) + end + + if sourceCenter <= 0 then + return targetStart + end + + local t = (sourceDistance - sourceStart) / sourceCenter + return targetStart + (t * targetCenter) + end + + local left = mapDistanceFromStart(contentPadding.left, originalWidth, borderBoxWidth, insets.left, insets.right) + local rightBoundary = mapDistanceFromStart( + originalWidth - contentPadding.right, + originalWidth, + borderBoxWidth, + insets.left, + insets.right + ) + local right = borderBoxWidth - rightBoundary + + local top = mapDistanceFromStart(contentPadding.top, originalHeight, borderBoxHeight, insets.top, insets.bottom) + local bottomBoundary = mapDistanceFromStart( + originalHeight - contentPadding.bottom, + originalHeight, + borderBoxHeight, + insets.top, + insets.bottom + ) + local bottom = borderBoxHeight - bottomBoundary + + return { + left = math.max(0, left), + top = math.max(0, top), + right = math.max(0, right), + bottom = math.max(0, bottom), + } + end + + local scaleX = borderBoxWidth / originalWidth + local scaleY = borderBoxHeight / originalHeight + return { + left = contentPadding.left * scaleX, + top = contentPadding.top * scaleY, + right = contentPadding.right * scaleX, + bottom = contentPadding.bottom * scaleY, + } + end + + return nil +end + +---Get scaled content padding for a specific theme state +---@param state string The theme state to resolve (e.g. "normal", "pressed") +---@param borderBoxWidth number The border box width +---@param borderBoxHeight number The border box height +---@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding +function ThemeManager:_getScaledContentPaddingForState(state, borderBoxWidth, borderBoxHeight) + if not self.themeComponent then + return nil + end + + local themeToUse = self:getTheme() + if not themeToUse or not themeToUse.components[self.themeComponent] then + return nil + end + + local component = themeToUse.components[self.themeComponent] + + local stateToUse = state or "normal" + if stateToUse ~= "normal" and component.states and component.states[stateToUse] then + component = component.states[stateToUse] + end + + return self:_getScaledContentPaddingForComponent(component, borderBoxWidth, borderBoxHeight) +end + +---@param state string The theme state to resolve (e.g. "normal", "pressed") +---@param borderBoxWidth number The border box width +---@param borderBoxHeight number The border box height +---@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding +function ThemeManager:getScaledContentPaddingForState(state, borderBoxWidth, borderBoxHeight) + Theme._ErrorHandler:warnDeprecated("Theme", "getScaledContentPaddingForState", "getScaledContentPadding") + return self:getScaledContentPadding(borderBoxWidth, borderBoxHeight) +end + +---Get scaled content padding based on current theme state and border box dimensions +---@param borderBoxWidth number The border box width +---@param borderBoxHeight number The border box height +---@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding +function ThemeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight) + local state = self._themeState or "normal" + return self:_getScaledContentPaddingForState(state, borderBoxWidth, borderBoxHeight) +end + +---Get content auto-sizing multiplier from theme or component +---@return table? multiplier Table with {width: number?, height: number?}, or nil if not defined +function ThemeManager:getContentAutoSizingMultiplier() + if not self.themeComponent then + return nil + end + + local themeToUse = self:getTheme() + if not themeToUse then + return nil + end + + if self.themeComponent and themeToUse.components and type(themeToUse.components) == "table" then + local component = themeToUse.components[self.themeComponent] + if component and component.contentAutoSizingMultiplier then + return component.contentAutoSizingMultiplier + elseif themeToUse.contentAutoSizingMultiplier then + return themeToUse.contentAutoSizingMultiplier + end + end + + if themeToUse.contentAutoSizingMultiplier then + return themeToUse.contentAutoSizingMultiplier + end + + return nil +end + +---Get the default font family path from the theme +---@return string? fontPath The default font path, or nil if not defined +function ThemeManager:getDefaultFontFamily() + local themeToUse = self:getTheme() + if themeToUse and themeToUse.fonts and type(themeToUse.fonts) == "table" and themeToUse.fonts["default"] then + return themeToUse.fonts["default"] + end + return nil +end + +---Set the theme and component for this ThemeManager +---@param themeName string? The theme name to use (nil to use active theme) +---@param componentName string? The component name to use +function ThemeManager:setTheme(themeName, componentName) + self.theme = themeName + self.themeComponent = componentName +end + +---Validate themeStateLock and warn if invalid +---@return boolean isValid True if themeStateLock is valid or false/nil +function ThemeManager:validateThemeStateLock() + -- false or nil is always valid (no lock) + if not self.themeStateLock or self.themeStateLock == false then + return true + end + + -- true is always valid (lock to normal) + if self.themeStateLock == true then + return true + end + + -- String value needs validation + if type(self.themeStateLock) == "string" then + -- "default" is always valid (lock to normal/base state) + if self.themeStateLock == "default" then + return true + end + + local component = self:getComponent() + + -- If no component, warn that themeStateLock has no effect + if not component then + if self.themeComponent then + Theme._ErrorHandler:warn("Theme", "THM_007", { + themeComponent = self.themeComponent, + reason = "themeStateLock has no effect without a valid theme component", + }) + end + self.themeStateLock = false + return false + end + + -- Check if component has any states at all + if not component.states or type(component.states) ~= "table" or next(component.states) == nil then + Theme._ErrorHandler:warn("Theme", "THM_008", { + themeComponent = self.themeComponent, + reason = "Theme component has no state variants, themeStateLock has no effect", + }) + self.themeStateLock = false + return false + end + + -- Check if the specified state exists + if not component.states[self.themeStateLock] then + -- Warn and fall back to false (no lock) + Theme._ErrorHandler:warn("Theme", "THM_009", { + themeComponent = self.themeComponent, + requestedState = self.themeStateLock, + availableStates = table.concat(self:_getAvailableStates(component), ", "), + fallback = "themeStateLock disabled (using dynamic state)", + }) + self.themeStateLock = false + return false + end + + return true + end + + -- Invalid type for themeStateLock + Theme._ErrorHandler:warn("Theme", "THM_010", { + themeStateLockType = type(self.themeStateLock), + reason = "themeStateLock must be boolean or string", + fallback = "themeStateLock disabled", + }) + self.themeStateLock = false + return false +end + +---Get available state names for a component +---@param component ThemeComponent The component to check +---@return table stateNames Array of state names +function ThemeManager:_getAvailableStates(component) + local states = {} + if component and component.states and type(component.states) == "table" then + for stateName, _ in pairs(component.states) do + table.insert(states, stateName) + end + end + return states +end + +Theme.Manager = ThemeManager + +--- Check theme definitions for correctness before use to catch configuration errors early +--- Use this during development to verify custom themes are properly structured +---@param theme table? The theme to validate +---@param options table? Optional validation options {strict: boolean} +---@return boolean valid, table errors List of validation errors +function Theme.validateTheme(theme, options) + local errors = {} + options = options or {} + + -- Basic structure validation + if theme == nil then + table.insert(errors, "Theme is nil") + return false, errors + end + + if type(theme) ~= "table" then + table.insert(errors, "Theme must be a table") + return false, errors + end + + -- Name validation (only required field) + if not theme.name then + table.insert(errors, "Theme must have a 'name' field") + elseif type(theme.name) ~= "string" then + table.insert(errors, "Theme 'name' must be a string") + elseif theme.name == "" then + table.insert(errors, "Theme 'name' cannot be empty") + end + + -- Colors validation (optional, but if present must be valid) + if theme.colors ~= nil then + if type(theme.colors) ~= "table" then + table.insert(errors, "Theme 'colors' must be a table") + else + for colorName, colorValue in pairs(theme.colors) do + if type(colorName) ~= "string" then + table.insert(errors, "Color name must be a string, got " .. type(colorName)) + else + -- Accept Color objects, hex strings, or named colors + local colorType = type(colorValue) + if colorType == "table" then + -- Assume it's a Color object if it has r,g,b fields + if not (colorValue.r and colorValue.g and colorValue.b) then + table.insert(errors, "Color '" .. colorName .. "' is not a valid Color object") + end + elseif colorType == "string" then + -- Validate color string + local isValid, err = Theme._Color.validateColor(colorValue) + if not isValid then + table.insert(errors, "Color '" .. colorName .. "': " .. err) + end + else + table.insert(errors, "Color '" .. colorName .. "' must be a Color object or string") + end + end + end + end + end + + -- Fonts validation (optional) + if theme.fonts ~= nil then + if type(theme.fonts) ~= "table" then + table.insert(errors, "Theme 'fonts' must be a table") + else + for fontName, fontPath in pairs(theme.fonts) do + if type(fontName) ~= "string" then + table.insert(errors, "Font name must be a string, got " .. type(fontName)) + elseif type(fontPath) ~= "string" then + table.insert(errors, "Font '" .. fontName .. "' path must be a string") + end + end + end + end + + -- Components validation (optional) + if theme.components ~= nil then + if type(theme.components) ~= "table" then + table.insert(errors, "Theme 'components' must be a table") + else + for componentName, component in pairs(theme.components) do + if type(component) == "table" then + -- Validate atlas if present + if component.atlas ~= nil and type(component.atlas) ~= "string" then + table.insert(errors, "Component '" .. componentName .. "' atlas must be a string") + end + + -- Validate insets if present + if component.insets ~= nil then + if type(component.insets) ~= "table" then + table.insert(errors, "Component '" .. componentName .. "' insets must be a table") + else + -- If insets are provided, all 4 sides must be present + for _, side in ipairs({ "left", "top", "right", "bottom" }) do + if component.insets[side] == nil then + table.insert(errors, "Component '" .. componentName .. "' insets must have '" .. side .. "' field") + elseif type(component.insets[side]) ~= "number" then + table.insert(errors, "Component '" .. componentName .. "' insets." .. side .. " must be a number") + elseif component.insets[side] < 0 then + table.insert(errors, "Component '" .. componentName .. "' insets." .. side .. " must be non-negative") + end + end + end + end + + -- Validate states if present + if component.states ~= nil then + if type(component.states) ~= "table" then + table.insert(errors, "Component '" .. componentName .. "' states must be a table") + else + for stateName, stateComponent in pairs(component.states) do + if type(stateComponent) ~= "table" then + table.insert( + errors, + "Component '" .. componentName .. "' state '" .. stateName .. "' must be a table" + ) + end + end + end + end + + -- Validate scaleCorners if present + if component.scaleCorners ~= nil then + if type(component.scaleCorners) ~= "number" then + table.insert(errors, "Component '" .. componentName .. "' scaleCorners must be a number") + elseif component.scaleCorners <= 0 then + table.insert(errors, "Component '" .. componentName .. "' scaleCorners must be positive") + end + end + + -- Validate scalingAlgorithm if present + if component.scalingAlgorithm ~= nil then + if type(component.scalingAlgorithm) ~= "string" then + table.insert(errors, "Component '" .. componentName .. "' scalingAlgorithm must be a string") + elseif component.scalingAlgorithm ~= "nearest" and component.scalingAlgorithm ~= "bilinear" then + table.insert( + errors, + "Component '" .. componentName .. "' scalingAlgorithm must be 'nearest' or 'bilinear'" + ) + end + end + end + end + end + end + + -- Scrollbars validation (optional) + if theme.scrollbars ~= nil then + if type(theme.scrollbars) ~= "table" then + table.insert(errors, "Theme 'scrollbars' must be a table") + else + for scrollbarName, scrollbarDef in pairs(theme.scrollbars) do + if type(scrollbarDef) == "table" then + -- Check if it has bar/frame subcomponents + if scrollbarDef.bar or scrollbarDef.frame then + -- Validate bar subcomponent + if scrollbarDef.bar ~= nil then + if type(scrollbarDef.bar) ~= "string" and type(scrollbarDef.bar) ~= "table" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' bar must be a string or table") + end + end + -- Validate frame subcomponent + if scrollbarDef.frame ~= nil then + if type(scrollbarDef.frame) ~= "string" and type(scrollbarDef.frame) ~= "table" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' frame must be a string or table") + end + end + else + -- Validate as a single ThemeComponent + -- Validate atlas if present + if scrollbarDef.atlas ~= nil and type(scrollbarDef.atlas) ~= "string" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' atlas must be a string") + end + + -- Validate insets if present + if scrollbarDef.insets ~= nil then + if type(scrollbarDef.insets) ~= "table" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets must be a table") + else + for _, side in ipairs({ "left", "top", "right", "bottom" }) do + if scrollbarDef.insets[side] == nil then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets must have '" .. side .. "' field") + elseif type(scrollbarDef.insets[side]) ~= "number" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets." .. side .. " must be a number") + elseif scrollbarDef.insets[side] < 0 then + table.insert( + errors, + "Scrollbar '" .. scrollbarName .. "' insets." .. side .. " must be non-negative" + ) + end + end + end + end + + -- Validate states if present + if scrollbarDef.states ~= nil then + if type(scrollbarDef.states) ~= "table" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' states must be a table") + else + for stateName, stateComponent in pairs(scrollbarDef.states) do + if type(stateComponent) ~= "table" then + table.insert( + errors, + "Scrollbar '" .. scrollbarName .. "' state '" .. stateName .. "' must be a table" + ) + end + end + end + end + end + end + end + end + end + + -- contentAutoSizingMultiplier validation (optional) + if theme.contentAutoSizingMultiplier ~= nil then + if type(theme.contentAutoSizingMultiplier) ~= "table" then + table.insert(errors, "Theme 'contentAutoSizingMultiplier' must be a table") + else + if theme.contentAutoSizingMultiplier.width ~= nil then + if type(theme.contentAutoSizingMultiplier.width) ~= "number" then + table.insert(errors, "contentAutoSizingMultiplier.width must be a number") + elseif theme.contentAutoSizingMultiplier.width <= 0 then + table.insert(errors, "contentAutoSizingMultiplier.width must be positive") + end + end + if theme.contentAutoSizingMultiplier.height ~= nil then + if type(theme.contentAutoSizingMultiplier.height) ~= "number" then + table.insert(errors, "contentAutoSizingMultiplier.height must be a number") + elseif theme.contentAutoSizingMultiplier.height <= 0 then + table.insert(errors, "contentAutoSizingMultiplier.height must be positive") + end + end + end + end + + -- Global atlas validation (optional) + if theme.atlas ~= nil then + if type(theme.atlas) ~= "string" then + table.insert(errors, "Theme 'atlas' must be a string") + end + end + + -- Strict mode: warn about unknown fields + if options.strict then + local knownFields = { + name = true, + atlas = true, + components = true, + scrollbars = true, + colors = true, + fonts = true, + contentAutoSizingMultiplier = true, + } + for field in pairs(theme) do + if not knownFields[field] then + table.insert(errors, "Unknown field '" .. field .. "' in theme") + end + end + end + + return #errors == 0, errors +end + +--- Clean up malformed theme data to make it usable without crashing +--- Use this to robustly handle user-created or external themes +---@param theme table? The theme to sanitize +---@return table sanitized The sanitized theme +function Theme.sanitizeTheme(theme) + local sanitized = {} + + -- Handle nil theme + if theme == nil then + return { name = "Invalid Theme" } + end + + -- Handle non-table theme + if type(theme) ~= "table" then + return { name = "Invalid Theme" } + end + + -- Sanitize name + if type(theme.name) == "string" and theme.name ~= "" then + sanitized.name = theme.name + else + sanitized.name = "Unnamed Theme" + end + + -- Sanitize colors + if type(theme.colors) == "table" then + sanitized.colors = {} + for colorName, colorValue in pairs(theme.colors) do + if type(colorName) == "string" then + local colorType = type(colorValue) + if colorType == "table" and colorValue.r and colorValue.g and colorValue.b then + -- Valid Color object + sanitized.colors[colorName] = colorValue + elseif colorType == "string" then + -- Try to validate color string + local isValid = Theme._Color.validateColor(colorValue) + if isValid then + sanitized.colors[colorName] = colorValue + else + -- Provide fallback color + sanitized.colors[colorName] = Theme._Color.new(0, 0, 0, 1) + end + end + end + end + end + + -- Sanitize fonts + if type(theme.fonts) == "table" then + sanitized.fonts = {} + for fontName, fontPath in pairs(theme.fonts) do + if type(fontName) == "string" and type(fontPath) == "string" then + sanitized.fonts[fontName] = fontPath + end + end + end + + -- Sanitize components (preserve as-is, they're complex) + if type(theme.components) == "table" then + sanitized.components = theme.components + end + + -- Sanitize scrollbars (preserve as-is, they're complex like components) + if type(theme.scrollbars) == "table" then + sanitized.scrollbars = theme.scrollbars + end + + -- Sanitize contentAutoSizingMultiplier + if type(theme.contentAutoSizingMultiplier) == "table" then + sanitized.contentAutoSizingMultiplier = {} + if type(theme.contentAutoSizingMultiplier.width) == "number" and theme.contentAutoSizingMultiplier.width > 0 then + sanitized.contentAutoSizingMultiplier.width = theme.contentAutoSizingMultiplier.width + end + if type(theme.contentAutoSizingMultiplier.height) == "number" and theme.contentAutoSizingMultiplier.height > 0 then + sanitized.contentAutoSizingMultiplier.height = theme.contentAutoSizingMultiplier.height + end + end + + -- Sanitize atlas + if type(theme.atlas) == "string" then + sanitized.atlas = theme.atlas + end + + return sanitized +end + +return Theme diff --git a/libs/flexlove/modules/UTF8.lua b/libs/flexlove/modules/UTF8.lua new file mode 100644 index 00000000..ba2adc95 --- /dev/null +++ b/libs/flexlove/modules/UTF8.lua @@ -0,0 +1,44 @@ +---@class UTF8 +---Compatibility layer for UTF-8 support across Lua versions +---Handles utf8 (Lua 5.3+), lua-utf8 (LuaRocks), and basic fallbacks + +local UTF8 = {} + +-- Try to load UTF-8 library in order of preference: +-- 1. Built-in utf8 (Lua 5.3+, LÖVE2D) +-- 2. lua-utf8 from LuaRocks (Lua 5.1, 5.2) +-- 3. Error if neither available +local function loadUTF8() + -- Try built-in utf8 first (Lua 5.3+ and LÖVE2D) + if utf8 and type(utf8) == "table" and utf8.len then + return utf8 + end + + -- Try lua-utf8 from LuaRocks + local ok, luautf8 = pcall(require, "lua-utf8") + if ok then + return luautf8 + end + + -- Try standard utf8 module name as fallback + ok, luautf8 = pcall(require, "utf8") + if ok then + return luautf8 + end + + -- No UTF-8 library available + error("No UTF-8 library available. Please install 'luautf8' via LuaRocks: luarocks install luautf8") +end + +-- Load the UTF-8 implementation +local utf8lib = loadUTF8() + +-- Export all utf8 functions +UTF8.char = utf8lib.char +UTF8.charpattern = utf8lib.charpattern +UTF8.codes = utf8lib.codes +UTF8.codepoint = utf8lib.codepoint +UTF8.len = utf8lib.len +UTF8.offset = utf8lib.offset + +return UTF8 diff --git a/libs/flexlove/modules/Units.lua b/libs/flexlove/modules/Units.lua new file mode 100644 index 00000000..aa25707f --- /dev/null +++ b/libs/flexlove/modules/Units.lua @@ -0,0 +1,335 @@ +--- Utility module for parsing and resolving CSS-like units (px, %, vw, vh) +--- Provides unit parsing, validation, and conversion to pixel values +---@class Units +---@field _Context table? Context module dependency +---@field _ErrorHandler table? ErrorHandler module dependency +---@field _Calc table? Calc module dependency +local Units = {} + +--- Initialize Units module with dependencies +---@param deps table Dependencies: { Context = table?, ErrorHandler = table?, Calc = table? } +function Units.init(deps) + Units._Context = deps.Context + Units._ErrorHandler = deps.ErrorHandler + Units._Calc = deps.Calc +end + +--- Parse a unit value into numeric value and unit type +--- Supports: px (pixels), % (percentage), vw/vh (viewport), and calc() expressions +---@param value string|number|table The value to parse (e.g., "50px", "10%", "2vw", 100, or calc object) +---@return number|table numericValue The numeric portion of the value or calc object +---@return string unitType The unit type ("px", "%", "vw", "vh", "calc") +function Units.parse(value) + -- Check if value is a calc expression + if Units._Calc and Units._Calc.isCalc(value) then + return value, "calc" + end + + if type(value) == "number" then + return value, "px" + end + + if type(value) ~= "string" and type(value) ~= "table" then + Units._ErrorHandler:warn("Units", "VAL_001", { + property = "unit value", + expected = "string, number, or calc object", + got = type(value), + }) + return 0, "px" + end + + -- Check for unit-only input (e.g., "px", "%", "vw" without a number) + local validUnits = { px = true, ["%"] = true, vw = true, vh = true } + if validUnits[value] then + Units._ErrorHandler:warn("Units", "VAL_005", { + input = value, + expected = "number + unit (e.g., '50" .. value .. "')", + }) + return 0, "px" + end + + -- Check for invalid format (space between number and unit) + if value:match("%d%s+%a") then + Units._ErrorHandler:warn("Units", "VAL_005", { + input = value, + issue = "contains space between number and unit", + }) + return 0, "px" + end + + -- Match number followed by optional unit + local numStr, unit = value:match("^([%-]?[%d%.]+)(.*)$") + if not numStr then + Units._ErrorHandler:warn("Units", "VAL_005", { + input = value, + }) + return 0, "px" + end + + local num = tonumber(numStr) + if not num then + Units._ErrorHandler:warn("Units", "VAL_005", { + input = value, + issue = "numeric value cannot be parsed", + }) + return 0, "px" + end + + -- Default to pixels if no unit specified + if unit == "" then + unit = "px" + end + + -- validUnits is already defined at the top of the function + if not validUnits[unit] then + Units._ErrorHandler:warn("Units", "VAL_005", { + input = value, + unit = unit, + validUnits = "px, %, vw, vh", + }) + return num, "px" + end + + return num, unit +end + +--- Convert relative units to absolute pixel values +--- Resolves %, vw, vh units based on viewport and parent dimensions, and evaluates calc() expressions +---@param value number|table Numeric value to convert or calc object +---@param unit string Unit type ("px", "%", "vw", "vh", "calc") +---@param viewportWidth number Current viewport width in pixels +---@param viewportHeight number Current viewport height in pixels +---@param parentSize number? Required for percentage units (parent dimension in pixels) +---@return number resolvedValue Resolved pixel value +function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize) + if unit == "calc" then + -- Resolve calc expression + if Units._Calc then + return Units._Calc.resolve(value, viewportWidth, viewportHeight, parentSize) + else + Units._ErrorHandler:warn("Units", "VAL_006", { + unit = "calc", + issue = "Calc module not available", + }) + return 0 + end + elseif unit == "px" then + return value + elseif unit == "%" then + if not parentSize then + Units._ErrorHandler:warn("Units", "LAY_003", { + unit = "%", + issue = "parent dimension not available", + }) + return 0 + end + return (value / 100) * parentSize + elseif unit == "vw" then + return (value / 100) * viewportWidth + elseif unit == "vh" then + return (value / 100) * viewportHeight + else + Units._ErrorHandler:warn("Units", "VAL_005", { + unit = unit, + validUnits = "px, %, vw, vh, calc", + }) + return 0 + end +end + +--- Get current viewport dimensions +--- Uses cached viewport during resize operations, otherwise queries LÖVE graphics +---@return number width Viewport width in pixels +---@return number height Viewport height in pixels +function Units.getViewport() + -- Return cached viewport if available (only during resize operations) + if Units._Context._cachedViewport and Units._Context._cachedViewport.width > 0 then + return Units._Context._cachedViewport.width, Units._Context._cachedViewport.height + end + + if love.graphics and love.graphics.getDimensions then + return love.graphics.getDimensions() + else + local w, h = love.window.getMode() + return w, h + end +end + +--- Apply base scale factor to a value based on axis +--- Used for responsive scaling of UI elements +---@param value number The value to scale +---@param axis "x"|"y" The axis to scale on +---@param scaleFactors {x:number, y:number} Scale factors for each axis +---@return number scaledValue The scaled value +function Units.applyBaseScale(value, axis, scaleFactors) + if axis == "x" then + return value * scaleFactors.x + else + return value * scaleFactors.y + end +end + +--- Resolve spacing properties (margin, padding) to pixel values +--- Supports individual sides (top, right, bottom, left) and shortcuts (vertical, horizontal) +---@param spacingProps table? Spacing properties with top/right/bottom/left/vertical/horizontal +---@param parentWidth number Parent element width in pixels +---@param parentHeight number Parent element height in pixels +---@return table resolvedSpacing Table with top, right, bottom, left in pixels +function Units.resolveSpacing(spacingProps, parentWidth, parentHeight) + if not spacingProps then + return { top = 0, right = 0, bottom = 0, left = 0 } + end + + local viewportWidth, viewportHeight = Units.getViewport() + local result = {} + + local vertical = spacingProps.vertical + local horizontal = spacingProps.horizontal + + if vertical then + if type(vertical) == "string" or (Units._Calc and Units._Calc.isCalc(vertical)) then + local value, unit = Units.parse(vertical) + vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) + end + end + + if horizontal then + if type(horizontal) == "string" or (Units._Calc and Units._Calc.isCalc(horizontal)) then + local value, unit = Units.parse(horizontal) + horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) + end + end + + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + local value = spacingProps[side] + if value then + if type(value) == "string" or (Units._Calc and Units._Calc.isCalc(value)) then + local numValue, unit = Units.parse(value) + local parentSize = (side == "top" or side == "bottom") and parentHeight or parentWidth + result[side] = Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize) + else + result[side] = value + end + else + if side == "top" or side == "bottom" then + result[side] = vertical or 0 + else + result[side] = horizontal or 0 + end + end + end + + return result +end + +--- Validate a unit string format +--- Checks if the string can be successfully parsed as a valid unit or calc expression +---@param unitStr string|table The unit string to validate (e.g., "50px", "10%") or calc object +---@return boolean isValid True if the unit string is valid, false otherwise +function Units.isValid(unitStr) + -- Check if it's a calc expression + if Units._Calc and Units._Calc.isCalc(unitStr) then + return true + end + + if type(unitStr) ~= "string" then + return false + end + + -- Check for invalid format (space between number and unit) + if unitStr:match("%d%s+%a") then + return false + end + + -- Match number followed by optional unit + local numStr, unit = unitStr:match("^([%-]?[%d%.]+)(.*)$") + if not numStr then + return false + end + + -- Check if numeric part is valid + local num = tonumber(numStr) + if not num then + return false + end + + -- Default to pixels if no unit specified + if unit == "" then + unit = "px" + end + + -- Check if unit is valid + local validUnits = { px = true, ["%"] = true, vw = true, vh = true } + return validUnits[unit] == true +end + +--- Parse CSS flex shorthand into flexGrow, flexShrink, flexBasis +--- Supports: number, "auto", "none", "grow shrink basis" +---@param flexValue number|string The flex shorthand value +---@return number flexGrow +---@return number flexShrink +---@return string|number flexBasis +function Units.parseFlexShorthand(flexValue) + -- Single number: flex-grow + if type(flexValue) == "number" then + return flexValue, 1, 0 + end + + -- String values + if type(flexValue) == "string" then + -- "auto" = 1 1 auto + if flexValue == "auto" then + return 1, 1, "auto" + end + + -- "none" = 0 0 auto + if flexValue == "none" then + return 0, 0, "auto" + end + + -- Parse "grow shrink basis" format + local parts = {} + for part in flexValue:gmatch("%S+") do + table.insert(parts, part) + end + + local grow = 0 + local shrink = 1 + local basis = "auto" + + if #parts == 1 then + -- Single value: could be grow (number) or basis (with unit) + local num = tonumber(parts[1]) + if num then + grow = num + basis = 0 + else + basis = parts[1] + end + elseif #parts == 2 then + -- Two values: grow shrink (both numbers) or grow basis + local num1 = tonumber(parts[1]) + local num2 = tonumber(parts[2]) + if num1 and num2 then + grow = num1 + shrink = num2 + basis = 0 + elseif num1 then + grow = num1 + basis = parts[2] + end + elseif #parts >= 3 then + -- Three values: grow shrink basis + grow = tonumber(parts[1]) or 0 + shrink = tonumber(parts[2]) or 1 + basis = parts[3] + end + + return grow, shrink, basis + end + + -- Default fallback + return 0, 1, "auto" +end + +return Units diff --git a/libs/flexlove/modules/ZIndex.lua b/libs/flexlove/modules/ZIndex.lua new file mode 100644 index 00000000..e10bdf56 --- /dev/null +++ b/libs/flexlove/modules/ZIndex.lua @@ -0,0 +1,35 @@ +---@class ZIndex +local ZIndex = {} + +-- The effective z-index formula used for sorting is: +-- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ +-- where rootZ is the z-index of the top-level ancestor, depth is the +-- nesting level, and ownZ is the element's own z property. +-- +-- Constraints enforced by these weights: +-- |ownZ| <= MAX_Z (must fit within DEPTH_WEIGHT digits) +-- DEPTH_WEIGHT has enough room for depths well beyond any practical tree +-- ROOT_WEIGHT has enough room for the rootZ without exceeding double-precision +--- +---@type integer +ZIndex.MIN_Z = -999 +---@type integer +ZIndex.MAX_Z = 999 +---@type integer +ZIndex.ROOT_WEIGHT = 10000000000 +---@type integer +ZIndex.DEPTH_WEIGHT = 1000 + +--- Clamp a z-index value to the valid range +---@param value number +---@return integer +function ZIndex.clamp(value) + if value < ZIndex.MIN_Z then + return ZIndex.MIN_Z + elseif value > ZIndex.MAX_Z then + return ZIndex.MAX_Z + end + return value +end + +return ZIndex diff --git a/libs/flexlove/modules/behaviors/Animated.lua b/libs/flexlove/modules/behaviors/Animated.lua new file mode 100644 index 00000000..16592025 --- /dev/null +++ b/libs/flexlove/modules/behaviors/Animated.lua @@ -0,0 +1,245 @@ +-- modules/behaviors/Animated.lua +-- +-- Concrete behavior: animation update, interpolation application, chaining +-- resolution, and transition wiring. +-- +-- Task 06 of the behavior-mode-unification refactor. Moves the entire +-- animation-update block out of Element:update (lines ~2761-2800) into +-- `Animated.onUpdate(element, dt)`, and the `_ColorModule`/`_TransformModule` +-- init-time wiring into `Animated.onAttach(element)`. +-- +-- This behavior is UNIQUE among the behavior set because it can attach +-- AFTER element creation. Animation is opt-in: a plain Element created without +-- `transitions` and without an `animation` field never attaches Animated. +-- The moment something creates an animation on the element — either directly +-- (`element.animation = Animation.new(...)`, `element:fadeIn(...)`) or via a +-- transition firing in `setProperty` — `Animated.ensureAttached(element)` +-- attaches this behavior on demand so subsequent `Element:update` frames +-- dispatch to `Animated.onUpdate`. +-- +-- Attachment rule (shouldAttach): true when `props.transitions` is set OR an +-- `element.animation` already exists at runtime. The runtime arm covers the +-- late-attach case (animateTo / fadeIn / direct animation assignment). +-- +-- State ownership (per the locked Behavior contract): +-- * Per-element runtime state lives ON THE ELEMENT (`element.animation`). +-- * The behavior instance itself is stateless and shared across elements. +-- * Element-class-level dependencies (Element._Animation, Element._Color, +-- Element._Transform) are resolved from the owning element's metatable, +-- exactly like Clickable does — keeping the behavior stateless without +-- expanding the 6-hook signature. +-- +-- saveState/restoreState are no-ops: animations are ephemeral (an in-flight +-- animation is not part of immediate-mode persisted state — the next frame +-- re-evaluates transitions / re-applies animations fresh). Persisted scalar +-- props (`opacity`, `x`, ...) survive via Element.saveState's `_props` block, +-- not via the animation. + +local _pkg = (...):match("^(.-)behaviors%.") or "modules." +local Behavior = require(_pkg .. "Behavior") + +-- Resolve the Element class from an element instance. +-- Element instances are created via `setmetatable({}, Element)` in _construct, +-- so their metatable IS the Element class — giving us Element._Animation, +-- Element._Color, Element._Transform, etc. without threading deps through the +-- behavior hook signature. +local function ElementClass(element) + return getmetatable(element) +end + +-- ---------------------------------------------------------------------------- +-- ensureAnimationModuleWiring — set Element._Animation._ColorModule / +-- _TransformModule. Idempotent; called from both onAttach and onUpdate so it +-- works even when an animation was assigned by a caller that bypassed +-- onAttach (direct `element.animation = Animation.new(...)`). +-- ---------------------------------------------------------------------------- + +local function ensureAnimationModuleWiring(element) + local Element = ElementClass(element) + local Animation = Element._Animation + if not Animation then + return + end + -- Ensure animation has Color module reference for color interpolation + if not Animation._ColorModule and Element._Color then + Animation._ColorModule = Element._Color + end + -- Ensure animation has Transform module reference for transform interpolation + if not Animation._TransformModule and Element._Transform then + Animation._TransformModule = Element._Transform + end +end + +-- ---------------------------------------------------------------------------- +-- shouldAttach (class-level predicate, no element required) +-- ---------------------------------------------------------------------------- + +-- True when the element declares transitions up front OR already has an +-- animation attached. The `animation` arm is consulted by ensureAttached at +-- runtime (after creation); the `transitions` arm lets Animated auto-attach +-- during Element.new for elements that pre-declare transitions. +local function shouldAttach(props) + if not props then + return false + end + if props.transitions ~= nil then + return true + end + -- Late-attach case: an animation was assigned after creation. When ensure + -- Attached passes the element instance as `props`, this arm catches it. + if type(props) == "table" and props.animation ~= nil then + return true + end + return false +end + +-- ---------------------------------------------------------------------------- +-- ensureAttached — dynamic late-attach entry point +-- ---------------------------------------------------------------------------- + +-- Idempotently attach the Animated behavior to an element that just gained an +-- animation (via animateTo / fadeIn / direct assignment / a firing transition +-- in setProperty). Called from Element.setProperty when a transition fires and +-- from the transition helper methods on Element. Safe to call when already +-- attached (no-op / returns false). +-- +-- `animatedBehavior` is the shared behavior instance resolved lazily by +-- Element (see Element._resolveAnimatedBehavior). The behavior is looked up +-- from the registry once and cached on the class. +-- +-- Returns true if the behavior was attached this call, false otherwise. +local function ensureAttached(element, animatedBehavior) + if not element or not animatedBehavior then + return false + end + -- Already attached? Avoid duplicate entries within one element lifetime + -- (a behavior may legitimately be re-added across immediate-mode frames + -- since Element is recreated each frame, but within one lifetime at most + -- once). + local behaviors = element.behaviors + if behaviors then + for i = 1, #behaviors do + if behaviors[i] == animatedBehavior then + return false + end + end + end + table.insert(element.behaviors, animatedBehavior) + animatedBehavior.onAttach(element) + return true +end + +-- ---------------------------------------------------------------------------- +-- onAttach — initialize Animation module references (formerly the +-- Element._Animation._ColorModule / _TransformModule wiring in Element:update +-- lines ~2772-2778). +-- ---------------------------------------------------------------------------- + +local function onAttach(element) + ensureAnimationModuleWiring(element) +end + +-- ---------------------------------------------------------------------------- +-- onUpdate — the animation update + interpolation + chain-resolution block +-- (formerly Element:update lines ~2761-2800). +-- ---------------------------------------------------------------------------- + +local function onUpdate(element, dt) + local animation = element.animation + if not animation then + return + end + + -- (Re)ensure module wiring is present in case the Animation instance was + -- created by a caller that bypassed onAttach (e.g. direct + -- `element.animation = Animation.new(...)`). Cheap idempotent writes. + ensureAnimationModuleWiring(element) + + local finished = animation:update(dt, element) + if finished then + -- Animation:update() already called onComplete callback. + -- Check for chained animation. + if animation._next then + element.animation = animation._next + elseif animation._nextFactory and type(animation._nextFactory) == "function" then + local success, nextAnim = pcall(animation._nextFactory, element) + if success and nextAnim then + element.animation = nextAnim + else + element.animation = nil + end + else + element.animation = nil + end + else + -- Apply animation interpolation during update. + animation:applyInterpolation(element) + end +end + +-- ---------------------------------------------------------------------------- +-- saveState / restoreState — no-ops (animations are ephemeral). +-- ---------------------------------------------------------------------------- + +-- Animations are not persisted across immediate-mode frames — they are +-- re-derived each frame from transitions / direct calls. The element's scalar +-- props (opacity, x, ...) are persisted by Element.saveState's _props block, +-- so a completed animation's final visual state still survives recreation. +-- While an animation is mid-flight in immediate mode, the element is recreated +-- and the animation is NOT carried over (intentional — animating in immediate +-- mode requires setting up the animation each frame). +local function saveState() + return nil +end + +local function restoreState() + return nil +end + +-- ---------------------------------------------------------------------------- +-- Build the (stateless, shared) behavior instance. +-- ---------------------------------------------------------------------------- + +-- onDetach/onDraw omitted: they default to no-ops (the behavior allocates no +-- behavior-local state and animations have no draw pass). Animation state lives +-- on the element (`element.animation`); nothing to tear down on detach. +-- +-- We build the immutable behavior via Behavior.new (for validation + freeze + +-- isBehavior parity with Clickable), then expose the late-attach helper on a +-- thin module table since the frozen instance cannot accept new keys. The +-- module table passes the behavior to the registry while making +-- `Animated.ensureAttached` callable from Element.setProperty / the transition +-- helpers — exactly as the task spec requires. +local behavior = Behavior.new({ + onAttach = onAttach, + onUpdate = onUpdate, + saveState = saveState, + restoreState = restoreState, + shouldAttach = shouldAttach, +}) + +-- Thin module table: exposes the behavior instance (for the registry) plus the +-- late-attach helper (for Element.setProperty). All hooks delegate to the +-- frozen behavior instance so dispatch sites get the validated, frozen +-- implementation. shouldAttach is also exposed at module level (mirrors +-- Clickable.shouldAttach) for tests/callers without an element. +local Animated = { + behavior = behavior, + ensureAttached = ensureAttached, + shouldAttach = shouldAttach, + onAttach = onAttach, + onUpdate = onUpdate, +} + +-- Metatable so the module table itself satisfies the duck-typed registry +-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach` +-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the +-- frozen behavior instance for every hook. +setmetatable(Animated, { + __index = behavior, + __tostring = function() + return "Animated" + end, +}) + +return Animated diff --git a/libs/flexlove/modules/behaviors/Clickable.lua b/libs/flexlove/modules/behaviors/Clickable.lua new file mode 100644 index 00000000..811ae11e --- /dev/null +++ b/libs/flexlove/modules/behaviors/Clickable.lua @@ -0,0 +1,344 @@ +-- modules/behaviors/Clickable.lua +-- +-- Concrete behavior: mouse/touch event handling, pressed-state tracking, +-- hit-testing, and theme-state sync. +-- +-- This is the largest behavior in the behavior-mode-unification refactor +-- (~200 LOC moved out of Element:update / _initSubSystems / saveState). +-- Task 02 extracts the entire `if self.onEvent or self.themeComponent or +-- self.editable or self._selectState or self.selectOption then ... end` block +-- from Element:update (hit-testing, mouse/touch event processing, immediate- +-- mode state save, theme-state update) plus EventHandler creation (formerly the +-- first half of Element:_initSubSystems) plus pressed-state drawing (formerly a +-- render layer in Renderer) plus EventHandler save/restore. +-- +-- Attachment rule (shouldAttach): the same predicate that previously guarded +-- mouse-event processing in Element:update. An element owns the EventHandler / +-- gets press feedback exactly when it is interactive: when it declares an +-- `onEvent` callback, a `themeComponent`, is `editable`, or participates in a +-- Select group (selectParent / selectOption). A plain passive element never +-- attaches Clickable and therefore never allocates an EventHandler. +-- +-- Element retains only the `self._eventHandler` field; Clickable owns it on +-- attach. All other Element paths that touched the EventHandler (handleTouchEvent, +-- handleGesture, getTouches) already nil-guard `self._eventHandler`, so they keep +-- working unchanged for non-clickable elements. +-- +-- State ownership (per the locked Behavior contract): +-- * Per-element runtime state lives ON THE ELEMENT (self._eventHandler etc.). +-- * The behavior instance itself is stateless and shared across elements. +-- * Element-class-level dependencies (EventHandler factory, StateManager, +-- Context) are resolved from the owning element's metatable (the Element +-- class set by Element:_construct). This keeps the behavior stateless while +-- avoiding a dependency-injection parameter that would violate the locked +-- 6-hook signature `(element, ...)`. + +local _pkg = (...):match("^(.-)behaviors%.") or "modules." +local Behavior = require(_pkg .. "Behavior") + +-- Resolve the Element class from an element instance. +-- Element instances are created via `setmetatable({}, Element)` in _construct, +-- so their metatable IS the Element class — giving us Element._EventHandler, +-- Element._eventHandlerDeps, Element._StateManager, Element._Context, etc. +-- without threading deps through the behavior hook signature. +local function ElementClass(element) + return getmetatable(element) +end + +-- ---------------------------------------------------------------------------- +-- shouldAttach (class-level predicate, no element required) +-- ---------------------------------------------------------------------------- + +-- Mirrors the cases that previously caused Element to allocate + use an +-- EventHandler. MUST cover every element that touches the EventHandler at +-- runtime: click (onEvent), theme press-feedback (themeComponent), text mouse +-- interaction (editable), Select groups (selectParent / selectOption), touch +-- callbacks (onTouchEvent), and gesture callbacks (onGesture). selectParent / +-- selectOption are the props that produce _selectState during _initSubSystems; +-- checking the props (rather than the runtime _selectState) lets shouldAttach +-- run before the Select subsystem is initialized. +local function shouldAttach(props) + props = props or {} + return props.onEvent ~= nil + or props.themeComponent ~= nil + or props.editable == true + or props.onTouchEvent ~= nil + or props.onGesture ~= nil + or props.selectOption ~= nil + or props.selectParent ~= nil +end + +-- ---------------------------------------------------------------------------- +-- onAttach — create the EventHandler (formerly Element:_initSubSystems +-- lines ~640-690) and restore immediate-mode EventHandler state. +-- ---------------------------------------------------------------------------- + +local function onAttach(element) + local Element = ElementClass(element) + + local eventHandlerConfig = { + -- element.onEvent is source of truth; not cached on handler + onEventDeferred = element.onEventDeferred, + -- element.onTouchEvent is source of truth; not cached on handler + onTouchEventDeferred = element.onTouchEventDeferred, + -- element.onGesture is source of truth; not cached on handler + onGestureDeferred = element.onGestureDeferred, + touchEnabled = element.touchEnabled, + multiTouchEnabled = element.multiTouchEnabled, + } + + -- In immediate mode, restore EventHandler state from StateManager so pressed + -- / hovered / click-count survive the per-frame element recreation cycle. + -- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11): + -- in retained mode the eventHandler persists, so nothing to restore. + if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then + local state = Element._StateManager.getState(element._stateId) + if state then + -- Restore EventHandler state from StateManager (sparse storage — provide defaults) + eventHandlerConfig._pressed = state._pressed or {} + eventHandlerConfig._lastClickTime = state._lastClickTime + eventHandlerConfig._lastClickButton = state._lastClickButton + eventHandlerConfig._clickCount = state._clickCount or 0 + eventHandlerConfig._dragStartX = state._dragStartX or {} + eventHandlerConfig._dragStartY = state._dragStartY or {} + eventHandlerConfig._lastMouseX = state._lastMouseX or {} + eventHandlerConfig._lastMouseY = state._lastMouseY or {} + eventHandlerConfig._hovered = state._hovered + end + end + + element._eventHandler = Element._EventHandler.new(eventHandlerConfig, Element._eventHandlerDeps) +end + +local function onDetach(element) + -- Clear focus callbacks read by KeyboardNavigation / TextEditor:focus so the + -- element's closure references can be collected in immediate mode (formerly + -- part of Element:_cleanup). The EventHandler instance itself is INTENTIONALLY + -- kept: Element:_cleanup preserves element structure for inspection (the + -- stale-element refs are released when the element is GC'd). onEvent, + -- onTouchEvent, onGesture are also left intact — the Renderer/EventHandler + -- read those directly from the element (not the cache), so clearing them + -- would break retained mode. + element.onFocus = nil + element.onBlur = nil +end + +-- ---------------------------------------------------------------------------- +-- onUpdate — the mouse hit-testing + event-processing + theme-state + +-- immediate-mode save block (formerly Element:update lines ~2813-2960). +-- ---------------------------------------------------------------------------- + +local function onUpdate(element, dt) + local Element = ElementClass(element) + local eventHandler = element._eventHandler + if not eventHandler then + return + end + + local mx, my = love.mouse.getPosition() + + -- Clickable area is the border box (x, y already includes padding) + -- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection + local bx = element.x + local by = element.y + local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) + + -- Account for scroll offsets from parent containers + -- Walk up the parent chain and accumulate scroll offsets. This stays in + -- Clickable because it's an interaction concern (hit-testing), not layout. + local scrollOffsetX = 0 + local scrollOffsetY = 0 + local current = element.parent + while current do + local overflowX = current.overflowX or current.overflow + local overflowY = current.overflowY or current.overflow + local hasScrollableOverflow = ( + overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" + or overflowX == "hidden" + or overflowY == "hidden" + ) + if hasScrollableOverflow then + scrollOffsetX = scrollOffsetX + (current._scrollX or 0) + scrollOffsetY = scrollOffsetY + (current._scrollY or 0) + end + current = current.parent + end + + -- Adjust mouse position by accumulated scroll offset for hit testing + local adjustedMx = mx + scrollOffsetX + local adjustedMy = my + scrollOffsetY + local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh + + -- Check if this is the topmost interactive element at the mouse position + -- (z-index ordering). This prevents blocked/occluded elements from + -- receiving interactions or visual feedback. A single mode-agnostic lookup + -- via `Context.findInteractiveAtPosition` (unified-event-routing task 05) + -- replaces the previous immediate/retained-mode split that used + -- `getTopElementAt` in immediate mode and `_activeEventElement` in retained + -- mode. `findInteractiveAtPosition` routes every hit test through + -- `pointHitsElement` (the single canonical `display == false` guard) and + -- resolves occlusion by z-index in both modes, so the active element is the + -- same one that would receive a hit under the cursor. + local topElement = Element._Context.findInteractiveAtPosition(mx, my) + local isActiveElement = (topElement == element or topElement == nil) + + -- Reset scrollbar press flag at start of each frame + eventHandler:resetScrollbarPressFlag() + + -- Process mouse events through EventHandler FIRST + -- This ensures pressed states are updated before theme state is calculated + eventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement) + + -- In immediate mode, save EventHandler state to StateManager after + -- processing events so it survives the per-frame recreation. + if element._stateId and Element._Context.isImmediateMode() and element._stateId ~= "" then + local eventHandlerState = eventHandler:getState() + Element._StateManager.updateState(element._stateId, { + _pressed = eventHandlerState._pressed, + _lastClickTime = eventHandlerState._lastClickTime, + _lastClickButton = eventHandlerState._lastClickButton, + _clickCount = eventHandlerState._clickCount, + _dragStartX = eventHandlerState._dragStartX, + _dragStartY = eventHandlerState._dragStartY, + _lastMouseX = eventHandlerState._lastMouseX, + _lastMouseY = eventHandlerState._lastMouseY, + _hovered = eventHandlerState._hovered, + }) + end + + -- Update theme state based on interaction. themeComponent state update + -- lives in Clickable because it is driven by hover/press state; the actual + -- theme RENDERING is the Themed behavior (task 07). + if element.themeComponent then + -- Check if any button is pressed via EventHandler + local anyPressed = eventHandler:isAnyButtonPressed() + + -- Update theme state via ThemeManager + local isFocused = Element._Context.getFocused() == element + local newThemeState = + element._themeManager:updateState(isHovering and isActiveElement, anyPressed, isFocused, element.disabled) + + if element._stateId and Element._Context.isImmediateMode() then + local hover = newThemeState == "hover" + local pressed = newThemeState == "pressed" + local focused = isFocused + + Element._StateManager.updateState(element._stateId, { + hover = hover, + pressed = pressed, + focused = focused, + disabled = element.disabled, + active = element.active, + }) + end + + if element._renderer then + element._renderer:setThemeState(newThemeState) + end + end + + -- Process touch events through EventHandler + eventHandler:processTouchEvents(element) +end + +-- ---------------------------------------------------------------------------- +-- onDraw — pressed-state visual feedback (formerly Renderer Layer 5). +-- ---------------------------------------------------------------------------- + +-- Draws the grey pressed overlay when any mouse button is currently pressed on +-- the element. Delegates the actual pixels to Renderer:drawPressedState (which +-- owns the RoundedRect + opacity math) but drives the DECISION + transform +-- context here, so the renderer no longer needs the `if element.onEvent ...` +-- behavioral branch. Honors disableHighlight (themes handle their own visual +-- feedback) exactly as the old render layer did. +local function onDraw(element) + if element.disableHighlight then + return + end + local eventHandler = element._eventHandler + if not eventHandler then + return + end + + local anyPressed = false + local pressedState = eventHandler:getState()._pressed or {} + for _, pressed in pairs(pressedState) do + if pressed then + anyPressed = true + break + end + end + if not anyPressed then + return + end + + local renderer = element._renderer + if not renderer then + return + end + + local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) + + -- Apply the element transform around the overlay, mirroring how the + -- Renderer wrapped its whole command buffer (pressed state was a render + -- layer subject to the same transform). + local Element = ElementClass(element) + local Transform = Element._Transform + local hasTransform = element.transform ~= nil and Transform ~= nil and not Transform.isIdentity(element.transform) + if hasTransform then + Transform.apply(element.transform, element.x, element.y, element.width, element.height) + end + + renderer:drawPressedState(element.x, element.y, bw, bh, element.opacity, element.cornerRadius) + + if hasTransform then + Transform.unapply() + end +end + +-- ---------------------------------------------------------------------------- +-- saveState / restoreState — EventHandler state (formerly the eventHandler +-- branches of Element:saveState / Element:restoreState). +-- ---------------------------------------------------------------------------- + +local function saveState(element) + if element._eventHandler then + return { eventHandler = element._eventHandler:getState() } + end + return nil +end + +local function restoreState(element, state) + if not state then + return nil + end + if element._eventHandler and state.eventHandler then + element._eventHandler:setState(state.eventHandler) + end + return nil +end + +-- ---------------------------------------------------------------------------- +-- Build the (stateless, shared, immutable) behavior instance. +-- ---------------------------------------------------------------------------- + +local Clickable = Behavior.new({ + onAttach = onAttach, + onDetach = onDetach, + onUpdate = onUpdate, + onDraw = onDraw, + saveState = saveState, + restoreState = restoreState, + shouldAttach = shouldAttach, +}) + +-- Expose the predicate at module level so callers/tests can reference it +-- directly without an element instance (mirrors Behavior.shouldAttach). +Clickable.shouldAttach = shouldAttach + +return Clickable diff --git a/libs/flexlove/modules/behaviors/Imageable.lua b/libs/flexlove/modules/behaviors/Imageable.lua new file mode 100644 index 00000000..b448a47b --- /dev/null +++ b/libs/flexlove/modules/behaviors/Imageable.lua @@ -0,0 +1,282 @@ +-- modules/behaviors/Imageable.lua +-- +-- Concrete behavior: image loading + image rendering config. +-- +-- Imageable owns the image side of the Renderer: it runs the deferred image- +-- load pipeline (cache check → defer → load → fire onImageLoad/onImageError +-- callbacks), populates the resolved `_loadedImage` cache on both the element +-- and the shared renderer, and persists that cache across immediate-mode +-- recreation. It is the behavior-mode-unification replacement for the image- +-- loading half of Element:_initImageAndRenderer and the deferred +-- Element:_loadImage method (behavior-mode-unification task 07). +-- +-- Image value props (imagePath/image/objectFit/objectPosition/imageOpacity/ +-- imageRepeat/imageTint) are bound on the ELEMENT by Element:_applyProps and read +-- from the element at draw time (Renderer._executeDrawCommand image branch) — +-- Imageable does NOT mirror them onto the renderer, so bare writes and +-- setProperty(...) are immediately consistent. Only the resolved _loadedImage +-- cache (the love.Image produced by the load pipeline) is renderer-mirrored, +-- because Renderer:draw reads `self._loadedImage`. +-- +-- Runtime reload: setProperty("imagePath", ...) / setProperty("image", ...) and +-- the bare-write-equivalent setImage* flows route through element._reloadImage +-- (installed below) which re-runs the load pipeline. See +-- TestRetainedPropertyConsistency (image props) and TestImageableIntegration. +-- +-- Attachment rule (shouldAttach): an element owns image concern exactly when it +-- declares an `imagePath` (load-from-path) or a direct `image` (already-loaded +-- love.Image). Mirrors the old `if self.imagePath / if self.image` init branches. +-- +-- Pairing with Themed: Themed.onAttach creates the Renderer with theme/blur +-- config; Imageable.onAttach enriches the SAME renderer instance with image +-- config + kicks off loading. They share `element._renderer`. In the registry +-- Imageable runs after Themed, so the renderer already exists; the create-or- +-- reuse guard below covers the defensive case where Imageable attaches first. +-- +-- onDraw: the image LAYER is rendered by the integrated `Renderer:draw` call +-- (owned by the Themed behavior) which executes the renderer's `image` draw +-- command using the config Imageable.onAttach wired. Imageable.onDraw is +-- therefore a no-op for the draw call itself — there is no separate +-- `_renderer:_drawImage` entry point; pixel emission lives in the integrated +-- Renderer:draw command buffer. Splitting it out would require Renderer surgery +-- with no behavioral gain (Renderer:draw already conditionally skips the image +-- layer when no image is loaded). +-- +-- State ownership (per the locked Behavior contract): +-- * Per-element runtime state lives ON THE ELEMENT (`element._loadedImage`, +-- `element._renderer._loadedImage`). The behavior instance is stateless. +-- * saveState/restoreState persist `_loadedImage` across immediate-mode frames +-- so the image renders even if the ImageCache is cleared between frames and +-- so the renderer's loaded-image cache survives element recreation. + +local _pkg = (...):match("^(.-)behaviors%.") or "modules." +local Behavior = require(_pkg .. "Behavior") + +-- Lua 5.4 removed the global `unpack`; mirror Element's alias. +local unpack = table.unpack or unpack + +-- Resolve the Element class from an element instance (mirrors Clickable/Themed). +local function ElementClass(element) + return getmetatable(element) +end + +-- ---------------------------------------------------------------------------- +-- shouldAttach (class-level predicate, no element required) +-- ---------------------------------------------------------------------------- + +local function shouldAttach(props) + props = props or {} + return props.imagePath ~= nil or props.image ~= nil +end + +-- ---------------------------------------------------------------------------- +-- Image callback helper (moved from Element._fireImageCallback). +-- Fires a user-supplied image callback (onImageLoad/onImageError) under pcall, +-- honoring the onXDeferred flag when `honorDeferred` is true, and emits a single +-- EVT_002 warn on failure. The direct-`image` sync init path passes +-- honorDeferred=false to preserve immediate firing (image is already loaded). +-- ---------------------------------------------------------------------------- + +local function fireImageCallback(element, callbackField, honorDeferred, ...) + local cb = element[callbackField] + if type(cb) ~= "function" then + return + end + local Element = ElementClass(element) + local argc = select("#", ...) + local args = { ... } + local function invoke() + local ok, err = pcall(cb, element, unpack(args, 1, argc)) + if not ok then + Element._ErrorHandler:warn("Element", "EVT_002", { + callback = callbackField, + error = tostring(err), + }) + end + end + if honorDeferred and element[callbackField .. "Deferred"] then + Element._Context.deferCallback(invoke) + else + invoke() + end +end + +-- ---------------------------------------------------------------------------- +-- Deferred image loader (replaces Element:_loadImage). +-- +-- Invoked by Element's deferred-method dispatcher via the instance closure that +-- onAttach installs on `element._loadImage`. Loads the image from cache or disk +-- (I/O), updates BOTH the element and renderer `_loadedImage` caches so the +-- image draws after an async load, and fires the load/error callback (deferred, +-- honoring onImageLoadDeferred / onImageErrorDeferred). +-- ---------------------------------------------------------------------------- + +local function loadImage(element) + if not element.imagePath or element.image then + return + end + local Element = ElementClass(element) + local loadedImage, err = Element._ImageCache.load(element.imagePath) + if loadedImage then + element._loadedImage = loadedImage + if element._renderer then + element._renderer._loadedImage = loadedImage + end + fireImageCallback(element, "onImageLoad", true, loadedImage) + else + fireImageCallback(element, "onImageError", true, err or "Unknown error") + end +end + +-- ---------------------------------------------------------------------------- +-- reloadImage — recompute the loaded-image cache from the current image/imagePath. +-- +-- This is the single entry point for (re)loading after either initial attach or +-- a runtime property change (see Element._specialSetHandlers.imagePath/image, +-- which call element:_reloadImage()). Precedence matches onAttach: a direct +-- `image` wins over `imagePath`; `nil` for both clears the cache. +-- +-- * direct image → set _loadedImage immediately, fire onImageLoad SYNC (the +-- image is already loaded; honorDeferred=false preserves the +-- original synchronous init contract). +-- * imagePath → cache CHECK only (no I/O) so a cached image can draw this +-- frame, then defer the loader (_loadImage) for the actual +-- I/O + deferred callbacks. load bails if `image` is later set. +-- * neither → clear _loadedImage on both element + renderer. +-- +-- Image value props (objectFit/imageOpacity/imageRepeat/imageTint/objectPosition) +-- and imagePath/image themselves live on the ELEMENT as source of truth; the +-- renderer reads them at draw time, so reloadImage does NOT mirror them onto the +-- renderer — only the resolved _loadedImage cache is pushed. +-- ---------------------------------------------------------------------------- + +local function reloadImage(element) + local Element = ElementClass(element) + local renderer = element._renderer + if element.image then + element._loadedImage = element.image + if renderer then + renderer._loadedImage = element.image + end + fireImageCallback(element, "onImageLoad", false, element.image) + elseif element.imagePath then + -- Cache check (no I/O). Populate both caches immediately if cached so the + -- image can draw this frame without waiting for the deferred load. + local cached = Element._ImageCache.get(element.imagePath) + element._loadedImage = cached + if renderer then + renderer._loadedImage = cached + end + -- Kick off the deferred I/O load + callbacks (idempotent: loadImage bails + -- if image is set or imagePath is nil by the time it runs). + if element._loadImage then + element:_deferMethod("_loadImage") + end + else + element._loadedImage = nil + if renderer then + renderer._loadedImage = nil + end + end +end + +-- ---------------------------------------------------------------------------- +-- onAttach — enrich the shared renderer with image config + kick off loading +-- (formerly the image block of Element:_initImageAndRenderer). +-- ---------------------------------------------------------------------------- + +local function onAttach(element) + local Element = ElementClass(element) + + -- Ensure the renderer exists (Thamed normally creates it; this create-or-reuse + -- guard is defensive for the Imageable-attaches-first ordering). + if not element._renderer then + element._renderer = Element._Renderer.new({ + theme = element.theme, + scaleCorners = element.scaleCorners, + scalingAlgorithm = element.scalingAlgorithm, + contentBlur = element.contentBlur, + backdropBlur = element.backdropBlur, + }, Element._rendererDeps) + end + + -- Install the (re)load hooks as instance methods so Element's + -- deferred-method dispatcher / setProperty special handlers can trigger a + -- reload without Element needing a behavior reference. This keeps Element + -- decoupled from the Imageable behavior (mirrors the stateless-behavior + + -- element-owned-state contract). Image value props and imagePath/image live + -- on the element as source of truth (read at draw time); only the resolved + -- _loadedImage cache is mirrored onto the renderer by reloadImage. + element._loadImage = function(el) + loadImage(el) + end + element._reloadImage = function(el) + reloadImage(el) + end + + -- Initial load: compute _loadedImage + defer the I/O load. + reloadImage(element) +end + +-- ---------------------------------------------------------------------------- +-- onDraw — no-op (see file header: the image layer is rendered by the integrated +-- Renderer:draw call owned by the Themed behavior, using the config wired here). +-- ---------------------------------------------------------------------------- + +-- ---------------------------------------------------------------------------- +-- saveState / restoreState — `_loadedImage` cache (for immediate-mode). +-- ---------------------------------------------------------------------------- + +local function saveState(element) + if element._loadedImage ~= nil then + return { _loadedImage = element._loadedImage } + end + return nil +end + +local function restoreState(element, state) + if not state or state._loadedImage == nil then + return nil + end + local loadedImage = state._loadedImage + element._loadedImage = loadedImage + if element._renderer then + element._renderer._loadedImage = loadedImage + end + return nil +end + +-- ---------------------------------------------------------------------------- +-- onDetach — release image-load callback closures so the element can be GC'd +-- cleanly in immediate mode (formerly part of Element:_cleanup). The cached +-- `_loadedImage` is reproduced on the next attach via the Imageable saveState +-- -> restoreState cycle, so dropping the live references is always safe. +-- ---------------------------------------------------------------------------- + +local function onDetach(element) + element.onImageLoad = nil + element.onImageError = nil +end + +-- ---------------------------------------------------------------------------- +-- Build the (stateless, shared, immutable) behavior instance. +-- ---------------------------------------------------------------------------- + +local Imageable = Behavior.new({ + onAttach = onAttach, + onDetach = onDetach, + onUpdate = function() end, + onDraw = function() end, + saveState = saveState, + restoreState = restoreState, + shouldAttach = shouldAttach, +}) + +-- Expose the predicate at module level so callers/tests can reference it +-- directly without an element instance (mirrors Behavior.shouldAttach / +-- Clickable.shouldAttach). `loadImage` is NOT exposed on the (frozen) behavior +-- instance; it is captured as a module-local upvalue by the onAttach closure that +-- installs `element._loadImage`. +Imageable.shouldAttach = shouldAttach + +return Imageable diff --git a/libs/flexlove/modules/behaviors/Persistable.lua b/libs/flexlove/modules/behaviors/Persistable.lua new file mode 100644 index 00000000..6bcdd0de --- /dev/null +++ b/libs/flexlove/modules/behaviors/Persistable.lua @@ -0,0 +1,132 @@ +-- modules/behaviors/Persistable.lua +-- +-- Concrete behavior: generic public-property persistence across the immediate- +-- mode recreation cycle (behavior-mode-unification task 12). +-- +-- Owns the ONE piece of Element save/restore state that is NOT subsystem state: +-- the snapshot of an element's own public scalar fields (`text`, `display`, +-- `opacity`, `x`, `width`, ...). Event-driven mutations to these fields (a +-- release callback changing `text`, a toggle hiding a panel via `display = +-- false`) must survive the per-frame Element recreation that defines immediate +-- mode. Persistable captures them in `saveState` and reapplies them in +-- `restoreState`, so the caller never branches on mode. +-- +-- This behavior is the final home for the former `Element:saveState` `_props` +-- block and the former `Element:restoreState` `_props` block (~20 LOC moved out +-- of Element.lua). With it in place, `Element:saveState` / `Element:restoreState` +-- collapse to a pure behavior-dispatch loop and Element owns zero property- +-- extraction logic — every persisted slice is owned by exactly one behavior. +-- +-- Attachment rule (shouldAttach): every element. Persistable attaches +-- unconditionally (mirrors the pre-refactor invariant that every element's +-- public scalar props were scanned). The actual snapshot is mode-gated inside +-- `saveState` (immediate-mode-only, matching the legacy contract); in retained +-- mode `saveState` returns nil and `restoreState` is a no-op unless a snapshot +-- is explicitly passed. +-- +-- Registry ordering: Persistable is intentionally placed LAST in the behavior +-- registry. `restoreState` applies `_props` AFTER every other behavior has +-- hydrated its subsystem state, so a persisted public-prop mutation (e.g. +-- `text = "mutated"`) overrides the freshly-restored TextEditor/Select state — +-- preserving the legacy restore ordering (behaviors first, `_props` tail). +-- +-- State ownership (per the locked Behavior contract): +-- * The persisted props live ON the element (they ARE the element's public +-- fields). The behavior instance is stateless + immutable and shared. +-- * The snapshot is returned under the `_props` key (prefixed with `_` so +-- the public-prop scan itself skips it — avoiding self-recursion). + +local _pkg = (...):match("^(.-)behaviors%.") or "modules." +local Behavior = require(_pkg .. "Behavior") + +-- Resolve the Element class from an element instance (mirrors Clickable / +-- Themed). Element instances are created via `setmetatable({}, Element)`, so +-- their metatable IS the Element class — giving access to Element._StateManager +-- without threading deps through the hook signature. +local function ElementClass(element) + return getmetatable(element) +end + +-- ============================================================================ +-- shouldAttach (class-level predicate, no element required) +-- ============================================================================ + +-- Every element's public scalar props are persistable, so this behavior +-- attaches unconditionally. The mode gate lives inside saveState (it needs the +-- runtime mode, which is only available with an element via StateManager). +local function shouldAttach() + return true +end + +-- ============================================================================ +-- saveState — snapshot public scalar fields (immediate-mode-only). +-- ============================================================================ + +-- Mirrors the former `Element:saveState` `_props` block exactly: +-- * Only string keys NOT prefixed with `_` (so internal fields like +-- `_renderer`, `_themeState`, `_initProps` are excluded). +-- * Only scalar values (numbers, strings, booleans); tables and functions +-- are excluded (children, padding, onEvent, ...). +-- Returns `{ _props = {...} }` when there is at least one persistable prop and +-- the element is in immediate mode; nil otherwise (retained mode no-op — +-- state lives on the element directly there, so nothing to snapshot). +local function saveState(element) + local Element = ElementClass(element) + if not Element._StateManager.isImmediateMode() then + return nil + end + local props = {} + for k, v in pairs(element) do + if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) ~= "table" and type(v) ~= "function" then + props[k] = v + end + end + if next(props) then + return { _props = props } + end + return nil +end + +-- ============================================================================ +-- restoreState — reapply the persisted public-prop snapshot onto a fresh +-- element (mode-agnostic; only fires when a `_props` slice is present). +-- ============================================================================ + +-- Applies persisted mutations on top of whatever the constructor + other +-- behaviors already set, so event-driven changes from the previous frame +-- override the declarative props of the recreated element. Runs last in the +-- behavior dispatch (Persistable is the registry tail) to preserve the legacy +-- restore ordering (subsystem restore first, `_props` override last). +local function restoreState(element, state) + if not state or not state._props then + return + end + for k, v in pairs(state._props) do + element[k] = v + end +end + +-- ============================================================================ +-- onAttach / onUpdate / onDraw / onDetach — no-ops. +-- ============================================================================ + +-- Persistable owns no subsystem and allocates no per-element state (the +-- "state" it persists IS the element's own fields). The lifecycle is purely +-- save/restore. + +-- ============================================================================ +-- Build the (stateless, shared, immutable) behavior instance. +-- ============================================================================ + +local Persistable = Behavior.new({ + saveState = saveState, + restoreState = restoreState, + shouldAttach = shouldAttach, +}) + +-- Expose the predicate at module level so callers/tests can reference it +-- directly without an element instance (mirrors Behavior.shouldAttach / +-- Clickable.shouldAttach). +Persistable.shouldAttach = shouldAttach + +return Persistable diff --git a/libs/flexlove/modules/behaviors/Scrollable.lua b/libs/flexlove/modules/behaviors/Scrollable.lua new file mode 100644 index 00000000..7c909e72 --- /dev/null +++ b/libs/flexlove/modules/behaviors/Scrollable.lua @@ -0,0 +1,264 @@ +-- modules/behaviors/Scrollable.lua +-- +-- Concrete behavior: ScrollManager lifecycle (creation + immediate-mode +-- scrollbar interaction-state restore). +-- +-- Scrollable owns the per-element ScrollManager instance — the subsystem that +-- manages overflow detection, scrollbar geometry, scroll position, and scrollbar +-- drag/hover interaction. It is the behavior-mode-unification replacement for +-- the former `Element:_initScrollManager` phase (~84 LOC) of Element.new +-- (behavior-mode-unification task 03 / landed as part of the task 08 capstone). +-- +-- Attachment rule (shouldAttach): an element owns a ScrollManager exactly when +-- it declares an `overflow`, `overflowX`, or `overflowY` prop — mirroring the +-- legacy `if props.overflow or props.overflowX or props.overflowY then` guard +-- in `Element:_initScrollManager`. The ScrollManager is created and its +-- normalized fields are exposed back onto the element (so the Renderer / +-- ScrollManager delegates read `element.overflow` / `element.scrollbarWidth` +-- etc.) exactly as the legacy inline phase did. +-- +-- Why onAttach reads `element._initProps` (not element fields): the scrollbar +-- configuration props (scrollbarWidth / scrollbarColor / scrollSpeed / +-- scrollbarPlacement / scrollbarBalance / invertScroll / smoothScrollEnabled / +-- scrollBarStyle / scrollbarKnobOffset / hideScrollbars / scrollbarRadius / +-- scrollbarPadding / scrollbarTrackColor / _scrollX / _scrollY) are listed in +-- SPECIAL_PROPS and therefore NOT bound onto the element by the schema-driven +-- `_applyProps` loop — they are consumed only by the ScrollManager constructor. +-- The locked behavior hook signature is `(element, ...)` with no props arg, so +-- the original construction props are stashed on the element as `_initProps` by +-- `Element:_construct` and read back here. (`overflow` / `overflowX` / +-- `overflowY` ARE bound onto the element by `_applyProps` so that +-- `Element:addChild`'s scroll-container auto-size guard sees them during +-- declarative-children processing in `_finalizeConstruction`, which runs BEFORE +-- this onAttach; onAttach then overwrites them with the ScrollManager's +-- normalized values, matching the legacy field-exposure order.) +-- +-- onUpdate / onDraw / saveState / restoreState are deferred to the +-- behavior-driven update/draw tasks (09 / 12): the ScrollManager update, +-- interaction, scrollbar drawing, and state save/restore currently stay inline +-- in `Element:update` / `Element:draw` / `Element:saveState` / +-- `Element:restoreState` (delegated through the ScrollManager API bound in +-- `Element.init`). Those inline call sites are NOT behavioral `if` branches — +-- they are unconditional 1-line delegates — so leaving them in Element does not +-- regress the behavior-dispatch goals of tasks 09/12; task 09 will fold them +-- into Scrollable hooks. +-- +-- State ownership (per the locked Behavior contract): +-- * Per-element runtime state lives ON THE ELEMENT (`element._scrollManager`, +-- `element.overflow`, `element._scrollX`, `element._scrollbarDragging`, ...). +-- * The behavior instance is stateless + immutable and shared across elements. +-- * Element-class-level dependencies (`Element._ScrollManager`, +-- `Element._scrollManagerDeps`, `Element._Context`, `Element._StateManager`) +-- are resolved from the owning element's metatable (the Element class set by +-- `Element:_construct`). + +local _pkg = (...):match("^(.-)behaviors%.") or "modules." +local Behavior = require(_pkg .. "Behavior") + +-- Resolve the Element class from an element instance. +-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE +-- the Element class, so this yields Element._ScrollManager, +-- Element._scrollManagerDeps, Element._Context, Element._StateManager without +-- threading deps through the hook signature. +local function ElementClass(element) + return getmetatable(element) +end + +-- ---------------------------------------------------------------------------- +-- shouldAttach (class-level predicate, no element required) +-- ---------------------------------------------------------------------------- + +-- Mirrors the legacy `if props.overflow or props.overflowX or props.overflowY` +-- guard. Uses `~= nil` (rather than truthiness) so that an explicit +-- `overflow = false` / `overflow = ""` does not spuriously attach — though in +-- practice overflow values are always strings or unset, matching the predicate +-- semantics of the other behaviors (Clickable / TextEditable / Selectable). +local function shouldAttach(props) + props = props or {} + return props.overflow ~= nil or props.overflowX ~= nil or props.overflowY ~= nil +end + +-- ---------------------------------------------------------------------------- +-- onAttach — create the ScrollManager + expose its fields + restore immediate- +-- mode scrollbar interaction state (formerly Element:_initScrollManager). +-- ---------------------------------------------------------------------------- + +local function onAttach(element) + local Element = ElementClass(element) + -- Construction props are stashed on the element by _construct (the scrollbar + -- config props are SPECIAL_PROPS and not bound as element fields). + local props = element._initProps or {} + + element._scrollManager = Element._ScrollManager.new({ + overflow = props.overflow, + overflowX = props.overflowX, + overflowY = props.overflowY, + scrollbarWidth = props.scrollbarWidth, + scrollbarColor = props.scrollbarColor, + scrollbarTrackColor = props.scrollbarTrackColor, + scrollbarRadius = props.scrollbarRadius, + scrollbarPadding = props.scrollbarPadding, + scrollSpeed = props.scrollSpeed, + invertScroll = props.invertScroll, + smoothScrollEnabled = props.smoothScrollEnabled, + scrollBarStyle = props.scrollBarStyle, + scrollbarKnobOffset = props.scrollbarKnobOffset, + hideScrollbars = props.hideScrollbars, + scrollbarPlacement = props.scrollbarPlacement, + scrollbarBalance = props.scrollbarBalance, + _scrollX = props._scrollX, + _scrollY = props._scrollY, + }, Element._scrollManagerDeps) + + -- Expose ScrollManager properties for backward compatibility (Renderer access). + local sm = element._scrollManager + element.overflow = sm.overflow + element.overflowX = sm.overflowX + element.overflowY = sm.overflowY + element.scrollbarWidth = sm.scrollbarWidth + element.scrollbarColor = sm.scrollbarColor + element.scrollbarTrackColor = sm.scrollbarTrackColor + element.scrollbarRadius = sm.scrollbarRadius + element.scrollbarPadding = sm.scrollbarPadding + element.scrollSpeed = sm.scrollSpeed + element.invertScroll = sm.invertScroll + element.scrollBarStyle = sm.scrollBarStyle + element.scrollbarKnobOffset = sm.scrollbarKnobOffset + element.hideScrollbars = sm.hideScrollbars + element.scrollbarPlacement = sm.scrollbarPlacement + element.scrollbarBalance = sm.scrollbarBalance + + -- Initialize state properties (will be synced from ScrollManager). + element._overflowX = false + element._overflowY = false + element._contentWidth = 0 + element._contentHeight = 0 + element._scrollX = 0 + element._scrollY = 0 + element._maxScrollX = 0 + element._maxScrollY = 0 + element._scrollbarHoveredVertical = false + element._scrollbarHoveredHorizontal = false + element._scrollbarDragging = false + element._hoveredScrollbar = nil + element._scrollbarDragOffset = 0 + + -- Restore scrollbar state from StateManager in immediate mode (must happen + -- before layout). Mirrors the legacy _initScrollManager restore block. + -- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11). + if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then + local state = Element._StateManager.getState(element._stateId) + if state and state.scrollManager then + element._scrollbarHoveredVertical = state.scrollManager._scrollbarHoveredVertical or false + element._scrollbarHoveredHorizontal = state.scrollManager._scrollbarHoveredHorizontal or false + element._scrollbarDragging = state.scrollManager._scrollbarDragging or false + element._hoveredScrollbar = state.scrollManager._hoveredScrollbar + element._scrollbarDragOffset = state.scrollManager._scrollbarDragOffset or 0 + + -- Apply to ScrollManager immediately. + sm._scrollbarHoveredVertical = element._scrollbarHoveredVertical + sm._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal + sm._scrollbarDragging = element._scrollbarDragging + sm._hoveredScrollbar = element._hoveredScrollbar + sm._scrollbarDragOffset = element._scrollbarDragOffset + + -- Restore drag start positions for relative movement tracking. + sm._dragStartMouseX = state.scrollManager._dragStartMouseX or 0 + sm._dragStartMouseY = state.scrollManager._dragStartMouseY or 0 + sm._dragStartScrollX = state.scrollManager._dragStartScrollX or 0 + sm._dragStartScrollY = state.scrollManager._dragStartScrollY or 0 + end + end +end + +-- -------------------------------------------------------------------------- +-- onUpdate — scroll-position momentum + scrollbar hover/drag/press interaction +-- (formerly the inline ScrollManager blocks in Element:update). +-- Runs BEFORE Clickable.onUpdate in the registry so the scrollbar press flag +-- is set before Clickable's EventHandler processes mouse events. +-- -------------------------------------------------------------------------- + +local function onUpdate(element, dt) + local Element = ElementClass(element) + local sm = element._scrollManager + if not sm then + return + end + -- Restore scrollbar interaction state from StateManager in immediate mode + -- (no-op outside immediate mode / when no state is stored). + Element._ScrollManager.restoreImmediateState(element) + + -- Smooth-scroll / momentum interpolation. + sm:update(dt) + element:_syncScrollManagerState() + + -- Scrollbar hover / drag / press interaction. Captures the mouse here so the + -- interaction state is consistent across the rest of the frame's behaviors. + local mx, my = love.mouse.getPosition() + Element._ScrollManager.updateInteraction(element, mx, my) +end + +-- -------------------------------------------------------------------------- +-- onDraw — scrollbar rendering (post-children overlay). Marked +-- `drawLayer = "overlay"` so Element:draw dispatches it AFTER children, so +-- scrollbars paint on top of clipped child content and without parent clipping. +-- -------------------------------------------------------------------------- + +local function onDraw(element, _ctx) + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + if overflowX ~= "scroll" and overflowX ~= "auto" and overflowY ~= "scroll" and overflowY ~= "auto" then + return + end + local scrollbarDims = element:_calculateScrollbarDimensions() + if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then + return + end + -- Clear any parent scissor clipping before drawing scrollbars so they render + -- fully visible (scrollbars must not be clipped by ancestor overflow). + love.graphics.setScissor() + element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims) +end + +-- -------------------------------------------------------------------------- +-- saveState / restoreState — ScrollManager state snapshot for immediate-mode +-- recreation (formerly the inline blocks in Element:saveState/ +-- Element:restoreState). Returns a table merged under the `scrollManager` key +-- by Element:saveState's behavior loop, mirroring the legacy contract. +-- -------------------------------------------------------------------------- + +local function saveState(element) + local sm = element._scrollManager + if not sm then + return nil + end + return { scrollManager = sm:getState() } +end + +local function restoreState(element, state) + if not state then + return + end + local sm = element._scrollManager + local smState = state.scrollManager + if sm and smState then + sm:setState(smState) + end +end + +local Scrollable = Behavior.new({ + onAttach = onAttach, + onDetach = function() end, + onUpdate = onUpdate, + onDraw = onDraw, + saveState = saveState, + restoreState = restoreState, + drawLayer = "overlay", +}) + +-- Expose the predicate at module level so callers/tests can reference it +-- directly without an element instance (mirrors Clickable.shouldAttach / +-- Selectable.shouldAttach). +Scrollable.shouldAttach = shouldAttach + +return Scrollable diff --git a/libs/flexlove/modules/behaviors/Selectable.lua b/libs/flexlove/modules/behaviors/Selectable.lua new file mode 100644 index 00000000..e63bb4c6 --- /dev/null +++ b/libs/flexlove/modules/behaviors/Selectable.lua @@ -0,0 +1,206 @@ +-- modules/behaviors/Selectable.lua +-- +-- Concrete behavior: Select state-machine lifecycle for dropdown-style +-- select groups. Owns the per-element Select subsystem initialization, the +-- managed-frame layout sync each frame, and select save/restore across the +-- immediate-mode recreation cycle. +-- +-- This behavior consolidates the legacy `if self._selectState` / `if +-- self.selectOption` branches that previously lived inside Element.lua: +-- +-- * Select subsystem init (formerly Element:_initSubSystems lines ~810-825 — +-- `Select.initSelectParent` / `Select.initSelectOption`). +-- * Managed-frame adoption (formerly Element:_initPositioning lines ~1700- +-- 1702 — `Select.adoptSelectFrame`). +-- * Per-frame frame-state sync (formerly Element:update line ~2747 — +-- `Select.ensureFrameState`). +-- * Save/restore of select open/value/label (formerly the `select` branch of +-- Element:saveState / Element:restoreState). +-- +-- Element retains `self._selectState` and `self.selectOption` for backward- +-- compat field access; runtime state lives ON THE ELEMENT. The behavior itself +-- is stateless + immutable (a single shared instance attaches to every +-- selectable element). +-- +-- The 20 Element select-API delegate methods (openSelect, closeSelect, +-- toggleSelect, isSelectOpen, getSelectValue, setSelectValue, ...) stay as +-- 1-line forwarders into the Select module — the behavior owns the +-- *lifecycle* (attach / update / save / restore / detach), not the API +-- surface (per task 05 spec notes). +-- +-- State ownership (per the locked Behavior contract): +-- * Per-element runtime state lives ON THE ELEMENT (self._selectState, +-- self.selectOption, self._selectParentElement, ...). +-- * The behavior instance is stateless + immutable and shared across elements. +-- * Element-class-level dependencies are resolved via `getmetatable(element)` +-- (which IS the Element class set by Element._construct), so the hook +-- signature stays exactly `(element, ...)` with no DI parameters. + +local _pkg = (...):match("^(.-)behaviors%.") or "modules." +local Behavior = require(_pkg .. "Behavior") + +-- Resolve the Element class from an element instance. +-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE +-- the Element class, so this yields Element._Select, Element._Context, +-- Element._StateManager, etc. without threading deps through the hook signature. +local function ElementClass(element) + return getmetatable(element) +end + +-- ============================================================================ +-- shouldAttach (class-level predicate, no element required) +-- ============================================================================ + +-- Mirrors the cases that previously caused Element to initialize a Select +-- subsystem. An element owns select state exactly when it declares a +-- `selectParent` config (the dropdown trigger) or a `selectOption` config (an +-- option inside a dropdown). Checking the props (rather than the runtime +-- `_selectState`) lets shouldAttach run before onAttach initializes the +-- subsystem, matching the auto-attach contract established by Clickable / +-- TextEditable. +local function shouldAttach(props) + props = props or {} + return type(props.selectParent) == "table" or type(props.selectOption) == "table" +end + +-- ============================================================================ +-- onAttach — initialize the Select subsystem (formerly Element:_initSubSystems +-- lines ~810-825) and adopt the managed frame (formerly Element:_initPositioning +-- lines ~1700-1702). +-- ============================================================================ + +local function onAttach(element) + local Element = ElementClass(element) + + -- Initialize the appropriate select role. Mirrors the legacy _initSubSystems + -- block exactly: selectParent → initSelectParent (sets _selectState + + -- immediate-mode restore from StateManager); selectOption → initSelectOption + -- (sets the option value/label/disabled). + if type(element.selectParent) == "table" then + Element._Select.initSelectParent(element, element.selectParent) + end + + if type(element.selectOption) == "table" then + Element._Select.initSelectOption(element, element.selectOption) + end + + -- Adopt the managed dropdown frame. This was formerly the tail of + -- _initPositioning (after the select parent's own addChild). It creates the + -- select anchor, reparents the frame under it, and syncs visibility. Moving + -- it here is safe because onAttach runs after _initPositioning: the parent's + -- own positioning is finalized, so the anchor's geometry can be computed. + if element._selectState and type(element.selectParent) == "table" and element.selectParent.selectFrame ~= nil then + Element._Select.adoptSelectFrame(element, element.selectParent.selectFrame) + end + + -- Backfill option registration for children added BEFORE this behavior + -- attached. The auto-attach pass runs at the very end of Element.new + -- (after _finalizeConstruction, which processes declarative `children`). + -- Declarative select-option children are addChild'd to this element during + -- _finalizeConstruction — at that point _selectState did not yet exist (this + -- onAttach had not run), so their registerWithSelectParent call walked the + -- parent chain, found no _selectState, and returned early. Re-scan now that + -- _selectState is initialized so these options are registered + reparented + -- into the managed frame exactly like runtime-added options. + -- (registerWithSelectParent is idempotent — it skips options already + -- registered — so this is a no-op for children added after _selectState was + -- set, e.g. the common `FlexLove.new({ parent = sp, selectOption = {...} })` + -- pattern.) + if element._selectState then + for _, child in ipairs(element.children) do + if child.selectOption then + Element._Select.registerWithSelectParent(child) + Element._Select.attachOptionToManagedFrame(child) + end + end + end +end + +local function onDetach(element) + -- Clear select-managed fields so the element can be GC'd cleanly in immediate + -- mode (formerly part of Element:_cleanup). This mirrors the select-clearing + -- block that lived in Element:_cleanup; Element:destroy separately routes + -- through Select.cleanupDestroy for full teardown (idempotent with this). + if element.selectParent then + element.selectParent.onChange = nil + end + element._selectState = nil + element._managedSelectOwner = nil + element._managedSelectFrame = nil + element._managedSelectAnchor = nil + element._managedSelectBaseOpacity = nil + element._managedSelectBaseVisibility = nil + element._managedSelectBaseDisabled = nil +end + +-- ============================================================================ +-- onUpdate — per-frame managed-frame layout sync (formerly Element:update +-- line ~2747 — `Select.ensureFrameState`). +-- ============================================================================ + +local function onUpdate(element, dt) + local Element = ElementClass(element) + Element._Select.ensureFrameState(element) +end + +-- ============================================================================ +-- onDraw — no-op. +-- ============================================================================ + +-- Select rendering is driven by the managed frame / anchor elements themselves +-- (visibility synced by Select.syncManagedFrameVisibility), not by the select +-- parent's draw path. The parent's own pixels are the theme/renderer's job. +local function onDraw() end + +-- ============================================================================ +-- saveState / restoreState — select open/value/label (formerly the `select` +-- branch of Element:saveState / Element:restoreState). +-- ============================================================================ + +-- Returns a snapshot under the `select` key to match the legacy immediate-mode +-- restoreState contract (Element:restoreState looked up state.select). The +-- behavior-dispatch loop merges behavior snapshots into the top-level state +-- table, so returning { select = ... } slots in identically to the old inline +-- `state.select = selectState` assignment. +local function saveState(element) + local Element = ElementClass(element) + local selectState = Element._Select.saveState(element) + if selectState then + return { select = selectState } + end + return nil +end + +-- Consumes the previously-saved snapshot keyed under `select`. The behavior- +-- dispatch loop passes the FULL top-level state table; this hook reads only +-- its own `state.select` slice, mirroring the legacy `if state.select then` +-- guard in Element:restoreState. +local function restoreState(element, state) + if not state then + return + end + local Element = ElementClass(element) + if state.select then + Element._Select.restoreState(element, state.select) + end +end + +-- ============================================================================ +-- Build the (stateless, shared, immutable) behavior instance. +-- ============================================================================ + +local Selectable = Behavior.new({ + onAttach = onAttach, + onDetach = onDetach, + onUpdate = onUpdate, + onDraw = onDraw, + saveState = saveState, + restoreState = restoreState, + shouldAttach = shouldAttach, +}) + +-- Expose the predicate at module level so callers/tests can reference it +-- directly without an element instance (mirrors Behavior.shouldAttach). +Selectable.shouldAttach = shouldAttach + +return Selectable diff --git a/libs/flexlove/modules/behaviors/TextEditable.lua b/libs/flexlove/modules/behaviors/TextEditable.lua new file mode 100644 index 00000000..7d3ee79b --- /dev/null +++ b/libs/flexlove/modules/behaviors/TextEditable.lua @@ -0,0 +1,576 @@ +-- modules/behaviors/TextEditable.lua +-- +-- Concrete behavior: TextEditor subsystem ownership — text editing, cursor +-- management, text selection, text-related input handling, and text-editor +-- state save/restore. +-- +-- This behavior consolidates the legacy `if self._textEditor` nil-guard +-- patterns that previously lived inside Element.lua: +-- +-- * TextEditor creation + immediate-mode state restore (formerly +-- Element:_initSubSystems lines ~813-830 — the `if self.editable then +-- self._textEditor = Element._TextEditor.new {...}` block). +-- * Cursor-blink update (formerly Element:update line ~2810 — +-- `if self._textEditor then self._textEditor:update(self, dt) end`). +-- * The 27 text-editor delegate methods (formerly Element:setText / +-- getText / setCursorPosition / setSelection / focus / textinput / +-- keypressed / _handleTextClick / _handleTextDrag / ...). Each was a 3-line +-- nil-guard stub (check `_textEditor`, forward call, end). They are now +-- module-level functions on this behavior; Element retains only 1-line +-- forwarders that route through `Element._TextEditable.(self, ...)`. +-- * Text-editor state save/restore (formerly the textEditor branch of +-- Element:saveState / Element:restoreState), including the cursor/selection +-- field sync and the text-selection drag-tracking fields +-- (`_mouseDownPosition` / `_textDragOccurred`). +-- +-- Element retains the `self._textEditor` field for backward-compat field +-- access (Renderer:drawText reads it directly for cursor/selection rendering); +-- runtime state lives ON THE ELEMENT. The behavior itself is stateless + +-- immutable + shared across elements. +-- +-- State ownership (per the locked Behavior contract): +-- * Per-element runtime state lives ON THE ELEMENT (`self._textEditor`, +-- `self._mouseDownPosition`, `self._textDragOccurred`). The behavior +-- instance is stateless + immutable and shared across all editable +-- elements. +-- * Element-class-level dependencies are resolved via `getmetatable(element)` +-- (which IS the Element class set by Element._construct), so the hook +-- signature stays exactly `(element, ...)` with no DI parameters. +-- +-- onDraw is a no-op: text/cursor/selection rendering stays in the Renderer's +-- command buffer (Layer 4 "text"), driven by the Thamed behavior's single +-- `Renderer:draw` call. The Renderer's `drawText` already reads +-- `element._textEditor` for cursor/selection, so TextEditable OWNS the +-- subsystem that drawText consumes, but the draw dispatch stays in the +-- renderer to preserve the unified transform/scissor command-buffer ordering +-- (mirrors Selectable.onDraw's no-op precedent, where rendering is owned by a +-- different layer). Hoisting drawText into this behavior's onDraw would +-- double-render text, since the Renderer command buffer already emits a "text" +-- layer for every element. + +local _pkg = (...):match("^(.-)behaviors%.") or "modules." +local Behavior = require(_pkg .. "Behavior") + +-- Resolve the Element class from an element instance (mirrors Clickable / +-- Selectable). `setmetatable({}, Element)` in `_construct` makes the instance +-- metatable BE the Element class, so this yields Element._TextEditor, +-- Element._textEditorDeps, Element._Context, Element._StateManager, etc. +-- without threading deps through the hook signature. +local function ElementClass(element) + return getmetatable(element) +end + +-- ============================================================================ +-- shouldAttach (class-level predicate, no element required) +-- ============================================================================ + +-- Mirrors the spec predicate: attach when the element is text-editable OR +-- carries text content. onAttach only ALLOCATES a TextEditor when +-- `element.editable` is true (preserving the pre-refactor creation invariant +-- "TextEditor created iff editable"), so non-editable text labels attach the +-- behavior but allocate no TextEditor — their onUpdate/onDraw/saveState are +-- nil-guarded no-ops, and the Element forwarders route them through the +-- non-editable branch of each delegate function (reads/writes `element.text` +-- directly). This keeps shouldAttach faithful to the spec while preserving +-- exact pre-refactor allocation behavior. +local function shouldAttach(props) + props = props or {} + return props.editable == true or props.text ~= nil +end + +-- ============================================================================ +-- onAttach — create the TextEditor (formerly Element:_initSubSystems lines +-- ~813-830) and restore immediate-mode TextEditor state. +-- ============================================================================ + +local function onAttach(element) + local Element = ElementClass(element) + + -- Only editable elements own a TextEditor. Preserves the exact pre-refactor + -- creation guard (`if self.editable then ... end`) — non-editable text + -- elements attach the behavior (so their forwarders route through a single + -- code path) but allocate no TextEditor. + if not element.editable then + return + end + + -- Config is sourced from element fields (bound by _applyProps / _initVisualState + -- before _attachBehaviors runs at the tail of Element.new) — NOT from raw + -- props. The callbacks (onFocus/onBlur/onTextInput/onTextChange/onEnter) are + -- schema-bound element fields by this point, and `element.text` is set by + -- _initVisualState, so no `props` reference is needed here (the hook + -- signature is `(element)`). + element._textEditor = Element._TextEditor.new({ + editable = element.editable, + multiline = element.multiline, + passwordMode = element.passwordMode, + textWrap = element.textWrap, + maxLines = element.maxLines, + maxLength = element.maxLength, + placeholder = element.placeholder, + inputType = element.inputType, + textOverflow = element.textOverflow, + scrollable = element.scrollable, + autoGrow = element.autoGrow, + selectOnFocus = element.selectOnFocus, + cursorColor = element.cursorColor, + selectionColor = element.selectionColor, + cursorBlinkRate = element.cursorBlinkRate, + text = element.text or "", + onFocus = element.onFocus, + onBlur = element.onBlur, + onTextInput = element.onTextInput, + onTextChange = element.onTextChange, + onEnter = element.onEnter, + }, Element._textEditorDeps) + + -- Restore TextEditor state from StateManager in immediate mode. Mirrors the + -- legacy _initSubSystems immediate-mode restore. Safe to run here (after + -- _construct registered the element with StateManager) — the StateManager + -- lookup is sparse and returns nil for a fresh element. Mode-aware via + -- Context.isImmediateMode (behavior-mode-unification task 11). + if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then + local state = Element._StateManager.getState(element._stateId) + if state and state.textEditor then + element._textEditor:setState(state.textEditor, element) + end + end +end + +local function onDetach(element) + -- Clear text-input callback closures read by TextEditor / KeyboardNavigation + -- so the element's closure references can be collected in immediate mode + -- (formerly part of Element:_cleanup). The TextEditor instance itself is + -- INTENTIONALLY kept: Element:_cleanup preserves element structure for + -- inspection (released when the element is GC'd). + element.onTextInput = nil + element.onTextChange = nil + element.onEnter = nil +end + +-- ============================================================================ +-- onUpdate — cursor-blink animation (formerly Element:update line ~2810). +-- ============================================================================ + +-- Drives TextEditor:update (cursor blink + blink-pause timer). Guarded on +-- `element._textEditor` because non-editable text elements attach this +-- behavior (per shouldAttach) but own no TextEditor. Element:update contains +-- zero text-editor references — the dispatch loop calls this hook. +local function onUpdate(element, dt) + local textEditor = element._textEditor + if textEditor then + textEditor:update(element, dt) + end +end + +-- ============================================================================ +-- onDraw — no-op (see file header: text rendering stays in the Renderer +-- command buffer driven by the Thamed behavior's Renderer:draw call). +-- ============================================================================ + +local function onDraw() end + +-- ============================================================================ +-- saveState / restoreState — TextEditor state + text-selection drag +-- tracking (formerly the textEditor branch of Element:saveState / +-- Element:restoreState, including the _mouseDownPosition / _textDragOccurred +-- fields). +-- ============================================================================ + +-- Returns a snapshot under the `textEditor` key to match the legacy immediate- +-- mode restoreState contract (Element:restoreState looked up state.textEditor). +-- The behavior-dispatch loop in Element:saveState merges behavior snapshots +-- into the top-level state table, so returning { textEditor = ... } slots in +-- identically to the old inline `state.textEditor = self._textEditor:getState()` +-- assignment. The drag-tracking fields are merged at the top level too +-- (matching the legacy `state._mouseDownPosition` / `state._textDragOccurred` +-- assignments) since they are text-selection state. +local function saveState(element) + local textEditor = element._textEditor + if not textEditor then + -- Non-editable text element: still persist drag-tracking fields if set + -- (they are only ever set for editable elements, but persist defensively). + local hasDragState = element._mouseDownPosition ~= nil or element._textDragOccurred ~= nil + if not hasDragState then + return nil + end + local snapshot = {} + if element._mouseDownPosition ~= nil then + snapshot._mouseDownPosition = element._mouseDownPosition + end + if element._textDragOccurred ~= nil then + snapshot._textDragOccurred = element._textDragOccurred + end + return snapshot + end + + local snapshot = { textEditor = textEditor:getState() } + if element._mouseDownPosition ~= nil then + snapshot._mouseDownPosition = element._mouseDownPosition + end + if element._textDragOccurred ~= nil then + snapshot._textDragOccurred = element._textDragOccurred + end + return snapshot +end + +-- Consumes the previously-saved snapshot keyed under `textEditor` plus the +-- drag-tracking fields. The behavior-dispatch loop passes the FULL top-level +-- state table; this hook reads only its own slices, mirroring the legacy +-- `if self._textEditor and state.textEditor then ... end` guard. +local function restoreState(element, state) + if not state then + return + end + local textEditor = element._textEditor + if textEditor and state.textEditor then + textEditor:setState(state.textEditor, element) + -- Sync TextEditor's focus/cursor/selection state to Element for theme + -- management (mirrors the legacy restoreState field sync). + element._focused = textEditor._focused + element._cursorPosition = textEditor._cursorPosition + element._selectionStart = textEditor._selectionStart + element._selectionEnd = textEditor._selectionEnd + element._textBuffer = textEditor._textBuffer + end + + -- Restore drag-tracking state for text selection (top-level keys). + if state._mouseDownPosition ~= nil then + element._mouseDownPosition = state._mouseDownPosition + end + if state._textDragOccurred ~= nil then + element._textDragOccurred = state._textDragOccurred + end +end + +-- ============================================================================ +-- Text-editor delegate functions. +-- +-- These are the module-level implementations of the 27 text-editor delegate +-- methods that previously lived on Element. Each mirrors the pre-refactor +-- Element method body VERBATIM (with `self` → `element`), including the +-- `element._textEditor` nil-guard: the guard is required because (a) non- +-- editable text elements attach this behavior (per shouldAttach) but own no +-- TextEditor, and (b) Element forwards these methods BEFORE onAttach has run +-- (e.g. an `onCreate` callback firing during _finalizeConstruction, which +-- runs before _attachBehaviors). The nil-guards live in THIS file (not in +-- Element.lua), so the Element.lua `if self._textEditor` count drops to 0. +-- +-- Element retains 1-line forwarders: `Element.setText = function(self, text) +-- return Element._TextEditable.setText(self, text) end` (etc.), so external +-- callers (EventHandler, KeyboardNavigation, game UI) keep working unchanged. +-- +-- The TextEditor API is mixed: most methods take the element as first arg +-- (`te:method(element, ...)` — "passesSelf"); a few getters omit it +-- (`te:method()`). The delegation contract is pinned by +-- subsystem_delegation_test.lua, so this mapping must match TextEditor's +-- method signatures exactly. +-- ============================================================================ + +-- --- Cursor management (passesSelf = element forwarded) ------------------ + +local function setCursorPosition(element, position) + local textEditor = element._textEditor + if textEditor then + textEditor:setCursorPosition(element, position) + end +end + +local function getCursorPosition(element) + local textEditor = element._textEditor + if textEditor then + return textEditor:getCursorPosition() + end + return 0 +end + +local function moveCursorBy(element, delta) + local textEditor = element._textEditor + if textEditor then + textEditor:moveCursorBy(element, delta) + end +end + +local function moveCursorToStart(element) + local textEditor = element._textEditor + if textEditor then + textEditor:moveCursorToStart(element) + end +end + +local function moveCursorToEnd(element) + local textEditor = element._textEditor + if textEditor then + textEditor:moveCursorToEnd(element) + end +end + +local function moveCursorToLineStart(element) + local textEditor = element._textEditor + if textEditor then + textEditor:moveCursorToLineStart(element) + end +end + +local function moveCursorToLineEnd(element) + local textEditor = element._textEditor + if textEditor then + textEditor:moveCursorToLineEnd(element) + end +end + +local function moveCursorToPreviousWord(element) + local textEditor = element._textEditor + if textEditor then + textEditor:moveCursorToPreviousWord(element) + end +end + +local function moveCursorToNextWord(element) + local textEditor = element._textEditor + if textEditor then + textEditor:moveCursorToNextWord(element) + end +end + +-- --- Selection management ------------------------------------------------ + +local function setSelection(element, startPos, endPos) + local textEditor = element._textEditor + if textEditor then + textEditor:setSelection(element, startPos, endPos) + end +end + +local function getSelection(element) + local textEditor = element._textEditor + if textEditor then + return textEditor:getSelection() + end + return nil +end + +local function hasSelection(element) + local textEditor = element._textEditor + if textEditor ~= nil then + return textEditor:hasSelection() + end + return false +end + +local function clearSelection(element) + local textEditor = element._textEditor + if textEditor then + textEditor:clearSelection(element) + end +end + +local function selectAll(element) + local textEditor = element._textEditor + if textEditor then + textEditor:selectAll(element) + end +end + +local function getSelectedText(element) + local textEditor = element._textEditor + if textEditor then + return textEditor:getSelectedText() + end + return nil +end + +local function deleteSelection(element) + local textEditor = element._textEditor + if textEditor then + return textEditor:deleteSelection(element) + end + return false +end + +-- --- Focus management ---------------------------------------------------- + +local function focus(element) + local textEditor = element._textEditor + if textEditor then + textEditor:focus(element) + end +end + +local function blur(element) + local textEditor = element._textEditor + if textEditor then + textEditor:blur(element) + end +end + +local function isFocused(element) + local textEditor = element._textEditor + if textEditor then + return textEditor:isFocused() + end + return false +end + +-- --- Text buffer management (with post-delegation sync) ------------------ +-- These methods sync `element.text` from the TextEditor result + drive +-- auto-grow, exactly as the legacy Element methods did. + +local function getText(element) + local textEditor = element._textEditor + if textEditor then + return textEditor:getText() + end + return element.text or "" +end + +local function setText(element, text) + local textEditor = element._textEditor + if textEditor then + textEditor:setText(element, text) + element.text = textEditor:getText() -- Sync display text + textEditor:updateAutoGrowHeight(element) + return + end + element.text = text +end + +local function insertText(element, text, position) + local textEditor = element._textEditor + if textEditor then + textEditor:insertText(element, text, position) + element.text = textEditor:getText() -- Sync display text + textEditor:updateAutoGrowHeight(element) + end +end + +local function deleteText(element, startPos, endPos) + local textEditor = element._textEditor + if textEditor then + textEditor:deleteText(element, startPos, endPos) + element.text = textEditor:getText() -- Sync display text + textEditor:updateAutoGrowHeight(element) + end +end + +local function replaceText(element, startPos, endPos, newText) + local textEditor = element._textEditor + if textEditor then + textEditor:replaceText(element, startPos, endPos, newText) + element.text = textEditor:getText() -- Sync display text + textEditor:updateAutoGrowHeight(element) + end +end + +-- --- Mouse text selection ------------------------------------------------ + +local function handleTextClick(element, mouseX, mouseY, clickCount) + local textEditor = element._textEditor + if textEditor then + textEditor:handleTextClick(element, mouseX, mouseY, clickCount) + -- Store mouse down position on element for drag tracking + if clickCount == 1 then + element._mouseDownPosition = textEditor:mouseToTextPosition(element, mouseX, mouseY) + end + end +end + +local function handleTextDrag(element, mouseX, mouseY) + local textEditor = element._textEditor + if textEditor then + textEditor:handleTextDrag(element, mouseX, mouseY) + element._textDragOccurred = textEditor._textDragOccurred + end +end + +-- --- Keyboard input ------------------------------------------------------ + +local function textinput(element, text) + local textEditor = element._textEditor + if textEditor then + textEditor:handleTextInput(element, text) + element.text = textEditor:getText() -- Sync display text + textEditor:updateAutoGrowHeight(element) + end +end + +local function keypressed(element, key, scancode, isrepeat) + local textEditor = element._textEditor + if textEditor then + textEditor:handleKeyPress(element, key, scancode, isrepeat) + element.text = textEditor:getText() -- Sync display text + textEditor:updateAutoGrowHeight(element) + end +end + +-- ============================================================================ +-- Build the (stateless, shared, immutable) behavior instance + thin module +-- table exposing the delegate functions (mirrors the Animated pattern). +-- ============================================================================ + +local behavior = Behavior.new({ + onAttach = onAttach, + onDetach = onDetach, + onUpdate = onUpdate, + onDraw = onDraw, + saveState = saveState, + restoreState = restoreState, + shouldAttach = shouldAttach, +}) + +-- Thin module table: exposes the frozen behavior instance (for the registry) +-- plus the text-editor delegate functions (for Element's 1-line forwarders). +-- All hooks delegate to the frozen behavior instance so dispatch sites get +-- the validated, frozen implementation. shouldAttach is also exposed at module +-- level (mirrors Clickable.shouldAttach) for tests/callers without an element. +local TextEditable = { + behavior = behavior, + shouldAttach = shouldAttach, + onAttach = onAttach, + onUpdate = onUpdate, + onDraw = onDraw, + saveState = saveState, + restoreState = restoreState, + -- Text-editor delegate functions (Element forwarders route through these): + setCursorPosition = setCursorPosition, + getCursorPosition = getCursorPosition, + moveCursorBy = moveCursorBy, + moveCursorToStart = moveCursorToStart, + moveCursorToEnd = moveCursorToEnd, + moveCursorToLineStart = moveCursorToLineStart, + moveCursorToLineEnd = moveCursorToLineEnd, + moveCursorToPreviousWord = moveCursorToPreviousWord, + moveCursorToNextWord = moveCursorToNextWord, + setSelection = setSelection, + getSelection = getSelection, + hasSelection = hasSelection, + clearSelection = clearSelection, + selectAll = selectAll, + getSelectedText = getSelectedText, + deleteSelection = deleteSelection, + focus = focus, + blur = blur, + isFocused = isFocused, + getText = getText, + setText = setText, + insertText = insertText, + deleteText = deleteText, + replaceText = replaceText, + _handleTextClick = handleTextClick, + _handleTextDrag = handleTextDrag, + textinput = textinput, + keypressed = keypressed, +} + +-- Metatable so the module table itself satisfies the duck-typed registry +-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach` +-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the +-- frozen behavior instance for every hook / isBehavior parity. +setmetatable(TextEditable, { + __index = behavior, + __tostring = function() + return "TextEditable" + end, +}) + +return TextEditable diff --git a/libs/flexlove/modules/behaviors/Themed.lua b/libs/flexlove/modules/behaviors/Themed.lua new file mode 100644 index 00000000..bd431331 --- /dev/null +++ b/libs/flexlove/modules/behaviors/Themed.lua @@ -0,0 +1,178 @@ +-- modules/behaviors/Themed.lua +-- +-- Concrete behavior: Renderer ownership + theme-state rendering. +-- +-- Themed owns the per-element Renderer instance and the single +-- `Renderer:draw` call that paints the core visual layers (background, image, +-- theme 9-patch, borders, text, customDraw). It is the behavior-mode-unification +-- replacement for the former `_initImageAndRenderer` Renderer creation block and +-- the former first `self._renderer:draw(self, backdropCanvas)` call in +-- Element:draw (behavior-mode-unification task 07). +-- +-- Attachment rule (shouldAttach): every renderable Element. The pre-refactor +-- code unconditionally created a Renderer for every Element and unconditionally +-- called `Renderer:draw` in Element:draw; Themed mirrors that invariant so the +-- Renderer is always available to subsystems that depend on it (TextEditor font +-- / wrap delegation, ScrollManager scrollbar drawing) AND so visual rendering of +-- background / border / theme / image layers is preserved for every element. +-- Restricting attachment to `themeComponent`-only elements would break editable +-- text fields and scrollable containers (which need a Renderer for subsystem +-- delegation even when they have no theme component). The 9-patch theme-state +-- rendering within `Renderer:draw` is a no-op for elements without a +-- `themeComponent`, so always-attaching carries no rendering cost. +-- +-- Themed and Imageable are paired (both configure the same `element._renderer`): +-- Themed.onAttach creates the Renderer with the theme/blur config; Imageable +-- (attached for imagePath/image elements) enriches the SAME renderer instance with +-- image config + deferred image loading. They share `element._renderer`. +-- +-- onUpdate is a no-op: theme-state transitions are DRIVEN by the Clickable +-- behavior (whose onUpdate recomputes hover/press/focus and calls +-- `renderer:setThemeState`). Themed only READS that state for rendering, so it has +-- no per-frame update work. +-- +-- saveState owns the blur-region snapshot (`state.blur`): the per-frame blur +-- geometry + radius/quality used by the Blur cache for invalidation (formerly +-- the inline `if self.backdropBlur or self.contentBlur` block of +-- Element:saveState — behavior-mode-unification task 12). restoreState is a +-- no-op: blur cache data is used for invalidation, not restoration (the Blur +-- cache is keyed by element id and cleared via `Blur.clearElementCache` from +-- FlexLove.endFrame, not replayed through restoreState). +-- +-- State ownership (per the locked Behavior contract): +-- * Per-element runtime state lives ON THE ELEMENT (`element._renderer`, +-- `element._themeState`, `element.backdropBlur`, `element.contentBlur`). +-- The behavior instance is stateless and shared. +-- * `element._renderer` is recreated on attach; onDetach is a no-op — the +-- reference is released when the element is GC'd (Element:_cleanup keeps +-- element structure for inspection). + +local _pkg = (...):match("^(.-)behaviors%.") or "modules." +local Behavior = require(_pkg .. "Behavior") + +-- Resolve the Element class from an element instance (mirrors Clickable). +-- Element instances are created via `setmetatable({}, Element)`, so their +-- metatable IS the Element class — giving access to Element._Renderer, +-- Element._rendererDeps, etc. without threading deps through the hook signature. +local function ElementClass(element) + return getmetatable(element) +end + +-- ---------------------------------------------------------------------------- +-- shouldAttach (class-level predicate, no element required) +-- ---------------------------------------------------------------------------- + +-- Returns true for every renderable Element. See file header for the rationale: +-- the pre-refactor invariant was "every Element has a Renderer; Element:draw +-- always calls Renderer:draw", and Thamed is the behavior-system embodiment of +-- that invariant. Returns true for `themeComponent`-bearing props (the spec's +-- headline case) and for every other element so subsystems/rendering stay intact. +local function shouldAttach(props) + return true +end + +-- ---------------------------------------------------------------------------- +-- onAttach — create the Renderer with theme/blur config (formerly the +-- Renderer.new block of Element:_initImageAndRenderer). +-- ---------------------------------------------------------------------------- + +local function onAttach(element) + local Element = ElementClass(element) + + -- Create-or-reuse the Renderer. Thamed is the first render behavior in the + -- registry, so it normally creates the instance; Imageable (if attached) will + -- reuse this same instance for image config. Guarded so Imageable-onAttach- + -- first (defensive) does not clobber an existing renderer. + if element._renderer then + return + end + + -- NOTE: backgroundColor/borderColor/opacity/cornerRadius/themeComponent are + -- intentionally NOT passed here. Renderer:draw() reads them from the element + -- as the single source of truth (see Renderer.lua draw()). Only renderer-owned + -- state (theme, blur) is cached on the renderer; image config is added by the + -- Imageable behavior. border is element-sourced too. + element._renderer = Element._Renderer.new({ + theme = element.theme, + scaleCorners = element.scaleCorners, + scalingAlgorithm = element.scalingAlgorithm, + contentBlur = element.contentBlur, + backdropBlur = element.backdropBlur, + }, Element._rendererDeps) +end + +-- ---------------------------------------------------------------------------- +-- onDraw — the single Renderer:draw call (formerly the first call in +-- Element:draw). Paints all core visual layers for this element. +-- ---------------------------------------------------------------------------- + +local function onDraw(element, ctx) + local renderer = element._renderer + if not renderer then + return + end + renderer:draw(element, ctx and ctx.backdropCanvas) +end + +-- ---------------------------------------------------------------------------- +-- onDetach — no-op. Element:_cleanup preserves element structure for +-- inspection (the original invariant), so the Renderer reference is released +-- when the element is GC'd rather than torn down here. Present as an explicit +-- hook so the behavior conforms to the full lifecycle contract. +-- ---------------------------------------------------------------------------- + +local function onDetach() end + +-- ---------------------------------------------------------------------------- +-- saveState — blur-region snapshot (formerly the `blur` branch of +-- Element:saveState). Returns `{ blur = {...} }` when the element configures a +-- backdrop or content blur, so the Blur cache can invalidate by element id; +-- nil otherwise. Mode-agnostic to match the legacy contract (the snapshot is +-- only read back by the cache-invalidation path, which itself is +-- immediate-mode-only via FlexLove.endFrame). +-- ---------------------------------------------------------------------------- + +local function saveState(element) + if not (element.backdropBlur or element.contentBlur) then + return nil + end + local blur = { + _blurX = element.x, + _blurY = element.y, + _blurWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right), + _blurHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom), + } + if element.backdropBlur then + blur._backdropBlurRadius = element.backdropBlur.radius + blur._backdropBlurQuality = element.backdropBlur.quality or 5 + end + if element.contentBlur then + blur._contentBlurRadius = element.contentBlur.radius + blur._contentBlurQuality = element.contentBlur.quality or 5 + end + return { blur = blur } +end + +-- restoreState — no-op: blur cache data is used for invalidation, not +-- restoration (see file header). Present so the behavior conforms to the +-- lifecycle contract without replaying geometry that the cache recomputes. + +-- ---------------------------------------------------------------------------- +-- Build the (stateless, shared, immutable) behavior instance. +-- ---------------------------------------------------------------------------- + +local Themed = Behavior.new({ + onAttach = onAttach, + onDetach = onDetach, + onUpdate = function() end, + onDraw = onDraw, + saveState = saveState, + restoreState = function() end, +}) + +-- Expose the predicate at module level so callers/tests can reference it +-- directly without an element instance (mirrors Behavior.shouldAttach / +-- Clickable.shouldAttach). +Themed.shouldAttach = shouldAttach + +return Themed diff --git a/libs/flexlove/modules/types.lua b/libs/flexlove/modules/types.lua new file mode 100644 index 00000000..dd504797 --- /dev/null +++ b/libs/flexlove/modules/types.lua @@ -0,0 +1,662 @@ +---@class SelectOptionProps +---@field value any -- Stable option value owned by the parent select +---@field label string? -- Optional label override, falls back to the element text +---@field disabled boolean? -- Whether the option can be selected +local SelectOptionProps = {} + +---@class SelectParentProps +---@field value any -- Currently selected option value +---@field open boolean? -- Initial open state for the select container +---@field placeholder string? -- Fallback text when no option is selected +---@field selectFrame Element? -- Optional pre-instantiated dropdown container; intended to be unattached before being adopted by the select +---@field onChange fun(element:Element, value:any, option:SelectOptionProps)? -- Called when selection changes +local SelectParentProps = {} + +---@class Animation +local Animation = {} + +---@class Color +local Color = {} + +---@class Theme +local Theme = {} + +---@class ThemeManager +local ThemeManager = {} + +--=====================================-- +-- For Animation.lua +--=====================================-- +---@alias EasingFunction fun(t:number): number + +---@class AnimationProps +---@field duration number -- Duration in seconds +---@field start table -- Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, transform, etc.) +---@field final table -- Final values (same properties as start) +---@field easing string? -- Easing function name: "linear", "easeInQuad", "easeOutQuad", "easeInOutQuad", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInExpo", "easeOutExpo" (default: "linear") +---@field keyframes AnimationKeyframe[]? -- Array of keyframes for complex animations +---@field onStart fun(animation:Animation, element:Element?)? -- Called when animation starts +---@field onUpdate fun(animation:Animation, element:Element?, progress:number)? -- Called each frame with progress (0-1) +---@field onComplete fun(animation:Animation, element:Element?)? -- Called when animation completes +---@field onCancel fun(animation:Animation, element:Element?)? -- Called when animation is cancelled +---@field transform TransformProps? -- Additional transform properties (legacy support) +---@field transition table? -- Transition properties (legacy support) +local AnimationProps = {} + +---@class Transform +---@field rotate number? Rotation in radians (default: 0) +---@field scaleX number? X-axis scale (default: 1) +---@field scaleY number? Y-axis scale (default: 1) +---@field translateX number? X translation in pixels (default: 0) +---@field translateY number? Y translation in pixels (default: 0) +---@field skewX number? X-axis skew in radians (default: 0) +---@field skewY number? Y-axis skew in radians (default: 0) +---@field originX number? Transform origin X (0-1, default: 0.5) +---@field originY number? Transform origin Y (0-1, default: 0.5) +local Transform = {} + +---@alias TransformProps Transform + +---@class TransitionProps +---@field duration number? +---@field easing string? +---@field delay number? +---@field onComplete fun(element:Element)? + +--=====================================-- +-- For Element.lua +--=====================================-- +---@class ElementProps +---@field id string? -- Unique identifier for the element (auto-generated in immediate mode if not provided) +---@field mode "immediate"|"retained"|nil -- Lifecycle mode override: "immediate" (auto-managed state), "retained" (manual state), nil (use global mode from FlexLove.getMode(), default) +---@field parent Element? -- Parent element for hierarchical structure +---@field x number|string|CalcObject? -- X coordinate: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0) +---@field y number|string|CalcObject? -- Y coordinate: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0) +---@field z number? -- Z-index for layering (default: 0, clamped to -999..999) +---@field tabIndex number? -- Tab navigation order: >0 (explicit order, visited first), 0 or nil (natural document order), -1 (excluded from keyboard navigation) +---@field width number|string|CalcObject? -- Width of the element: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: calculated automatically) +---@field height number|string|CalcObject? -- Height of the element: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: calculated automatically) +---@field minWidth number|string|CalcObject? -- Minimum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal. +---@field maxWidth number|string|CalcObject? -- Maximum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal. +---@field minHeight number|string|CalcObject? -- Minimum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical. +---@field maxHeight number|string|CalcObject? -- Maximum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical. +---@field top number|string|CalcObject? -- Offset from top edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning) +---@field right number|string|CalcObject? -- Offset from right edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning) +---@field bottom number|string|CalcObject? -- Offset from bottom edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning) +---@field left number|string|CalcObject? -- Offset from left edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning) +---@field border Border? -- Border configuration for the element +---@field borderColor Color? -- Color of the border (default: black) +---@field opacity number? -- Element opacity 0-1 (default: 1) +---@field visibility "visible"|"hidden"? -- Element visibility (default: "visible") +---@field display boolean? -- Whether element participates in layout, rendering, and hit testing (default: true). Set false for CSS display:none behavior (zero layout space, no rendering, no hit testing). NOTE: In retained mode, toggling at runtime requires setting the parent's `_dirty = true` or calling `layoutChildren()` on the parent to trigger re-layout. +---@field backgroundColor Color? -- Background color (default: transparent) +---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0) +---@field gap number|string|CalcObject? -- Space between children elements: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0) +---@field padding number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Padding around children: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0}) +---@field margin number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Margin around element: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0}) +---@field text string? -- Text content to display (default: nil) +---@field textAlign TextAlignSpec? -- Alignment of the text content: simple string, compound string ("top-left"), or {horizontal, vertical} table (default: START) +---@field textColor Color? -- Color of the text content (default: black or theme text color) +---@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md" or 12px) +---@field minTextSize number? -- Minimum text size in pixels for auto-scaling +---@field maxTextSize number? -- Maximum text size in pixels for auto-scaling +---@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default, inherits from parent) +---@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true) +---@field positioning Positioning? -- Layout positioning mode: "absolute"|"relative"|"flex"|"grid" (default: RELATIVE) +---@field flexDirection FlexDirection? -- Direction of flex layout: "horizontal"|"vertical"|"row"|"column"|"row-reverse"|"column-reverse"|"horizontal-reverse"|"vertical-reverse" (row→horizontal, column→vertical, row-reverse→horizontal-reverse, column-reverse→vertical-reverse, default: HORIZONTAL) +---@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START) +---@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH) +---@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH) +---@field flexWrap FlexWrap? -- Whether children wrap to multiple lines: "nowrap"|"wrap"|"wrap-reverse" (default: NOWRAP) +---@field flex number|string? -- Shorthand for flexGrow, flexShrink, flexBasis: number (flex-grow only), string ("1 0 auto"), or nil (default: nil) +---@field flexGrow number? -- How much the element should grow relative to siblings (default: 0) +---@field flexShrink number? -- How much the element should shrink relative to siblings (default: 1) +---@field flexBasis number|string|CalcObject? -- Initial size before growing/shrinking: number (px), string ("50%", "10vw", "auto"), or CalcObject (default: "auto") +---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO) +---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO) +---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events +---@field onEventDeferred boolean? -- Whether onEvent callback should be deferred until after canvases are released (default: false) +---@field onFocus fun(element:Element)? -- Callback when element receives focus +---@field onFocusDeferred boolean? -- Whether onFocus callback should be deferred (default: false) +---@field dropFocusOnSelection boolean? -- Override keyboard-navigation focus drop after Enter/Space activation (default: nil, uses KeyboardNavigation.config.dropFocusOnSelection) +---@field onBlur fun(element:Element)? -- Callback when element loses focus +---@field onBlurDeferred boolean? -- Whether onBlur callback should be deferred (default: false) +---@field onTextInput fun(element:Element, text:string)? -- Callback when text is input +---@field onTextInputDeferred boolean? -- Whether onTextInput callback should be deferred (default: false) +---@field onTextChange fun(element:Element, text:string)? -- Callback when text content changes +---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false) +---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed +---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false) +---@field onCreate fun(element:Element, props:table)? -- Callback when element is created, receives the element and original creation props +---@field onCreateDeferred boolean? -- Whether onCreate callback should be deferred (default: false) +---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease) +---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false) +---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.) +---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false) +---@field touchEnabled boolean? -- Whether the element responds to touch events (default: true) +---@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false) +---@field transform TransformProps? -- Transform properties for animations and styling +---@field transition TransitionProps? -- Transition settings for animations +---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil) +---@field gridRows number|table? -- Number of equal 1fr rows, or array of track specs (e.g. {"1fr","100px","auto"}) +---@field gridColumns number|table? -- Number of equal 1fr columns, or array of track specs (e.g. {"1fr","100px","auto"}) +---@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0) +---@field rowGap number|string|CalcObject? -- Gap between grid rows: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0) +---@field theme string? -- Theme name to use (e.g., "space", "metal"). Defaults to theme from flexlove.init() +---@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied +---@field disabled boolean? -- Whether the element is disabled (default: false) +---@field active boolean? -- Whether the element is active/focused (for inputs, default: false) +---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false, or true when using themeComponent) +---@field themeStateLock boolean|string? -- Lock theme state: true/"default" = lock to base state, false = normal behavior, string = specific state ("hover", "pressed", "active", "disabled") (default: false) +---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually (e.g. {"hover", "pressed"}). Interaction logic still fires. +---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme or {1, 1}) +---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges. E.g., 2 = 2x size (overrides theme setting) +---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting) +---@field contentBlur {radius:number, quality:number?}? -- Blur the element's content including children (radius: pixels, quality: 1-10, default(quality): 5) +---@field backdropBlur {radius:number, quality:number?}? -- Blur content behind the element (radius: pixels, quality: 1-10, default(quality): 5) +---@field editable boolean? -- Whether the element is editable (default: false) +---@field multiline boolean? -- Whether the element supports multiple lines (default: false) +---@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line) +---@field maxLines number? -- Maximum number of lines (default: nil) +---@field maxLength number? -- Maximum text length in characters (default: nil) +---@field placeholder string? -- Placeholder text when empty (default: nil) +---@field passwordMode boolean? -- Whether to display text as password (default: false, disables multiline) +---@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text") +---@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip") +---@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line) +---@field autoGrow boolean? -- Whether element auto-grows with text (default: false for single-line, true for multi-line) +---@field selectOnFocus boolean? -- Whether to select all text on focus (default: false) +---@field cursorColor Color? -- Cursor color (default: nil, uses textColor) +---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default) +---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5) +---@field selectParent SelectParentProps? -- Parent-owned select/dropdown state and callbacks +---@field selectOption SelectOptionProps? -- Option metadata attached to a child of a select parent +---@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "hidden") +---@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow) +---@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow) +---@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12) +---@field scrollbarColor Color? -- Scrollbar thumb color (default: Color.new(0.5, 0.5, 0.5, 0.8)) +---@field scrollbarTrackColor Color? -- Scrollbar track color (default: Color.new(0.2, 0.2, 0.2, 0.5)) +---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6) +---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2) +---@field scrollSpeed number? -- Pixels per wheel notch (default: 20) +---@field invertScroll boolean? -- Invert mouse wheel scroll direction (default: false) +---@field smoothScrollEnabled boolean? -- Enable smooth scrolling animation for wheel events (default: false) +---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars, default: uses first scrollbar or fallback rendering) +---@field scrollbarKnobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -- Offset for scrollbar knob/handle position in pixels (number for both axes, or table for per-axis control, default: 0, adds to theme offset) +---@field scrollbarPlacement "reserve-space"|"overlay"? -- Scrollbar rendering mode: "reserve-space" (reduces content area, default) or "overlay" (renders over content) +---@field scrollbarBalance boolean? -- When true, reserve scrollbar space on both sides of content for visual balance (default: false) +---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false) +---@field imagePath string? -- Path to image file (auto-loads via ImageCache) +---@field image love.Image? -- Image object to display +---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill") +---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center") +---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity) +---@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat") +---@field imageTint Color? -- Color to tint the image (default: nil/white, no tint) +---@field onImageLoad fun(element:Element, image:love.Image)? -- Callback when image loads successfully +---@field onImageLoadDeferred boolean? -- Whether onImageLoad callback should be deferred (default: false) +---@field onImageError fun(element:Element, error:string)? -- Callback when image fails to load +---@field onImageErrorDeferred boolean? -- Whether onImageError callback should be deferred (default: false) +---@field _scrollX number? -- Internal: scroll X position (restored in immediate mode) +---@field _scrollY number? -- Internal: scroll Y position (restored in immediate mode) +---@field children? ElementProps[] +---@field userdata table? -- User-defined data storage for custom properties +---@field ariaRole ARIA? -- ARIA role for screen readers (e.g., "button", "link", "dialog") +---@field ariaLabel string? -- Accessible name for screen readers (overrides text content) +---@field ariaDescribedBy string? -- ID of element that describes this element +---@field ariaExpanded boolean? -- Whether element is expanded/collapsed (for containers) +---@field ariaPressed boolean? -- Whether element is pressed (for toggle buttons) +---@field ariaChecked boolean? -- Whether element is checked (for checkboxes/radios) +---@field ariaDisabled boolean? -- Whether element is disabled (overrides disabled property) +---@field ariaBusy boolean? -- Whether element is processing (for live regions) +---@field ariaLive "off"|"polite"|"assertive"? -- Live region priority for announcements +local ElementProps = {} + +---@class Border +---@field top boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0) +---@field right boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0) +---@field bottom boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0) +---@field left boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0) +local Border = {} + +--=====================================-- +-- For KeyboardNavigation.lua +--=====================================-- +---@class KeyboardNavigationKeyConfig +---@field next string -- Key used to move to the next focusable element +---@field previous string -- Key used to move to the previous focusable element +---@field up string -- Key used for directional navigation upward +---@field down string -- Key used for directional navigation downward +---@field left string -- Key used for directional navigation leftward +---@field right string -- Key used for directional navigation rightward +---@field activate string[] -- Keys that activate the currently focused element +---@field dismiss string -- Key used to dismiss or clear the currently focused element +---@field toggleDebug string -- Key used to toggle keyboard-navigation debug tooling +---@field inspect string -- Key used to inspect the currently focused element in developer tools +local KeyboardNavigationKeyConfig = {} + +---@class KeyboardNavigationDeveloperToolsConfig +---@field enabled boolean? -- Enable keyboard-navigation developer tools (default: true) +---@field showProperties boolean? -- Show focused element properties in developer tools (default: true) +---@field highlightColor number[]? -- RGBA color used for keyboard-navigation debug highlighting (default: {1, 0.8, 0, 0.5}) +local KeyboardNavigationDeveloperToolsConfig = {} + +---@class KeyboardNavigationFocusIndicatorConfig +---@field enabled boolean? -- Enable the keyboard focus indicator (default: true) +---@field color number[]? -- RGBA color of the focus indicator (default: {0.2, 0.6, 1.0, 0.8}) +---@field lineWidth number? -- Focus indicator stroke width in pixels (default: 2) +---@field inset number? -- Offset from the element bounds in pixels (default: -3) +---@field borderRadius number? -- Focus indicator border radius in pixels (default: 4) +---@field animationDuration number? -- Focus indicator entrance animation duration in seconds (default: 0.15) +---@field pulseEnabled boolean? -- Enable pulse animation for the focus indicator when supported +---@field pulseDuration number? -- Seconds per pulse cycle +---@field pulseScaleMin number? -- Minimum scale during pulse animation +---@field pulseScaleMax number? -- Maximum scale during pulse animation +---@field draw fun(element:Element, bounds:table, style:KeyboardNavigationFocusIndicatorConfig)? -- Custom focus indicator renderer +local KeyboardNavigationFocusIndicatorConfig = {} + +---@class KeyboardNavigationConfig +---@field enabled boolean? -- Enable or disable keyboard navigation globally (default: true) +---@field debugMode boolean? -- Enable keyboard-navigation debug logging (default: false) +---@field keys KeyboardNavigationKeyConfig? -- Key bindings used by keyboard navigation +---@field wrapAround boolean? -- Allow wrapping from last to first focusable element (default: true) +---@field directionalNavigation boolean? -- Enable arrow-key directional navigation (default: true) +---@field focusVisible boolean? -- Show the focus indicator for keyboard-driven focus (default: true) +---@field autofocusOnCreate boolean? -- Auto-focus the first focusable element on creation (default: false) +---@field dropFocusOnSelection boolean? -- Drop focus after Enter/Space activates an element (default: true) +---@field developerTools KeyboardNavigationDeveloperToolsConfig? -- Developer tool settings for keyboard navigation +---@field focusIndicator KeyboardNavigationFocusIndicatorConfig? -- Focus indicator style configuration +local KeyboardNavigationConfig = {} + +--=====================================-- +-- For FlexLove.init() +--=====================================-- +---@class FlexLoveConfig +---@field baseScale {width:number?, height:number?}? -- Base resolution for responsive scaling (default: nil, no scaling) +---@field theme string|ThemeDefinition? -- Theme name (string) or ThemeDefinition to use (default: nil, no theme) +---@field immediateMode boolean? -- Enable immediate mode (React-like, recreates UI each frame) vs retained mode (default: false) +---@field autoFrameManagement boolean? -- Automatically call beginFrame/endFrame (default: false) +---@field stateRetentionFrames number? -- Number of frames to retain unused state in immediate mode (default: 60) +---@field maxStateEntries number? -- Maximum number of state entries before forcing cleanup (default: 1000) +---@field includeStackTrace boolean? -- Include stack traces in error messages (default: true) +---@field reportingLogLevel LOG_LEVEL? -- Error log level: 1: critical, 2: error, 3: warn, 4: info, 5: debug/all (default: 3:warn) +---@field errorLogTarget string? -- Error log target: "console", "file", "both" (default: "console") +---@field errorLogFile string? -- Path to error log file (default: "flexlove_errors.log") +---@field errorLogMaxSize number? -- Maximum error log file size in bytes (default: 1048576, 1MB) +---@field maxErrorLogFiles number? -- Maximum number of rotated error log files (default: 5) +---@field errorLogRotateEnabled boolean? -- Enable error log rotation (default: true) +---@field performanceMonitoring boolean? -- Enable performance monitoring (default: true) +---@field performanceHudKey string? -- Key to toggle performance HUD (default: "f3") +---@field performanceHudPosition {x:number, y:number}? -- Position of performance HUD (default: {x=10, y=10}) +---@field performanceWarningThreshold number? -- Frame time warning threshold in ms (default: 13.0) +---@field performanceCriticalThreshold number? -- Frame time critical threshold in ms (default: 16.67) +---@field performanceLogToConsole boolean? -- Log performance metrics to console (default: false) +---@field performanceWarnings boolean? -- Enable performance warnings (default: false) +---@field memoryProfiling boolean? -- Enable memory profiling (default: false, auto-enabled in immediate mode) +---@field gcStrategy string? -- Garbage collection strategy: "auto", "periodic", "manual", "disabled" (default: "auto") +---@field gcMemoryThreshold number? -- Memory threshold in MB before forcing GC (default: 100) +---@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60) +---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200) +---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true) +---@field keyboardNavigation boolean|KeyboardNavigationConfig? -- Enable keyboard navigation with defaults (`true`) or provide configuration overrides +---@field debugDraw boolean? -- Enable debug draw overlay showing element boundaries with random colors (default: false) +---@field debugDrawKey string? -- Key to toggle debug draw overlay at runtime (default: nil, no toggle key) +local FlexLoveConfig = {} + +--=====================================-- +-- Public FlexLove API +--=====================================-- +---@alias TextAlignCompound "top-left" | "top-center" | "top-right" | "center-left" | "center-center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right" +---@alias TextAlignSpec TextAlign | TextAlignCompound | {horizontal: TextAlign, vertical: TextAlignVertical} + +---@class FlexLoveEnums +---@field TextAlign TextAlign +---@field TextAlignVertical TextAlignVertical +---@field Positioning Positioning +---@field FlexDirection FlexDirection +---@field JustifyContent JustifyContent +---@field JustifySelf JustifySelf +---@field AlignItems AlignItems +---@field AlignSelf AlignSelf +---@field AlignContent AlignContent +---@field FlexWrap FlexWrap +---@field TextSize TextSize +---@field ImageRepeat ImageRepeat +---@field ARIA ARIA +local FlexLoveEnums = {} + +---@class AnimationKeyframe +---@field at number -- Normalized time position (0-1) +---@field values table -- Property values at this keyframe +---@field easing string|EasingFunction? -- Easing used between this and the next keyframe +local AnimationKeyframe = {} + +---@class AnimationGroupProps +---@field animations Animation[] -- Animations to coordinate +---@field mode "parallel"|"sequence"|"stagger"? -- Group playback mode (default: "parallel") +---@field stagger number? -- Delay between staggered animations in seconds (default: 0.1) +---@field onComplete fun(group:AnimationGroup)? -- Called when all animations complete +---@field onStart fun(group:AnimationGroup)? -- Called when the group starts +local AnimationGroupProps = {} + +---@class AnimationGroup +---@field animations Animation[] +---@field mode "parallel"|"sequence"|"stagger" +---@field stagger number +---@field onComplete fun(group:AnimationGroup)? +---@field onStart fun(group:AnimationGroup)? +local AnimationGroup = {} + +---@class Animation +---@field duration number +---@field start table +---@field final table +---@field elapsed number +---@field easing EasingFunction +---@field keyframes AnimationKeyframe[]? +---@field transform TransformProps? +---@field transition TransitionProps? +---@field onStart fun(animation:Animation, element:Element?)? +---@field onUpdate fun(animation:Animation, element:Element?, progress:number)? +---@field onComplete fun(animation:Animation, element:Element?)? +---@field onCancel fun(animation:Animation, element:Element?)? +---@field update fun(self:Animation, dt:number, element:table?): boolean +---@field findKeyframes fun(self:Animation, progress:number): AnimationKeyframe?, AnimationKeyframe? +---@field lerpKeyframes fun(self:Animation, prevFrame:AnimationKeyframe, nextFrame:AnimationKeyframe, easedT:number): table +---@field interpolate fun(self:Animation): table +---@field apply fun(self:Animation, element:table) +---@field pause fun(self:Animation) +---@field resume fun(self:Animation) +---@field isPaused fun(self:Animation): boolean +---@field reverse fun(self:Animation) +---@field isReversed fun(self:Animation): boolean +---@field setSpeed fun(self:Animation, speed:number) +---@field getSpeed fun(self:Animation): number +---@field seek fun(self:Animation, time:number) +---@field getState fun(self:Animation): string +---@field cancel fun(self:Animation, element:table?) +---@field reset fun(self:Animation) +---@field getProgress fun(self:Animation): number +---@field chain fun(self:Animation, nextAnimation:Animation|function): Animation +---@field delay fun(self:Animation, seconds:number): Animation +---@field repeatCount fun(self:Animation, count:number): Animation +---@field yoyo fun(self:Animation, enabled:boolean?): Animation +---@class AnimationModule +---@field Easing table -- Built-in easing functions and easing factories +---@field Transform table? -- Animation transform helpers exposed by the animation module +---@field Group AnimationGroup -- Animation group class table +---@field new fun(props:AnimationProps): Animation +---@field fade fun(duration:number, fromOpacity:number, toOpacity:number, easing:string?): Animation +---@field scale fun(duration:number, fromScale:{width:number, height:number}, toScale:{width:number, height:number}, easing:string?): Animation +---@field keyframes fun(props:{duration:number, keyframes:AnimationKeyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?}): Animation +---@field chainSequence fun(animations:Animation[]): Animation +local AnimationModule = {} + +---@class ColorInputTable +---@field [1] number? +---@field [2] number? +---@field [3] number? +---@field [4] number? +---@field r number? +---@field g number? +---@field b number? +---@field a number? +local ColorInputTable = {} + +---@alias ColorInput string|Color|ColorInputTable + +---@class ColorModule +---@field new fun(r:number?, g:number?, b:number?, a:number?): Color +---@field fromHex fun(hexWithTag:string): Color +---@field validateColorChannel fun(value:any, max:number?): boolean, number? +---@field validateHexColor fun(hex:string): boolean, string? +---@field validateRGBColor fun(r:number, g:number, b:number, a:number?, max:number?): boolean, string? +---@field isValidColorFormat fun(value:any): string? +---@field sanitizeColor fun(value:any, default:Color?): Color +---@field parse fun(value:any): Color +---@field lerp fun(colorA:Color, colorB:Color, t:number): Color +local ColorModule = {} + +---@class ThemeManagerConfig +---@field theme string? -- Theme name override +---@field themeComponent string? -- Component name to resolve from the theme +---@field disabled boolean? -- Force disabled theme state +---@field active boolean? -- Force active theme state +---@field disableHighlight boolean? -- Disable pressed highlight overlay +---@field themeStateLock boolean|string? -- Lock the theme state to base/default or a named state +---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually +---@field scaleCorners number? -- Scale multiplier for 9-patch corners and edges +---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for non-stretched theme regions +local ThemeManagerConfig = {} + +---@class ThemeRegion +---@field x number +---@field y number +---@field w number +---@field h number +local ThemeRegion = {} + +---@class ThemeComponent +---@field atlas string|love.Image? +---@field insets {left:number, top:number, right:number, bottom:number}? +---@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion}? +---@field stretch {horizontal:table, vertical:table}? +---@field states table? +---@field contentAutoSizingMultiplier {width:number?, height:number?}? +---@field scaleCorners number? +---@field scalingAlgorithm "nearest"|"bilinear"? +---@field knobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? +local ThemeComponent = {} + +---@class ThemeDefinition +---@field name string +---@field atlas string|love.Image? +---@field components table +---@field scrollbars table? +---@field colors table? +---@field fonts table? +---@field contentAutoSizingMultiplier {width:number?, height:number?}? +local ThemeDefinition = {} + +---@class Theme +---@field name string +---@field atlas love.Image? +---@field atlasData love.ImageData? +---@field components table +---@field scrollbars table +---@field colors table +---@field fonts table +---@field contentAutoSizingMultiplier {width:number?, height:number?}? +---@class ThemeManager +---@field theme string? +---@field themeComponent string? +---@field disabled boolean +---@field active boolean +---@field disableHighlight boolean? +---@field themeStateLock boolean|string? +---@field themeComponentDisabledStates table +---@field scaleCorners number? +---@field scalingAlgorithm "nearest"|"bilinear"? +---@field updateState fun(self:ThemeManager, isHovered:boolean, isPressed:boolean, isFocused:boolean, isDisabled:boolean): string +---@field getState fun(self:ThemeManager): string +---@field setState fun(self:ThemeManager, state:string) +---@field hasThemeComponent fun(self:ThemeManager): boolean +---@field getTheme fun(self:ThemeManager): Theme? +---@field getComponent fun(self:ThemeManager): ThemeComponent? +---@field getStateComponent fun(self:ThemeManager): ThemeComponent? +---@field getScrollbarComponent fun(self:ThemeManager, scrollbarName:string?): ThemeComponent? +---@field getStyle fun(self:ThemeManager, property:string): any? +---@field _getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table? +---@field getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table? -- deprecated, use getScaledContentPadding +---@field getScaledContentPadding fun(self:ThemeManager, borderBoxWidth:number, borderBoxHeight:number): table? +---@field getContentAutoSizingMultiplier fun(self:ThemeManager): table? +---@field getDefaultFontFamily fun(self:ThemeManager): string? +---@field setTheme fun(self:ThemeManager, themeName:string?, componentName:string?) +---@field validateThemeStateLock fun(self:ThemeManager): boolean +---@class Color +---@field r number +---@field g number +---@field b number +---@field a number +---@field toRGBA fun(self:Color): number, number, number, number +---@class ThemeModule +---@field Manager ThemeManager -- Theme manager class table +---@field new fun(definition:ThemeDefinition): Theme +---@field load fun(path:string): Theme? +---@field setActive fun(themeOrName:string|Theme) +---@field getActive fun(): Theme? +---@field getComponent fun(componentName:string, state:string?): ThemeComponent? +---@field getDefaultScrollbar fun(): ThemeComponent? +---@field getScrollbar fun(scrollbarName:string, state:string?): ThemeComponent? +---@field getFont fun(fontName:string): string? +---@field getColor fun(colorName:string): Color? +---@field hasActive fun(): boolean +---@field getRegisteredThemes fun(): table +---@field getColorNames fun(): string[] +---@field getAllColors fun(): table +---@field getColorOrDefault fun(colorName:string, fallback:Color): Color +---@field get fun(themeName:string): Theme? +---@field validateTheme fun(theme:table?, options:table?): boolean, table +---@field sanitizeTheme fun(theme:table?): table +local ThemeModule = {} + +---@class FlexLove +---@field _VERSION string +---@field _DESCRIPTION string +---@field _URL string +---@field _LICENSE string +---@field Animation AnimationModule? +---@field Color ColorModule +---@field Theme ThemeModule? +---@field enums FlexLoveEnums +---@field isReady fun(): boolean +---@field init fun(config:FlexLoveConfig?) +---@field setKeyboardNavigationDebug fun(enabled:boolean) +---@field enableKeyboardNavigation fun(config:KeyboardNavigationConfig?) +---@field deferCallback fun(callback:function) +---@field executeDeferredCallbacks fun() +---@field resize fun() +---@field setMode fun(mode:"immediate"|"retained") +---@field getMode fun(): "immediate"|"retained" +---@field beginFrame fun() +---@field endFrame fun() +---@field draw fun(gameDrawFunc:function|nil, postDrawFunc:function|nil) +---@field getElementAtPosition fun(x:number, y:number): Element? +---@field update fun(dt:number) +---@field collectGarbage fun(mode:string?, stepSize:number?): number? +---@field setGCStrategy fun(strategy:"auto"|"periodic"|"manual"|"disabled") +---@field getGCStats fun(): GCStats +---@field textinput fun(text:string) +---@field keypressed fun(key:string, scancode:string, isrepeat:boolean) +---@field wheelmoved fun(dx:number, dy:number) +---@field touchpressed fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number) +---@field touchmoved fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number) +---@field touchreleased fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number) +---@field getActiveTouchCount fun(): number +---@field getTouchOwner fun(touchId:string): Element? +---@field getById fun(id:string): Element? +---@field destroy fun() +---@field new fun(props:ElementProps, callback:function?): Element? +---@field getStateCount fun(): number +---@field clearState fun(id:string) +---@field clearAllStates fun() +---@field getStateStats fun(): table +---@field calc fun(expr:string): CalcObject +---@field getFocusedElement fun(): Element? +---@field setFocusedElement fun(element:Element?) +---@field clearFocus fun() +---@field setDebugDraw fun(enabled:boolean) +---@field getDebugDraw fun(): boolean +local FlexLove = {} + +--=====================================-- +-- For State Persistence +--=====================================-- +---@class ElementStateData +---@field _focused boolean? +---@field eventHandler table? -- EventHandler state +---@field textEditor table? -- TextEditor state +---@field scrollManager table? -- ScrollManager state +---@field blur BlurCacheData? -- Blur cache invalidation data + +---@class BlurCacheData +---@field _blurX number +---@field _blurY number +---@field _blurWidth number +---@field _blurHeight number +---@field _backdropBlurRadius number? +---@field _backdropBlurQuality number? +---@field _contentBlurRadius number? +---@field _contentBlurQuality number? + +--=====================================-- +-- For Calc.lua +--=====================================-- +---@class CalcDependencies +---@field ErrorHandler ErrorHandler? -- Error handler module + +---@class CalcToken +---@field type string -- Token type: "NUMBER", "UNIT", "PLUS", "MINUS", "MULTIPLY", "DIVIDE", "LPAREN", "RPAREN", "EOF" +---@field value number? -- Numeric value (for NUMBER tokens) +---@field unit string? -- Unit type: "px", "%", "vw", "vh" (for NUMBER tokens) + +---@class CalcASTNode +---@field type string -- Node type: "number", "add", "subtract", "multiply", "divide" +---@field value number? -- Numeric value (for "number" nodes) +---@field unit string? -- Unit type (for "number" nodes) +---@field left CalcASTNode? -- Left operand (for operator nodes) +---@field right CalcASTNode? -- Right operand (for operator nodes) + +---@class CalcObject +---@field _isCalc boolean -- Marker to identify calc objects (always true) +---@field _expr string -- Original expression string +---@field _ast CalcASTNode? -- Parsed abstract syntax tree (nil if parsing failed) +---@field _error string? -- Error message if parsing failed + +--=====================================-- +-- For FlexLove.lua Internals +--=====================================-- +---@class GCConfig +---@field strategy string -- "auto", "periodic", "manual", or "disabled" +---@field memoryThreshold number -- MB before forcing GC +---@field interval number -- Frames between GC steps (for periodic mode) +---@field stepSize number -- Work units per GC step (higher = more aggressive) + +---@class GCState +---@field framesSinceLastGC number -- Frames elapsed since last GC +---@field lastMemory number -- Last recorded memory usage in MB +---@field gcCount number -- Total number of GC operations performed + +---@class GCStats +---@field gcCount number -- Total number of GC operations performed +---@field framesSinceLastGC number -- Frames elapsed since last GC +---@field currentMemoryMB number -- Current memory usage in MB +---@field strategy string -- Current GC strategy +---@field threshold number -- Memory threshold in MB + +---@class FlexLoveDependencies +---@field Context table -- Context module +---@field Theme Theme? -- Theme module +---@field Color Color -- Color module +---@field Calc Calc -- Calc module +---@field Units table -- Units module +---@field Blur table? -- Blur module +---@field ImageRenderer table? -- ImageRenderer module +---@field ImageScaler table? -- ImageScaler module +---@field NinePatch table? -- NinePatch module +---@field RoundedRect table -- RoundedRect module +---@field ImageCache table? -- ImageCache module +---@field utils table -- Utils module +---@field Grid table -- Grid module +---@field InputEvent table -- InputEvent module +---@field GestureRecognizer table? -- GestureRecognizer module +---@field StateManager StateManager -- StateManager module +---@field TextEditor table -- TextEditor module +---@field LayoutEngine LayoutEngine -- LayoutEngine module +---@field Renderer table -- Renderer module +---@field EventHandler EventHandler -- EventHandler module +---@field ScrollManager table -- ScrollManager module +---@field ErrorHandler ErrorHandler -- ErrorHandler module +---@field Performance Performance? -- Performance module +---@field Transform table? -- Transform module diff --git a/libs/flexlove/modules/utils.lua b/libs/flexlove/modules/utils.lua new file mode 100644 index 00000000..71a074c0 --- /dev/null +++ b/libs/flexlove/modules/utils.lua @@ -0,0 +1,319 @@ +local modulePath = (...):match("(.-)[^%.]+$") +local function req(name) + return require(modulePath .. name) +end + +-- Focused sub-modules (utils now re-exports their surfaces as backward-compatible +-- aliases so call sites needn't change). Loaded eagerly so the aliases resolve. +local NumberValidation = req("NumberValidation") +local TextSanitizer = req("TextSanitizer") +local PathValidator = req("PathValidator") +local FontCache = req("FontCache") +local Enums = req("Enums") + +-- ErrorHandler is injected via init() (safeLoadImage closes over this upvalue). +local ErrorHandler = nil + +local enums = Enums.enums + +-- Generic math, table, and path helpers (utils' own concern). +-- All validation, font-cache, text-sanitization, and path-validation logic +-- lives in the focused sub-modules above and is re-exported below. + +--- Get current keyboard modifiers state +---@return {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} +local function getModifiers() + return { + shift = love.keyboard.isDown("lshift", "rshift"), + ctrl = love.keyboard.isDown("lctrl", "rctrl"), + alt = love.keyboard.isDown("lalt", "ralt"), + ---@diagnostic disable-next-line + super = love.keyboard.isDown("lgui", "rgui"), -- cmd/windows key + } +end + +local TEXT_SIZE_PRESETS = { + ["2xs"] = 0.75, + xxs = 0.75, + xs = 1.25, + sm = 1.75, + md = 2.25, + lg = 2.75, + xl = 3.5, + xxl = 4.5, + ["2xl"] = 4.5, + ["3xl"] = 5.0, + ["4xl"] = 7.0, +} + +--- Resolve text size preset to viewport units +---@param sizeValue string|number +---@return number?, string? +local function resolveTextSizePreset(sizeValue) + if type(sizeValue) == "string" then + local preset = TEXT_SIZE_PRESETS[sizeValue] + if preset then + return preset, "vh" + end + end + return nil, nil +end + +--- Auto-detect the base path where FlexLove is located +---@return string filesystemPath +local function getFlexLoveBasePath() + local info = debug.getinfo(1, "S") + if info and info.source then + local source = info.source + if source:sub(1, 1) == "@" then + source = source:sub(2) + end + + local filesystemPath = source:match("(.*/)") + if filesystemPath then + local fsPath = filesystemPath + fsPath = fsPath:gsub("^%./", "") + fsPath = fsPath:gsub("/$", "") + fsPath = fsPath:gsub("/modules$", "") + return fsPath + end + end + return "libs" +end + +local FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath() + +--- Helper function to resolve paths relative to FlexLove +---@param path string +---@return string +local function resolveImagePath(path) + if path:match("^/") or path:match("^[A-Z]:") then + return path + end + return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path +end + +-- Math utilities + +--- Clamp a value between optional min/max bounds. Either bound may be nil. +--- When both bounds are inverted (min > max), max wins (matches CSS behavior). +---@param value number Value to clamp +---@param min number|nil Minimum value (nil = no lower bound) +---@param max number|nil Maximum value (nil = no upper bound) +---@return number Clamped value +local function clamp(value, min, max) + if min and value < min then + value = min + end + if max and value > max then + value = max + end + return value +end + +--- Linear interpolation between two values +---@param a number Start value +---@param b number End value +---@param t number Interpolation factor (0-1) +---@return number Interpolated value +local function lerp(a, b, t) + return a + (b - a) * t +end + +--- Round a number to the nearest integer +---@param value number Value to round +---@return number Rounded value +local function round(value) + return math.floor(value + 0.5) +end + +-- Image utilities + +--- Safely load an image with error handling +--- Returns both Image and ImageData to avoid deprecated getData() API +---@param imagePath string Path to image file +---@return love.Image?, love.ImageData?, string? Returns image, imageData, or nil with error message +local function safeLoadImage(imagePath) + local success, imageData = pcall(function() + return love.image.newImageData(imagePath) + end) + + if not success then + local errorMsg = string.format("Failed to load image data: %s - %s", imagePath, tostring(imageData)) + if ErrorHandler then + ErrorHandler:warn("utils", "RES_004", { + resourceType = "image data", + path = imagePath, + error = tostring(imageData), + }) + end + return nil, nil, errorMsg + end + + local imageSuccess, image = pcall(function() + return love.graphics.newImage(imageData) + end) + + if imageSuccess then + return image, imageData, nil + else + local errorMsg = string.format("Failed to create image: %s - %s", imagePath, tostring(image)) + if ErrorHandler then + ErrorHandler:warn("utils", "RES_004", { + resourceType = "image", + path = imagePath, + error = tostring(image), + }) + end + return nil, nil, errorMsg + end +end + +-- Color manipulation utilities + +--- Brighten a color by a factor +---@param r number Red component (0-1) +---@param g number Green component (0-1) +---@param b number Blue component (0-1) +---@param a number Alpha component (0-1) +---@param factor number Brightness factor (e.g., 1.2 for 20% brighter) +---@return number, number, number, number Brightened color components +local function brightenColor(r, g, b, a, factor) + return math.min(1, r * factor), math.min(1, g * factor), math.min(1, b * factor), a +end + +-- Property normalization utilities + +--- Normalize a boolean or table property with vertical/horizontal fields +---@param value boolean|table|nil Input value (boolean applies to both, table for individual control) +---@param defaultValue boolean Default value if nil (default: false) +---@return table Normalized table with vertical and horizontal fields +local function normalizeBooleanTable(value, defaultValue) + defaultValue = defaultValue or false + + if value == nil then + return { vertical = defaultValue, horizontal = defaultValue } + end + + if type(value) == "boolean" then + return { vertical = value, horizontal = value } + end + + if type(value) == "table" then + return { + vertical = value.vertical ~= nil and value.vertical or defaultValue, + horizontal = value.horizontal ~= nil and value.horizontal or defaultValue, + } + end + + return { vertical = defaultValue, horizontal = defaultValue } +end + +--- Normalize an offset value to {x, y} or {horizontal, vertical} format +---@param value number|table|nil Input value (number applies to both, table for individual control) +---@param defaultValue number Default value if nil (default: 0) +---@return table Normalized table with x/y or horizontal/vertical fields +local function normalizeOffsetTable(value, defaultValue) + defaultValue = defaultValue or 0 + + if value == nil then + return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue } + end + + if type(value) == "number" then + return { x = value, y = value, horizontal = value, vertical = value } + end + + if type(value) == "table" then + -- Support both {x, y} and {horizontal, vertical} formats + local x = value.x or value.horizontal or defaultValue + local y = value.y or value.vertical or defaultValue + return { + x = x, + y = y, + horizontal = x, + vertical = y, + } + end + + return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue } +end + +--- Apply content auto-sizing multiplier to a dimension +---@param value number The dimension value +---@param multiplier table? The contentAutoSizingMultiplier table {width:number?, height:number?} +---@param axis "width"|"height" Which axis to apply +---@return number The multiplied value +local function applyContentMultiplier(value, multiplier, axis) + if multiplier and multiplier[axis] then + return value * multiplier[axis] + end + return value +end + +--- Initialize dependencies +---@param deps table Dependencies: { ErrorHandler = ErrorHandler } +local function init(deps) + if type(deps) == "table" then + ErrorHandler = deps.ErrorHandler + end + -- Propagate shared ErrorHandler to focused sub-modules that need it. + NumberValidation.init({ ErrorHandler = ErrorHandler, clamp = clamp }) + TextSanitizer.init({ ErrorHandler = ErrorHandler }) + FontCache.init({ ErrorHandler = ErrorHandler, resolveImagePath = resolveImagePath }) + -- PathValidator has no external dependencies. +end + +return { + enums = enums, + FONT_CACHE = FontCache.FONT_CACHE, + resolveTextSizePreset = resolveTextSizePreset, + getModifiers = getModifiers, + TEXT_SIZE_PRESETS = TEXT_SIZE_PRESETS, + init = init, + clamp = clamp, + -- Alias for `clamp`; exposed under the size-clamping name so Element/LayoutEngine + -- and tests can reference min/max content-size clamping explicitly. + clampSize = clamp, + lerp = lerp, + round = round, + safeLoadImage = safeLoadImage, + brightenColor = brightenColor, + resolveImagePath = resolveImagePath, + normalizeBooleanTable = normalizeBooleanTable, + normalizeOffsetTable = normalizeOffsetTable, + applyContentMultiplier = applyContentMultiplier, + -- Backward-compatible aliases (delegated to focused sub-modules) + validateEnum = NumberValidation.validateEnum, + validateRange = NumberValidation.validateRange, + validateType = NumberValidation.validateType, + isNaN = NumberValidation.isNaN, + isInfinity = NumberValidation.isInfinity, + validateNumber = NumberValidation.validateNumber, + sanitizeNumber = NumberValidation.sanitizeNumber, + validateInteger = NumberValidation.validateInteger, + validatePercentage = NumberValidation.validatePercentage, + validateOpacity = NumberValidation.validateOpacity, + validateDegrees = NumberValidation.validateDegrees, + validateCoordinate = NumberValidation.validateCoordinate, + validateDimension = NumberValidation.validateDimension, + normalizePath = PathValidator.normalizePath, + sanitizePath = PathValidator.sanitizePath, + isPathSafe = PathValidator.isPathSafe, + validatePath = PathValidator.validatePath, + getFileExtension = PathValidator.getFileExtension, + hasAllowedExtension = PathValidator.hasAllowedExtension, + sanitizeText = TextSanitizer.sanitizeText, + validateTextInput = TextSanitizer.validateTextInput, + validateTextRange = TextSanitizer.validateTextRange, + escapeHtml = TextSanitizer.escapeHtml, + escapeLuaPattern = TextSanitizer.escapeLuaPattern, + stripNonPrintable = TextSanitizer.stripNonPrintable, + resolveFontPath = FontCache.resolveFontPath, + getFont = FontCache.getFont, + getFontCacheStats = FontCache.getFontCacheStats, + setFontCacheSize = FontCache.setFontCacheSize, + clearFontCache = FontCache.clearFontCache, + preloadFont = FontCache.preloadFont, + resetFontCacheStats = FontCache.resetFontCacheStats, +} diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 6683efde..b234058b 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -108,7 +108,8 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`. ### Payload path `app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`, -`data/`, `assets/`, and the Red, Blue, and Yellow ROM manifests. The Android +`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`, +`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android packer verifies the Yellow manifest before it packages; if a partial source export omitted it, it restores the file from this checkout's Git data and then falls back to the project's GitHub copy. Generated game data, diff --git a/scripts/build.sh b/scripts/build.sh index 409eebe6..8e350935 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -65,8 +65,10 @@ mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux" say "packing game.love" LOVE_FILE="$WORK/game.love" rm -f "$LOVE_FILE" +# libs/ carries the vendored FlexLove toolkit the launcher UI is built on +# (src/import/LauncherView.lua); a build without it dies on the first frame. (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ - main.lua conf.lua src data assets tools/save-editor \ + main.lua conf.lua src libs data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua new file mode 100644 index 00000000..665b5504 --- /dev/null +++ b/src/import/LauncherSettings.lua @@ -0,0 +1,387 @@ +-- Launcher settings rows: the gear menu's model layer. +-- +-- The in-game OPTION menu (src/ui/OptionsMenu.lua) mutates game.save.options +-- and live-applies each change to the running engine. The launcher has no +-- running engine, so this builds the same ladders against the persisted +-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the +-- next boot's applyOptions pick the values up. Every ladder mirrors +-- OptionsMenu's semantics and stored values; when editing one, keep the two +-- in sync. ZOOM is deliberately absent: its range depends on the live +-- renderer's fit scale (Renderer:fitScale), which does not exist here. +-- +-- Rows are the same descriptor idiom OptionRows draws in game: +-- { label, value = fn() -> string, step = fn(dir) -> changed, +-- editText = { maxLen } } -- editText marks a free-text row; the view +-- opens its prompt and commits via setText. +-- +-- Mod rows come from each enabled mod's options_schema (the manager's auto-UI +-- contract, src/mods/ManagerState.lua buildOptionRows) and persist in +-- options.modOptions[modId][key], the exact table the loader reads on boot. + +local Strings = require("src.core.Strings") +local SaveData = require("src.core.SaveData") + +local LauncherSettings = {} + +local function wrapIndex(i, n) + i = i % n + if i < 0 then i = i + n end + return i +end + +local function volLabel(v) + v = v or 7 + return v == 0 and "OFF" or tostring(v) +end + +local function stepVolume(v, dir) + return math.max(0, math.min(7, (v or 7) + dir)) +end + +-- Cycle a stored value through an ordered list of {stored, label} pairs. +local function ladder(opts, key, pairsList, default) + local function index() + local cur = opts[key] + if cur == nil then cur = default end + for i, p in ipairs(pairsList) do + if p[1] == cur then return i end + end + return 1 + end + return function() return Strings(pairsList[index()][2]) end, + function(dir) + opts[key] = pairsList[wrapIndex(index() - 1 + (dir or 1), #pairsList) + 1][1] + return true + end +end + +-- TextSpeedOptionData delays with the original labels (OptionsMenu SPEEDS). +local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } +local FILTERS = { "OFF", "1X", "2X", "3X" } + +-- The core rows. Helper modules are required lazily under pcall: they are +-- pure label/cycle tables, but the launcher must never die because a render +-- module grew a dependency on live game data. +local function coreRows(opts) + local rows = {} + local function add(label, value, step) + rows[#rows + 1] = { label = label, value = value, step = step } + end + + add(Strings("TEXT SPEED"), ladder(opts, "textSpeed", SPEEDS, 3)) + add(Strings("BATTLE ANIMATION"), + ladder(opts, "animations", + { { true, "ON" }, { false, "OFF" } }, true)) + add(Strings("BATTLE STYLE"), + ladder(opts, "battleStyle", + { { "shift", "SHIFT" }, { "set", "SET" } }, "shift")) + add(Strings("BATTLE LAYOUT"), + ladder(opts, "battleLayout", + { { "og", "OG" }, { "wide", "WIDE" } }, "og")) + add(Strings("BATTLE SIZE"), + ladder(opts, "battleFit", + { { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed")) + add(Strings("BATTLE BG"), + ladder(opts, "battleBg", + { { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } }, + "white")) + add(Strings("UI LAYOUT"), + ladder(opts, "uiLayout", + { { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered")) + + add(Strings("MUSIC VOL"), + function() return volLabel(opts.musicVol) end, + function(dir) opts.musicVol = stepVolume(opts.musicVol, dir); return true end) + add(Strings("SFX VOL"), + function() return volLabel(opts.sfxVol) end, + function(dir) opts.sfxVol = stepVolume(opts.sfxVol, dir); return true end) + add(Strings("MUSIC FILTER"), + function() return FILTERS[(opts.musicFilter or 0) + 1] end, + function(dir) + opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS + return true + end) + + local okPerf, Performance = pcall(require, "src.core.Performance") + if okPerf then + add(Strings("PERFORMANCE"), + function() return Strings(Performance.label(opts.performance)) end, + function(dir) + opts.performance = Performance.cycle(opts.performance, dir) + return true + end) + end + + local okPal, PaletteFX = pcall(require, "src.render.PaletteFX") + if okPal then + add(Strings("COLORS"), + function() return PaletteFX.modeLabel(opts.colors or "gbc") end, + function(dir) + local cur, idx = opts.colors or "gbc", 1 + for i, m in ipairs(PaletteFX.MODES) do + if m == cur then idx = i break end + end + opts.colors = PaletteFX.MODES[wrapIndex(idx - 1 + dir, #PaletteFX.MODES) + 1] + return true + end) + end + + local okTilt, Tilt = pcall(require, "src.render.Tilt") + if okTilt then + add(Strings("TILT"), + function() return Tilt.levelLabel(opts.tilt or 0) end, + function(dir) + opts.tilt = wrapIndex((opts.tilt or 0) + dir, 4) + return true + end) + end + + -- issue #136: GBC FX soft-bricks the mobile present shader; same gate as + -- the in-game row. + local okFx, GBCFX = pcall(require, "src.render.GBCFX") + if okFx and GBCFX.isSupported() then + add(Strings("GBC FX"), + function() return GBCFX.levelLabel(opts.gbcfx or 0) end, + function(dir) + opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5) + return true + end) + end + + local okTile, TileRenderer = pcall(require, "src.render.TileRenderer") + if okTile and TileRenderer.VOID_FILLS then + add(Strings("VOID FILL"), + function() return TileRenderer.voidFillLabel(opts.voidFill) end, + function(dir) + local modes = TileRenderer.VOID_FILLS + local cur, idx = opts.voidFill or "trees", 1 + for i, m in ipairs(modes) do + if m == cur then idx = i break end + end + opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1] + return true + end) + end + + local okVm, VideoMode = pcall(require, "src.core.VideoMode") + if okVm then + add(Strings("VIDEO MODE"), + function() return VideoMode.modeLabel(opts.videoMode) end, + function(dir) + opts.videoMode = VideoMode.cycle(opts.videoMode, dir) + return true + end) + end + + local okFr, FaithfulRes = pcall(require, "src.core.FaithfulRes") + if okFr then + add(Strings("FAITHFUL RATIO"), + function() return FaithfulRes.label(opts.faithfulRes) end, + function(dir) + opts.faithfulRes = FaithfulRes.cycle(opts.faithfulRes, dir) + return true + end) + end + + local okCap, FrameCap = pcall(require, "src.core.FrameCap") + if okCap then + add(Strings("MAX FPS"), + function() return FrameCap.label(opts.fpsCap) end, + function(dir) + opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir) + return true + end) + end + + local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed") + if okSpd then + add(Strings("GAME SPEED"), + function() return GameSpeed.levelLabel(opts.speed) end, + function(dir) + opts.speed = GameSpeed.cycle(opts.speed, dir) + return true + end) + end + + -- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's + -- gate (mobile, or desktop forced by POKEPORT_TOUCH=1). + do + local env = os.getenv("POKEPORT_TOUCH") + local osName = love.system and love.system.getOS and love.system.getOS() + local show = env == "1" + or (env ~= "0" and (osName == "Android" or osName == "iOS")) + if show then + add(Strings("TOUCH PAD"), + function() + local tc = opts.touchControls + local on = not (type(tc) == "table" and tc.enabled == false) + return on and Strings("ON") or Strings("OFF") + end, + function() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled = tc.enabled == false + opts.touchControls = tc + return true + end) + end + end + + return rows +end + +-- ------- per-mod options (the manager's options_schema auto-UI contract) + +local OPTION_TYPES = { toggle = true, choice = true, number = true, text = true } + +-- Enabled mods with a loadable options_schema, discovered the same way +-- LauncherMods discovers manifests (mods/ one level deep; the launcher's +-- readiness check has already mounted a portable install's game folder). +local function discoverModSchemas(opts) + local fs = love and love.filesystem + local out = {} + if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end + if not fs.getInfo("mods") then return out end + local okJson, Json = pcall(require, "src.link.Json") + local okMan, Manifest = pcall(require, "src.mods.Manifest") + if not (okJson and okMan) then return out end + local enabledFlags = opts.mods or {} + local seen = {} + for _, name in ipairs(fs.getDirectoryItems("mods")) do + local path = "mods/" .. name + local info = fs.getInfo(path) + if info and (info.type == "directory" or info.type == "symlink") then + local raw = fs.read(path .. "/manifest.json") + local data = raw and select(1, Json.decode(raw)) + local okV, m = false, nil + if data then okV, m = pcall(Manifest.validate, data, path) end + if okV and m and not seen[m.id] and m.options_schema then + seen[m.id] = true + -- deriveList's enable resolution: a missing entry means enabled, + -- except experimental mods, which stay off until opted in. + local flag = enabledFlags[m.id] + local enabled = flag == true or (flag == nil and not m.experimental) + if enabled then + local chunk = fs.load(path .. "/" .. m.options_schema) + if chunk then + local okR, schema = pcall(chunk) + if okR and type(schema) == "table" then + out[#out + 1] = { id = m.id, name = m.name or m.id, schema = schema } + end + end + end + end + end + end + table.sort(out, function(a, b) return a.id < b.id end) + return out +end + +-- Rows for one mod's schema against options.modOptions (ManagerState's +-- persistence shape, so the game sees launcher edits on its next boot). +local function modRows(opts, mod) + local rows = {} + local modId = mod.id + local function stored() + local t = opts.modOptions + return t and t[modId] or nil + end + local function get(row) + local s = stored() + local v = s and s[row.key] + if v == nil then v = row.default end + return v + end + local function set(key, value) + opts.modOptions = opts.modOptions or {} + opts.modOptions[modId] = opts.modOptions[modId] or {} + opts.modOptions[modId][key] = value + end + + for _, row in ipairs(mod.schema) do + if type(row) ~= "table" or type(row.key) ~= "string" or row.key == "" + or not OPTION_TYPES[row.type] then + -- malformed rows are skipped silently here; the in-game manager is + -- where schema errors are reported to the author + elseif row.type == "toggle" then + rows[#rows + 1] = { label = row.label or row.key, + value = function() return get(row) and Strings("ON") or Strings("OFF") end, + step = function() + set(row.key, not get(row)) + return true + end } + elseif row.type == "choice" then + rows[#rows + 1] = { label = row.label or row.key, + value = function() + local cur = get(row) + for _, choice in ipairs(row.choices or {}) do + if choice[2] == cur then return tostring(choice[1]) end + end + local first = (row.choices or {})[1] + return first and tostring(first[1]) or "----" + end, + step = function(dir) + local choices = row.choices or {} + if #choices == 0 then return false end + local cur, index = get(row), 1 + for i, choice in ipairs(choices) do + if choice[2] == cur then index = i break end + end + set(row.key, choices[wrapIndex(index - 1 + dir, #choices) + 1][2]) + return true + end } + elseif row.type == "number" then + rows[#rows + 1] = { label = row.label or row.key, + value = function() return tostring(get(row) or 0) end, + step = function(dir) + local v = (tonumber(get(row)) or 0) + dir * (row.step or 1) + if row.min then v = math.max(row.min, v) end + if row.max then v = math.min(row.max, v) end + set(row.key, v) + return true + end } + elseif row.type == "text" then + rows[#rows + 1] = { label = row.label or row.key, + value = function() return tostring(get(row) or "") end, + editText = { maxLen = row.maxLen or 7 }, + setText = function(text) set(row.key, text) end } + end + end + if #rows > 0 then + rows[#rows + 1] = { label = Strings("RESET DEFAULTS"), + value = function() return "" end, + step = function() + for _, row in ipairs(mod.schema) do + if type(row) == "table" and type(row.key) == "string" + and OPTION_TYPES[row.type] then + set(row.key, row.default) + end + end + return true + end } + end + return rows +end + +-- Build the whole settings model: one options table (edited in place), +-- sections of rows, and a save() that persists it. The caller keeps the +-- model for as long as the panel is open; nothing else in the launcher +-- writes options while a modal covers it, so the cached table stays true. +function LauncherSettings.open() + local opts = SaveData.loadOptions() + local sections = { + { title = Strings("OPTIONS"), rows = coreRows(opts) }, + } + for _, mod in ipairs(discoverModSchemas(opts)) do + local rows = modRows(opts, mod) + if #rows > 0 then + sections[#sections + 1] = { title = mod.name, rows = rows } + end + end + return { + opts = opts, + sections = sections, + save = function() SaveData.saveOptions(opts) end, + } +end + +return LauncherSettings diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua new file mode 100644 index 00000000..0a076c5b --- /dev/null +++ b/src/import/LauncherView.lua @@ -0,0 +1,1826 @@ +-- The launcher's FlexLove view. RomImporter owns every piece of state and +-- all import/platform logic; this module rebuilds the immediate-mode element +-- tree from that state once per frame, so the UI can never drift from the +-- importer and every window size lays out fresh (no cached geometry, no hand +-- hit-testing). Flat design: solid fills and hairline borders only, no +-- gradients or glows. +-- +-- Interaction contract with RomImporter: +-- * every click handler only QUEUES work (imp._uiActions); update() drains +-- the queue after FlexLove.update, so a handler that tears the view down +-- (Play, Edit save) never destroys the tree that is dispatching it. +-- * clicks are deduped per control key (a touch tap can surface as both a +-- touch release and a synthesized mouse click; one action must not fire +-- twice -- the shape of #553's double import). +-- * hover state lives in imp._hot, written by events this frame and read +-- by styles next frame (immediate mode recreates elements every frame). +-- * the gamepad virtual cursor clicks through clickAt(), which dispatches +-- a synthetic event to the element under the pad pointer. + +local FlexLove = require("libs.flexlove.FlexLove") +local Color = FlexLove.Color +local SafeArea = require("src.core.SafeArea") +local GameVersion = require("src.core.GameVersion") +local Strings = require("src.core.Strings") + +local LauncherView = {} + +-- ------- palette (flat; alpha per use site) +local function rgba(r, g, b, a) + return Color.new(r / 255, g / 255, b / 255, a or 1) +end +local PAL = { + bg = { 10, 15, 34 }, + card = { 16, 23, 48 }, + rowBg = { 9, 14, 34 }, + border = { 120, 150, 220 }, + red = { 255, 60, 72 }, + blue = { 70, 150, 255 }, + gold = { 255, 203, 5 }, + green = { 62, 224, 138 }, + greenDark = { 22, 163, 90 }, + greenInk = { 6, 32, 18 }, + white = { 255, 255, 255 }, + detail = { 198, 208, 230 }, + warn = { 159, 176, 208 }, + gray = { 143, 163, 200 }, + disabled = { 120, 132, 158 }, + link = { 127, 208, 255 }, + danger = { 255, 83, 97 }, + chipModTop = { 61, 74, 109 }, +} +local function C(name, a) + local c = PAL[name] + return rgba(c[1], c[2], c[3], a) +end + +-- Every element in this view refuses to flex-shrink: overflow is always a +-- scroll container's job here, and the engine otherwise compresses +-- auto-height children inside height-constrained columns until their text +-- overlaps (the portrait single-column layout was the visible case). +-- horizontal padding of a props table, for content-width bookkeeping +local function propsPadH(p) + local pad = p.padding + if type(pad) == "number" then return pad * 2 end + if type(pad) == "table" then + return (pad.left or pad.horizontal or 0) + (pad.right or pad.horizontal or 0) + end + return 0 +end + +local function mk(props) + if props.flexShrink == nil then props.flexShrink = 0 end + -- Resolve "100%" here, against the parent's CONTENT width: the engine + -- resolves a percentage against the parent's border box and ignores its + -- padding, so every percent child of a padded container overflowed to the + -- right by exactly that padding (the clipped LOADED/Delete chips). + -- Parents are always created before their children in this view, so the + -- tracked inner width is available by the time a child asks. + if props.width == "100%" and props.parent and props.parent._innerW then + props.width = props.parent._innerW + end + local el = FlexLove.new(props) + if el then + if type(props.width) == "number" then + el._innerW = props.width - propsPadH(props) + elseif props.parent and props.parent._innerW then + el._innerW = props.parent._innerW - propsPadH(props) + end + end + return el +end + +local COMMUNITY_URL = "https://bois.icu" + +local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end + + +-- ------- lifecycle + +local function ensureFlex(imp) + if not FlexLove.isReady() then + FlexLove.init({ + immediateMode = true, + performanceMonitoring = false, + keyboardNavigation = false, + }) + end + if not imp._flex then + imp._flex = true + imp._hot = imp._hot or {} + imp._actAt = imp._actAt or {} + imp._uiActions = imp._uiActions or {} + -- Held backspace/arrows must repeat in the text fields; restored on + -- detach because the game's Input does its own per-step edge detection + -- and never expects repeated keypressed events. + if love.keyboard and love.keyboard.setKeyRepeat then + pcall(love.keyboard.setKeyRepeat, true) + end + end +end + +-- Tear the tree down before handing the screen to the game / editor: the +-- engine draws with raw love.graphics and must not share canvases or input +-- polling with a live UI toolkit. +function LauncherView.detach(imp) + if not imp._flex then return end + imp._flex = nil + if love.keyboard and love.keyboard.setKeyRepeat then + pcall(love.keyboard.setKeyRepeat, false) + end + pcall(FlexLove.destroy) +end + +function LauncherView.update(imp, dt) + if not imp._flex then return end + FlexLove.update(dt) + -- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free + -- to destroy the view (Play/Edit) or block in a native picker. + local queue = imp._uiActions + if queue and #queue > 0 then + imp._uiActions = {} + for _, fn in ipairs(queue) do + local ok, err = pcall(fn) + if not ok then print("launcher action error: " .. tostring(err)) end + end + end +end + +function LauncherView.wheelmoved(imp, dx, dy) + if not imp._flex then return end + pcall(FlexLove.wheelmoved, dx, dy) +end + +-- Synthetic click for the gamepad virtual cursor: find the element under the +-- pad pointer and run its handler with a click-shaped event. +function LauncherView.clickAt(imp, x, y) + if not imp._flex then return end + local ok, el = pcall(FlexLove.getElementAtPosition, x, y) + if not ok then return end + while el and not el.onEvent do el = el.parent end + if el and el.onEvent then + pcall(el.onEvent, el, { type = "click", button = 1, x = x, y = y, + modifiers = {}, clickCount = 1 }) + end +end + +-- ------- shared widget helpers + +-- One dedup window covers a touch release plus the mouse click SDL +-- synthesizes for the same tap. +local ACT_DEDUP = 0.35 + +local function queueAction(imp, key, fn, keepArm) + local now = love.timer.getTime() + local last = imp._actAt[key] + if last and now - last < ACT_DEDUP then return end + imp._actAt[key] = now + -- Any press that is not a Delete's own second click disarms the pending + -- delete confirm (#433's rule, preserved from the hit-rect launcher). + if not keepArm then imp._confirmDelete = nil end + imp._uiActions[#imp._uiActions + 1] = fn +end + +local function handler(imp, key, action, keepArm) + return function(_, ev) + if ev.type == "hover" then + imp._hot[key] = true + elseif ev.type == "unhover" then + imp._hot[key] = nil + elseif action and (ev.type == "click" or ev.type == "touchrelease") then + queueAction(imp, key, action, keepArm) + end + end +end + +-- Shared measuring fonts, cached by integer size: control widths and heights +-- come from the same faces the elements render with. Estimating them from +-- character counts broke at every scale except the one it was tuned on +-- (clipped Delete chips, button rows spilling out of their cards). +local measureFonts = {} +local function mfont(size) + size = math.max(8, math.floor(size + 0.5)) + local f = measureFonts[size] + if not f then + f = love.graphics.newFont(size) + measureFonts[size] = f + end + return f +end +local function textWidth(size, text) return mfont(size):getWidth(text) end +local function textHeight(size) return mfont(size):getHeight() end + +-- wrapped text height at a width, from the same font the element renders +local function wrapHeight(size, text, width) + if not text or text == "" or (width or 0) <= 0 then return 0 end + local f = mfont(size) + local _, lines = f:getWrap(text, width) + return math.max(1, #lines) * f:getHeight() +end + +-- Every text size in this file is already scaled by m.s, so FlexLove's own +-- viewport text scaling must stay off: with it on, the layout boxes shrink +-- away from the rendered glyphs on non-reference window sizes and lines +-- overlap. +local function label(parent, text, size, color, props) + -- integer sizes only: the measuring fonts are integer-sized, and a + -- fractional rendered size drifting a few percent wider than its measure + -- is exactly how button rows crept out of their cards on some displays + size = math.floor(size + 0.5) + local p = { + parent = parent, text = text, textSize = size, textColor = color, + textWrap = "word", autoScaleText = false, + -- id keyed by the text: the engine's Persistable behavior snapshots + -- every scalar prop (text included) per id and stomps it back onto the + -- recreated element, freezing any label whose text changes between + -- frames (typed search text stuck on its first letter). A new text is + -- a new id, so it always renders fresh. + id = "lbl:" .. tostring(text), + } + for k, v in pairs(props or {}) do p[k] = v end + return mk(p) +end + +-- kinds: primary (solid green), accent (green outline), danger (red outline), +-- dangerArmed (solid red), neutral (translucent white), disabled (inert) +local function button(imp, parent, key, text, opts) + opts = opts or {} + local hot = imp._hot[key] + local kind = opts.kind or "neutral" + local bgc, fgc, brc + if kind == "primary" then + bgc = hot and C("green") or C("greenDark") + fgc, brc = C("greenInk"), C("green", 0.9) + elseif kind == "accent" then + bgc = C("green", hot and 0.30 or 0.12) + fgc, brc = hot and C("white") or C("green"), C("green", 0.7) + elseif kind == "danger" then + bgc = C("danger", hot and 0.30 or 0.12) + fgc, brc = hot and C("white") or C("danger"), C("danger", 0.7) + elseif kind == "dangerArmed" then + bgc, fgc, brc = C("danger"), C("white"), C("danger") + elseif kind == "disabled" then + bgc = C("disabled", 0.22) + fgc, brc = C("disabled"), C("disabled", 0.35) + else + bgc = C("white", hot and 0.22 or 0.10) + fgc, brc = C("white"), C("white", hot and 0.4 or 0.2) + end + -- Explicit measured size always: the layout engine measures an auto-sized + -- button as zero-height while its parent card is auto-sizing, which let + -- bottom action rows spill past their card's edge. + local size = math.floor((opts.size or 14) + 0.5) + local pad = opts.pad or { horizontal = 12, vertical = 6 } + local padX = pad.horizontal or 12 + local padY = pad.vertical or 6 + local w = opts.w + if not w and not opts.flex then + w = math.ceil(textWidth(size, text)) + 2 * padX + 2 + end + local h = opts.h or (math.ceil(textHeight(size)) + 2 * padY + 2) + local p = { + parent = parent, + -- same Persistable-stomp guard as label(): a button whose caption + -- changes (Delete -> Sure?, Update ladders) must not keep frame one's + id = "btn:" .. key .. ":" .. tostring(text), + width = w, height = h, + flex = opts.flex, + backgroundColor = bgc, + border = 1, borderColor = brc, + cornerRadius = opts.r or 8, + text = text, textColor = fgc, textSize = size, + textAlign = "center-center", autoScaleText = false, + } + if kind ~= "disabled" and opts.action then + p.onEvent = handler(imp, key, opts.action, opts.keepArm) + elseif kind ~= "disabled" then + p.onEvent = handler(imp, key, nil) + end + return mk(p) +end + +local function card(parent, props) + local p = { + parent = parent, + width = "100%", + backgroundColor = C("card", 0.75), + border = 1, borderColor = C("border", 0.28), + cornerRadius = 14, + positioning = "flex", flexDirection = "vertical", + } + for k, v in pairs(props or {}) do p[k] = v end + return mk(p) +end + +local function pill(parent, text, colName, size) + size = math.floor((size or 12) + 0.5) + local h = math.ceil(textHeight(size)) + 8 + return mk({ + parent = parent, text = text, textColor = C(colName), + id = "pill:" .. tostring(text), + textSize = size, textAlign = "center-center", autoScaleText = false, + width = math.ceil(textWidth(size, text)) + 20, height = h, + backgroundColor = C(colName, 0.12), + border = 1, borderColor = C(colName, 0.55), + cornerRadius = h / 2, + }) +end + +local function progressBar(parent, frac, colName, h) + h = h or 10 + frac = clamp(frac or 0, 0, 1) + local track = mk({ + parent = parent, width = "100%", height = h, + backgroundColor = C("bg", 0.9), cornerRadius = h / 2, + }) + mk({ + parent = track, width = (frac * 100) .. "%", + -- id keyed by the fraction, or Persistable pins the bar at frame one + id = "prog:" .. math.floor(frac * 1000), + height = "100%", backgroundColor = C(colName), cornerRadius = h / 2, + }) + return track +end + +-- Flat toggle switch (read-only visual; the pressable area is the caller's). +-- The knob is flex-aligned rather than absolutely positioned: absolute +-- children resolve in screen space here, not against the parent. +local function toggleSwitch(parent, on, w, h, idKey) + w, h = w or 46, h or 24 + local track = mk({ + parent = parent, width = w, height = h, + -- state-keyed id: justifyContent is a persisted scalar, and a stale + -- snapshot would hold the knob on its frame-one side after a toggle + id = idKey and (idKey .. (on and ":on" or ":off")) or nil, + backgroundColor = on and C("greenDark") or C("disabled", 0.4), + border = 1, borderColor = on and C("green", 0.8) or C("disabled", 0.6), + cornerRadius = h / 2, + positioning = "flex", flexDirection = "horizontal", + alignItems = "center", + justifyContent = on and "flex-end" or "flex-start", + padding = 3, + }) + mk({ + parent = track, + width = h - 6, height = h - 6, + backgroundColor = C("white"), cornerRadius = (h - 6) / 2, + }) + return track +end + +-- a darkened copy of a palette color, for the embossed chips' bottom ledge +local function darken(name, f, a) + local c = PAL[name] + return rgba(c[1] * f, c[2] * f, c[3] * f, a or 1) +end + +-- Hand-rolled text field: the importer owns the string (textinput / +-- keypressed routing), this draws it. The field clips its content, keeps +-- the TAIL of the text visible while typing (the interesting end), and +-- shows a font-height caret on the importer's pulse clock. +local function dropFirstChar(t) + local i = 2 + while i <= #t do + local b = t:byte(i) + if b < 0x80 or b >= 0xC0 then break end + i = i + 1 + end + return t:sub(i) +end + +local function textField(imp, parent, key, rawText, placeholder, focused, action) + local size = 14 + local h = math.max(36, math.ceil(textHeight(size)) + 18) + local field = mk({ + parent = parent, width = "100%", height = h, + backgroundColor = C("bg", focused and 1 or 0.85), + border = focused and 2 or 1, + borderColor = focused and C("green", 0.85) + or C("border", imp._hot[key] and 0.7 or 0.4), + cornerRadius = 8, overflow = "hidden", + positioning = "flex", flexDirection = "horizontal", + alignItems = "center", gap = 2, + padding = { horizontal = 12 }, + onEvent = action and handler(imp, key, action) or nil, + }) + local avail = (field._innerW or 200) - 6 + local shown = rawText or "" + local f = mfont(size) + while #shown > 1 and f:getWidth(shown) > avail do + shown = dropFirstChar(shown) + end + if shown ~= "" then + label(field, shown, size, C("white"), { textWrap = false }) + elseif placeholder and not focused then + label(field, placeholder, size, C("disabled"), { textWrap = false }) + end + if focused and (imp.pulse * 2 % 1) < 0.5 then + mk({ parent = field, width = 2, + height = math.ceil(textHeight(size)) + 2, + backgroundColor = C("green"), cornerRadius = 1 }) + end + return field +end + +-- ------- status derivations shared with the old panels + +local function modStatusChip(status) + if status == "ok" then return Strings("Ready"), "green" end + if status == "conflict" then return Strings("Conflict"), "danger" end + return Strings("Incompatible"), "gold" +end + +local function findActionFor(entry, installedVersion) + local ModIndex = require("src.mods.ModIndex") + if not ModIndex.canInstall(entry) then + return nil, Strings("Not installable from this index") + end + if not installedVersion then return Strings("Install"), nil end + local listed = ModIndex.displayVersion(entry) + local ModUpdate = require("src.mods.ModUpdate") + if type(installedVersion) == "string" + and ModUpdate.isNewer(installedVersion, listed) then + return Strings("Update"), "Installed v" .. installedVersion + end + return Strings("Reinstall"), "Installed v" .. tostring(installedVersion) +end + +local DELETE_LABEL = function(armed) + return armed and Strings("Sure?") or Strings("Delete") +end + +local function deleteArmed(imp, kind, id, version) + local a = imp._confirmDelete + return a ~= nil and a.kind == kind and a.id == id and a.version == version +end + +-- ------- header: strip, logo row (with the settings gear), tab bar + +local function buildHeader(imp, root, m) + -- tricolor strip + local strip = mk({ + parent = root, width = "100%", height = math.max(4, 5 * m.s), + positioning = "flex", flexDirection = "horizontal", + }) + for _, name in ipairs({ "red", "blue", "gold" }) do + mk({ parent = strip, flex = 1, height = "100%", + backgroundColor = C(name) }) + end + + -- logo row: spacer / centered logo / settings gear, so the logo stays + -- centered while the gear holds the app's top-right corner + local gearSize = math.max(34, 40 * m.s) + local row = mk({ + parent = root, width = "100%", height = m.logoH + 12 * m.s, + positioning = "flex", flexDirection = "horizontal", + alignItems = "center", gap = 8 * m.s, + padding = { horizontal = m.pad }, + }) + mk({ parent = row, width = gearSize, height = 1 }) + local mid = mk({ parent = row, flex = 1, + positioning = "flex", justifyContent = "center", alignItems = "center" }) + mk({ + parent = mid, image = imp.logo, objectFit = "contain", + width = math.min(320 * m.s, m.w * 0.6), height = m.logoH, + }) + imp._gearIcon = imp._gearIcon + or love.graphics.newImage("assets/launcher/gear.png") + mk({ + parent = row, + width = gearSize, height = gearSize, + backgroundColor = C("white", imp._hot.gear and 0.20 or 0.07), + border = 1, borderColor = C("border", imp._hot.gear and 0.85 or 0.4), + cornerRadius = 10, + image = imp._gearIcon, objectFit = "contain", + imageTint = imp._hot.gear and C("white") or C("detail"), + padding = math.floor(gearSize * 0.18), + onEvent = handler(imp, "gear", function() + imp:_openSettings() + end), + }) + + -- tab bar + local bar = mk({ + parent = root, width = "100%", + positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", + alignItems = "center", gap = 8 * m.s, + padding = { horizontal = m.pad, vertical = 8 * m.s }, + }) + imp._modsIcon = imp._modsIcon + or love.graphics.newImage("assets/launcher/mods.png") + imp._findIcon = imp._findIcon + or love.graphics.newImage("assets/launcher/find.png") + local tabs = { + { id = "red", letter = "R", col = "red", ink = "white", labelText = Strings("RED") }, + { id = "blue", letter = "B", col = "blue", ink = "white", labelText = Strings("BLUE") }, + { id = "yellow", letter = "Y", col = "gold", ink = "bg", labelText = Strings("YELLOW") }, + { id = "mods", icon = imp._modsIcon, col = "chipModTop", ink = "white", labelText = Strings("MODS") }, + { id = "find", icon = imp._findIcon, col = "chipModTop", ink = "white", labelText = Strings("FIND MODS") }, + } + for _, t in ipairs(tabs) do + if t.id == "mods" then + mk({ parent = bar, width = 1, height = m.chip * 0.8, + backgroundColor = C("border", 0.3) }) + end + local active = imp.tab == t.id + local key = "tab-" .. t.id + local labelSize = 13 * m.s + 4 + local hot = imp._hot[key] + -- embossed chip: a darker base ledge under the face gives the tab a + -- raised look; hover lifts the face brightness and rims it white + local ledge = math.max(2, math.floor(3 * m.s)) + local baseEl = mk({ + parent = bar, width = m.chip, height = m.chip + ledge, + backgroundColor = darken(t.col, 0.35, active and 1 or 0.8), + cornerRadius = 10, + onEvent = handler(imp, key, function() + imp:_switchTab(t.id) + end), + }) + local face = { + parent = baseEl, width = "100%", height = m.chip, + backgroundColor = C(t.col, active and 1 or (hot and 0.75 or 0.42)), + border = (active or hot) and 1 or false, + borderColor = C("white", active and 0.6 or 0.35), + cornerRadius = 10, + } + if t.icon then + face.image = t.icon + face.objectFit = "contain" + face.padding = math.floor(m.chip * 0.2) + face.imageTint = C("white", active and 1 or 0.85) + else + face.text = t.letter + -- the gold chip's dark ink only reads on the full-strength active + -- fill; dimmed inactive chips all take light ink + face.textColor = (active and t.ink == "bg") and C("bg") or C("white") + face.textSize = math.floor(m.chip * 0.45) + face.textAlign = "center-center" + face.autoScaleText = false + end + mk(face) + if active then + -- explicit width: a percentage inside this auto-sized wrap would not + -- resolve (LayoutEngine LAY_004), so the underline takes the label's + -- measured pixel width + local lw = math.ceil(textWidth(labelSize, t.labelText)) + 2 + local wrap = mk({ parent = bar, width = lw, + positioning = "flex", flexDirection = "vertical", gap = 3 * m.s }) + label(wrap, t.labelText, labelSize, C("white"), { textWrap = false }) + mk({ parent = wrap, width = lw, height = 3, + backgroundColor = C(t.col) }) + end + end + -- "N of 3 ready" right-aligned filler + local ready = 0 + for _, v in ipairs(GameVersion.ORDER) do + if imp.ready[v] then ready = ready + 1 end + end + mk({ parent = bar, flex = 1 }) + label(bar, Strings("%d of 3 ready", ready), 12 * m.s + 2, C("gray"), + { textWrap = false }) + mk({ parent = root, width = "100%", height = 1, + backgroundColor = C("border", 0.22) }) +end + +-- ------- game panel + +local function buildRomCard(imp, parent, m, version, info, ready, locked) + local dropHint = imp.android and Strings("Copy the .gb/.gbc via USB.") + or Strings("Or drop the .gb/.gbc file here.") + local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress + if locked then + romState, romDetail = Strings("Not supported yet"), + Strings("Support for this game is on the way.") + romBtnLabel, romBtnEnabled = Strings("Import unavailable"), false + else + local importing = imp.importing == version + local erroring = imp.workState == "error" and imp.errorVersion == version + local notice = imp.notice and imp.notice.version == version and imp.notice + if importing and (imp.workState == "working" or imp.workState == "complete") then + romState = imp.status or Strings("Importing") + romDetail = imp.detail or "" + romProgress = imp.progress or 0 + elseif ready then + romState = imp.romName[version] or Strings("ROM imported") + romDetail = Strings("Verified.") + romBtnLabel, romBtnEnabled = Strings("Re-import ROM"), true + elseif erroring then + romState = Strings("Import failed") + romDetail = imp.detail or Strings("That ROM could not be imported.") + romBtnLabel, romBtnEnabled = Strings("Import ROM"), true + elseif notice then + romState = Strings("No ROM imported") + romDetail = ((notice.status or "") .. " " .. (notice.detail or "")) + :gsub("^%s+", ""):gsub("%s+$", "") + romBtnLabel, romBtnEnabled = Strings("Import ROM"), true + elseif imp.returning[version] then + romState = Strings("Update required") + romDetail = Strings("This build needs a few more things from your ") + .. info.label .. Strings(" ROM. Re-import to continue.") + romBtnLabel, romBtnEnabled = Strings("Re-import ROM"), true + else + romState = Strings("No ROM imported") + romDetail = Strings("The ROM is verified before any files are created. ") + .. dropHint + romBtnLabel, romBtnEnabled = Strings("Import ROM"), true + end + end + + local accent = version == "yellow" and "gold" or version + local c = card(parent, { padding = m.cardPad, gap = 8 * m.s }) + label(c, "ROM", 12 * m.s + 1, C("gray")) + label(c, romState, 15 * m.s + 2, C("white")) + label(c, romDetail, 12 * m.s + 2, C("detail")) + if romProgress ~= nil then + progressBar(c, romProgress, accent, math.max(8, 10 * m.s)) + else + button(imp, c, "rom-" .. version, romBtnLabel, { + w = "100%", h = m.btnH, size = 14 * m.s, + kind = romBtnEnabled and "neutral" or "disabled", + action = romBtnEnabled and function() + if imp.ready[version] then imp:reimport(version) + else imp:choose(version) end + end or nil, + }) + end +end + +local function buildSaveFilesCard(imp, parent, m, version, ready, locked) + local sfImportEnabled, sfExportEnabled = false, false + if not locked then + imp:_ensureSlots(version) + sfImportEnabled = ready and true or false + local activeId = imp.activeSlot[version] + for _, sl in ipairs(imp.slots[version] or {}) do + if sl.id == activeId and sl.exists then sfExportEnabled = true break end + end + end + local sfNotice = (not locked) and imp.saveNotice[version] or nil + local hintText, hintCol + if sfNotice then + hintText, hintCol = sfNotice.text, (sfNotice.ok and "green" or "danger") + elseif locked then + hintText, hintCol = Strings("Not available yet."), "warn" + elseif imp.android then + hintText = Strings("Import or export a .sav with the system file picker.") + hintCol = "warn" + else + hintText = Strings("Import a .sav to a new slot, or export the active slot.") + hintCol = "warn" + end + + local c = card(parent, { padding = m.cardPad, gap = 8 * m.s }) + label(c, "SAVE FILES", 12 * m.s + 1, C("gray")) + local row = mk({ parent = c, width = "100%", + positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s }) + -- explicit halves rather than flex growth, which mis-distributed inside + -- an auto-height card + local halfW = math.floor((m.colW - 32 - 10 * m.s) / 2) + button(imp, row, "sav-import-" .. version, Strings("Import save"), { + w = halfW, h = m.btnH, size = 13 * m.s + 1, + kind = sfImportEnabled and "neutral" or "disabled", + action = sfImportEnabled and function() + imp:chooseSaveImport(version) + end or nil, + }) + button(imp, row, "sav-export-" .. version, Strings("Export save"), { + w = halfW, h = m.btnH, size = 13 * m.s + 1, + kind = sfExportEnabled and "neutral" or "disabled", + action = sfExportEnabled and function() + imp:exportSave(version) + end or nil, + }) + label(c, hintText, 12 * m.s + 1, C(hintCol)) + if sfNotice and sfNotice.dir then + local dir = sfNotice.dir + label(c, Strings("Open folder"), 12 * m.s + 1, + C("link", imp._hot["sav-folder-" .. version] and 1 or 0.85), { + onEvent = handler(imp, "sav-folder-" .. version, function() + love.system.openURL(imp:fileUrl(dir)) + end), + }) + end +end + +local function buildSlotCard(imp, parent, m, version) + imp:_ensureSlots(version) + local slots = imp.slots[version] or {} + local active = imp.activeSlot[version] + local n = #slots + + local c = card(parent, { padding = m.cardPad, gap = 10 * m.s }) + local head = mk({ parent = c, width = "100%", + positioning = "flex", flexDirection = "horizontal", + justifyContent = "space-between", alignItems = "center" }) + label(head, "SAVE SLOT", 12 * m.s + 1, C("gray"), { textWrap = false }) + label(head, n == 1 and Strings("1 slot") or Strings("%d slots", n), + 12 * m.s + 1, C("gray"), { textWrap = false }) + + if n == 0 then + local box = mk({ + parent = c, width = "100%", height = 90 * m.s, + border = 1, borderColor = C("border", 0.45), cornerRadius = 12, + positioning = "flex", justifyContent = "center", alignItems = "center", + padding = { horizontal = 12 }, + }) + label(box, Strings("No saves yet - start a new game or import one."), + 12 * m.s + 2, C("warn"), { textAlign = "center" }) + end + + -- Taller stacked rows: name + LOADED line, meta line, then the action + -- buttons on their own full-width line, so no control can ever clip + -- against the card's right edge at any scale. Every line height is + -- measured, and the row height is their explicit sum: the engine's + -- auto-height came up short on some displays and let the button row fall + -- out of the card. + local chipSize = math.floor(11 * m.s + 1.5) + local nameSize = math.floor(14 * m.s + 2.5) + local metaSize = math.floor(11 * m.s + 2.5) + local pillSize = math.floor(10 * m.s + 1.5) + local btnH = math.ceil(textHeight(chipSize)) + 14 + local headH = math.max(math.ceil(textHeight(nameSize)), + math.ceil(textHeight(pillSize)) + 8) + local metaH = math.ceil(textHeight(metaSize)) + local rowH = 10 + headH + 5 + metaH + 8 + btnH + 10 + for _, slot in ipairs(slots) do + local selected = slot.id == active + local rowKey = "slot-" .. version .. "-" .. slot.id + local row = mk({ + parent = c, width = "100%", height = rowH, + backgroundColor = C("rowBg", imp._hot[rowKey] and 0.85 or 0.6), + border = 1, + borderColor = selected and C("green", 0.9) or C("border", 0.25), + cornerRadius = 12, + positioning = "flex", flexDirection = "vertical", gap = 5, + padding = { horizontal = 12, vertical = 10 }, + onEvent = handler(imp, rowKey, function() + imp:_selectSlot(version, slot.id) + end), + }) + local rowInner = row._innerW + local headRow = mk({ parent = row, width = rowInner, height = headH, + positioning = "flex", flexDirection = "horizontal", + justifyContent = "space-between", alignItems = "center" }) + local name = slot.label or slot.name or Strings("NEW GAME") + local pillW = selected + and (math.ceil(textWidth(pillSize, Strings("LOADED"))) + 20) or 0 + label(headRow, name, nameSize, C("white"), + { width = rowInner - pillW - 8, + textWrap = false, textOverflow = "ellipsis" }) + if selected then pill(headRow, Strings("LOADED"), "green", pillSize) end + local metaTxt + if slot.exists and slot.meta then + metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, + slot.meta.timeText or "0:00", slot.meta.dexCount or 0) + else + metaTxt = Strings("empty slot") + end + label(row, metaTxt, metaSize, C("warn"), + { width = rowInner, textWrap = false, textOverflow = "ellipsis" }) + + local btnRow = mk({ parent = row, width = rowInner, height = btnH, + positioning = "flex", flexDirection = "horizontal", + justifyContent = "flex-end", gap = 6 }) + if not imp.android then + button(imp, btnRow, rowKey .. "-rename", Strings("Rename"), { + size = chipSize, kind = "neutral", + action = function() imp:_beginRename(version, slot.id) end, + }) + end + if imp.onEditSave and slot.exists then + button(imp, btnRow, rowKey .. "-edit", Strings("Edit"), { + size = chipSize, kind = "accent", + action = function() imp.onEditSave(version, slot.id) end, + }) + end + local armed = deleteArmed(imp, "slot", slot.id, version) + -- width pinned to the unarmed label so arming to "Sure?" never reflows + -- the row under the pointer (#433) + button(imp, btnRow, rowKey .. "-del", DELETE_LABEL(armed), { + w = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26, + size = chipSize, kind = armed and "dangerArmed" or "danger", + keepArm = true, + action = function() + imp:pressDelete("slot", slot.id, version, function() + imp:_deleteSlot(version, slot.id) + end) + end, + }) + end + + button(imp, c, "slot-new-" .. version, Strings("+ New save slot"), { + w = "100%", h = m.btnH, size = 13 * m.s + 1, kind = "neutral", + action = function() imp:_newSlot(version) end, + }) +end + +local function buildGamePanel(imp, parent, m, version) + imp.panelVersion = version + local info = GameVersion.info(version) + local locked = info == nil + local gameName = info and (info.launcherName or info.displayName) + or tostring(version) + local ready = (not locked) and imp.ready[version] or false + + -- header: name + status pill + local head = mk({ parent = parent, width = "100%", + positioning = "flex", flexDirection = "horizontal", + alignItems = "center", gap = 12 * m.s }) + label(head, gameName, 22 * m.s + 4, C("white"), { textWrap = false }) + if ready then pill(head, Strings("GOOD TO GO"), "green", 11 * m.s + 1) + elseif locked then pill(head, Strings("COMING SOON"), "disabled", 11 * m.s + 1) + else pill(head, Strings("ROM REQUIRED"), "gold", 11 * m.s + 1) end + + -- Two columns get explicit pixel widths (percentage children inside a + -- flex-grown column do not resolve, LayoutEngine LAY_004). Single-column + -- mode adds the cards straight to the page instead of nesting columns: + -- the engine under-measures a vertical column-of-columns' auto height, + -- which pushed the footer up over the save-slot card on phone shapes. + local left, right + if m.twoCol then + local grid = mk({ parent = parent, width = "100%", + positioning = "flex", flexDirection = "horizontal", + gap = m.colGap, alignItems = "flex-start" }) + left = mk({ parent = grid, width = m.colW, + positioning = "flex", flexDirection = "vertical", gap = 12 * m.s }) + right = mk({ parent = grid, width = m.colW, + positioning = "flex", flexDirection = "vertical" }) + else + left, right = parent, parent + end + buildRomCard(imp, left, m, version, info, ready, locked) + buildSaveFilesCard(imp, left, m, version, ready, locked) + if imp.onEditTouchControls then + button(imp, left, "touch-controls", Strings("Touch Controls"), { + w = "100%", h = m.btnH, size = 13 * m.s + 1, kind = "neutral", + action = function() imp.onEditTouchControls() end, + }) + end + button(imp, left, "play-" .. version, + ready and (Strings("Play ") .. gameName) + or (locked and Strings("Coming soon") or Strings("Import a ROM to play")), + { + w = "100%", h = math.max(48, 52 * m.s), size = 18 * m.s + 2, + kind = ready and "primary" or "disabled", + action = ready and function() imp:play(version) end or nil, + }) + + if not locked then + buildSlotCard(imp, right, m, version) + end +end + +-- ------- mods panel + +local function buildModsPanel(imp, parent, m) + imp:_ensureMods() + local mods = imp.mods or {} + local enabledCount = 0 + for _, mod in ipairs(mods) do + if mod.enabled then enabledCount = enabledCount + 1 end + end + + local head = mk({ parent = parent, width = "100%", + positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", + alignItems = "center", gap = 10 * m.s }) + label(head, Strings("Mods"), 22 * m.s + 4, C("white"), { textWrap = false }) + label(head, Strings("%d of %d enabled", enabledCount, #mods), + 12 * m.s + 2, C("warn"), { textWrap = false }) + mk({ parent = head, flex = 1 }) + if #mods > 0 then + button(imp, head, "mods-enable-all", Strings("Enable all"), { + size = 11 * m.s + 1, kind = "neutral", + action = function() imp:_setAllMods(true) end, + }) + button(imp, head, "mods-disable-all", Strings("Disable all"), { + size = 11 * m.s + 1, kind = "neutral", + action = function() imp:_setAllMods(false) end, + }) + end + button(imp, head, "mods-import", Strings("Import mod .zip"), { + h = m.btnH, size = 13 * m.s + 1, kind = "neutral", + action = function() imp:chooseMod() end, + }) + + if imp.modNotice then + label(parent, imp.modNotice.text, 12 * m.s + 2, + C(imp.modNotice.ok and "green" or "danger")) + else + label(parent, imp.android and Strings("Or copy a mod .zip via USB.") + or Strings("Or drop a mod .zip onto the window."), 12 * m.s + 2, C("warn")) + end + + if #mods == 0 then + local box = mk({ + parent = parent, width = "100%", height = 110 * m.s, + backgroundColor = C("card", 0.4), + border = 1, borderColor = C("border", 0.3), cornerRadius = 14, + positioning = "flex", justifyContent = "center", alignItems = "center", + padding = { horizontal = 16 }, + }) + label(box, imp.android + and Strings("No mods installed - tap Import mod .zip to add one.") + or Strings("No mods installed - drop a mod .zip here to add one."), + math.floor(12 * m.s + 2.5), C("detail"), { textAlign = "center" }) + return + end + + -- Explicit column widths AND heights: a flex-grown container collapses + -- its children's layout in this engine, and card auto-height came up + -- short on some displays, dropping the bottom action row out of the card. + -- Everything is measured with the same integer-sized fonts the labels + -- render with, and the card gets the exact sum. + local innerW = m.contentW - 32 + local clusterW = math.max(96, math.floor(110 * m.s)) + local bodyW = innerW - clusterW - 10 + local nameSize = math.floor(15 * m.s + 2.5) + local smallSize = math.floor(12 * m.s + 1.5) + local badgeSize = math.floor(10 * m.s + 1.5) + local chipSize = math.floor(11 * m.s + 1.5) + local btnH = math.ceil(textHeight(chipSize)) + 14 + local badgeH = math.ceil(textHeight(badgeSize)) + 6 + local toggleH = math.floor(24 * m.s + 2) + 8 + local pillH = math.ceil(textHeight(chipSize)) + 8 + local clusterH = pillH + 6 + toggleH + for _, mod in ipairs(mods) do + local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) + local checkLine, checkCol + if info and info.status == "available" then + checkLine = Strings("Checked for updates - v%s available", + tostring(info.latest)) + checkCol = "green" + elseif info and info.status == "current" then + checkLine, checkCol = Strings("Checked for updates - up to date"), "green" + elseif info and info.status == "error" then + checkLine, checkCol = Strings("Checked for updates - failed"), "danger" + elseif mod.github and mod.github ~= "" then + checkLine, checkCol = Strings("Not checked for updates yet"), "warn" + end + + -- measure the body: name (with the badge beside it only when it fits), + -- version, check line, wrapped description + local badgeW = math.ceil(textWidth(badgeSize, mod.badge)) + 14 + local nameH = math.ceil(textHeight(nameSize)) + local badgeBesideName = + math.ceil(textWidth(nameSize, mod.name)) + 8 + badgeW <= bodyW + local bodyH = badgeBesideName and math.max(nameH, badgeH) + or (nameH + 4 + badgeH) + bodyH = bodyH + 4 + math.ceil(textHeight(smallSize)) + if checkLine then + bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW) + end + if mod.description ~= "" then + bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW) + end + local rowH = math.max(bodyH, clusterH) + + -- how many lines the right-aligned action row needs + local btnRowW = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26 + local updLabel, updKind = Strings("Check for updates"), "neutral" + if info and info.status == "available" then + updLabel, updKind = Strings("Update"), "accent" + elseif info and info.status == "current" then + updLabel = Strings("Check again") + end + if mod.github and mod.github ~= "" then + btnRowW = btnRowW + math.ceil(textWidth(chipSize, updLabel)) + 26 + 6 + + math.ceil(textWidth(chipSize, Strings("Versions"))) + 26 + 6 + end + local btnLines = math.max(1, math.ceil(btnRowW / innerW)) + local cardH = 28 + rowH + 8 + btnLines * btnH + (btnLines - 1) * 6 + + local c = card(parent, { padding = m.cardPad, gap = 8, height = cardH }) + local row = mk({ parent = c, width = "100%", height = rowH, + positioning = "flex", flexDirection = "horizontal", + gap = 10, alignItems = "flex-start" }) + local body = mk({ parent = row, width = bodyW, height = rowH, + positioning = "flex", flexDirection = "vertical", gap = 4 }) + local function badge(parent2) + mk({ + parent = parent2, text = mod.badge, autoScaleText = false, + textColor = mod.experimental and C("gold") or C("warn"), + textSize = badgeSize, textAlign = "center-center", + width = badgeW, height = badgeH, + border = 1, borderColor = C("border", 0.5), cornerRadius = 5, + }) + end + if badgeBesideName then + local nameRow = mk({ parent = body, width = "100%", + height = math.max(nameH, badgeH), + positioning = "flex", flexDirection = "horizontal", + alignItems = "center", gap = 8 }) + label(nameRow, mod.name, nameSize, C("white"), { textWrap = false }) + badge(nameRow) + else + label(body, mod.name, nameSize, C("white"), + { width = "100%", textWrap = false, textOverflow = "ellipsis" }) + badge(body) + end + label(body, "v" .. tostring(mod.version or "?"), smallSize, C("detail")) + if checkLine then + label(body, checkLine, smallSize, C(checkCol), { width = "100%" }) + end + if mod.description ~= "" then + label(body, mod.description, smallSize, C("detail"), { width = "100%" }) + end + + local cluster = mk({ parent = row, width = clusterW, height = clusterH, + positioning = "flex", flexDirection = "vertical", + alignItems = "flex-end", gap = 6 }) + local chipText, chipCol = modStatusChip(mod.status) + pill(cluster, chipText, chipCol, chipSize) + local togKey = "mod-toggle-" .. mod.id + local togWrap = mk({ parent = cluster, + width = math.floor(46 * m.s + 4) + 8, height = toggleH, + padding = 4, + onEvent = handler(imp, togKey, function() imp:_toggleMod(mod.id) end), + }) + toggleSwitch(togWrap, mod.enabled, math.floor(46 * m.s + 4), + math.floor(24 * m.s + 2), "tog:" .. mod.id) + + local btnRow = mk({ parent = c, width = "100%", + height = btnLines * btnH + (btnLines - 1) * 6, + positioning = "flex", flexDirection = "horizontal", + justifyContent = "flex-end", flexWrap = "wrap", gap = 6 }) + if mod.github and mod.github ~= "" then + button(imp, btnRow, "mod-upd-" .. mod.id, updLabel, { + size = chipSize, kind = updKind, + action = function() imp:_modGithubAction(mod.id, "update") end, + }) + button(imp, btnRow, "mod-ver-" .. mod.id, Strings("Versions"), { + size = chipSize, kind = "neutral", + action = function() imp:_modGithubAction(mod.id, "versions") end, + }) + end + local armed = deleteArmed(imp, "mod", mod.id, nil) + button(imp, btnRow, "mod-del-" .. mod.id, DELETE_LABEL(armed), { + w = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26, + size = chipSize, kind = armed and "dangerArmed" or "danger", + keepArm = true, + action = function() + imp:pressDelete("mod", mod.id, nil, function() + imp:_deleteMod(mod.id) + end) + end, + }) + end +end + +-- ------- find mods panel + +local function buildFindPanel(imp, parent, m) + imp._findThumbFetched = false + imp:_ensureFind() + imp:_ensureMods() + local ModIndex = require("src.mods.ModIndex") + local sources = imp.findSources or {} + local rows = imp:_findRows() + local total = #((imp.findIndex and imp.findIndex.mods) or {}) + + local head = mk({ parent = parent, width = "100%", + positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", + alignItems = "center", gap = 10 * m.s }) + label(head, Strings("Find Mods"), 22 * m.s + 4, C("white"), { textWrap = false }) + if #sources > 0 then + label(head, (#rows == total) and Strings("%d mods listed", total) + or Strings("%d of %d mods", #rows, total), 12 * m.s + 2, C("warn"), + { textWrap = false }) + end + mk({ parent = head, flex = 1 }) + if #sources > 0 then + button(imp, head, "find-refresh", Strings("Refresh"), { + h = m.btnH, size = 13 * m.s + 1, kind = "neutral", + action = function() + imp._findSearchFocus = false + imp:_disarmTextInput() + imp:_refreshFind(true) + end, + }) + end + button(imp, head, "find-add", + (#sources == 0) and Strings("Add an index") or Strings("Add index"), { + h = m.btnH, size = 13 * m.s + 1, kind = "neutral", + action = function() imp:_promptAddIndex() end, + }) + + if imp.findNotice then + label(parent, imp.findNotice.text, 12 * m.s + 2, + C(imp.findNotice.ok and "green" or "danger")) + else + label(parent, Strings( + "Mods here are listed, not reviewed - read the source and trust the author."), + 12 * m.s + 2, C("warn")) + end + + if #sources == 0 then + local box = mk({ + parent = parent, width = "100%", height = 150 * m.s, + border = 1, borderColor = C("border", 0.45), cornerRadius = 14, + positioning = "flex", flexDirection = "vertical", + justifyContent = "center", alignItems = "center", gap = 6 * m.s, + padding = { horizontal = 20 }, + }) + label(box, Strings("No mod index added"), 15 * m.s + 2, C("white"), + { textAlign = "center", width = "100%" }) + label(box, Strings( + "Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."), + 12 * m.s + 1, C("warn"), { textAlign = "center", width = "100%" }) + return + end + + for _, source in ipairs(sources) do + local srow = mk({ parent = parent, width = "100%", + positioning = "flex", flexDirection = "horizontal", + alignItems = "center", gap = 8 * m.s }) + label(srow, source.label or source.feed, 12 * m.s + 1, C("detail"), + { width = m.contentW + - (math.ceil(textWidth(11 * m.s + 1, Strings("Remove"))) + 26) + - 8 * m.s, + textWrap = false, textOverflow = "ellipsis" }) + button(imp, srow, "find-src-rm-" .. tostring(source.feed), Strings("Remove"), { + size = 11 * m.s + 1, kind = "danger", + action = function() imp:_removeIndex(source.feed) end, + }) + end + + -- search field (hand-rolled text state, same routing as the rename modal) + textField(imp, parent, "find-search", + imp.findQuery or "", Strings("Search mods"), + imp._findSearchFocus == true, + function() + imp._findSearchFocus = true + imp:_armTextInput() + end) + + local cats = (imp.findIndex and imp.findIndex.categories) or {} + if #cats > 0 then + local catRow = mk({ parent = parent, width = "100%", + positioning = "flex", flexDirection = "horizontal", + flexWrap = "wrap", gap = 6 * m.s }) + local function catChip(name, id, active) + local key = "find-cat-" .. id + mk({ + parent = catRow, text = name, + textColor = active and C("green") + or (imp._hot[key] and C("white") or C("detail")), + textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false, + backgroundColor = active and C("green", 0.18) or C("border", 0.10), + border = 1, + borderColor = active and C("green", 0.6) or C("border", 0.35), + cornerRadius = 999, + padding = { horizontal = 10, vertical = 4 }, + onEvent = handler(imp, key, function() + imp.findCategory = (id ~= "" and imp.findCategory ~= id) and id or nil + end), + }) + end + catChip(Strings("All"), "", imp.findCategory == nil) + for _, cat in ipairs(cats) do + catChip(cat, cat, imp.findCategory == cat) + end + end + + if #rows == 0 then + local box = mk({ + parent = parent, width = "100%", height = 130 * m.s, + backgroundColor = C("card", 0.4), + border = 1, borderColor = C("border", 0.3), cornerRadius = 14, + positioning = "flex", flexDirection = "vertical", + justifyContent = "center", alignItems = "center", gap = 8, + padding = { horizontal = 20 }, + }) + mk({ parent = box, width = math.floor(30 * m.s), + height = math.floor(30 * m.s), + image = imp._findIcon, objectFit = "contain", + imageTint = C("gray", 0.6) }) + label(box, (total == 0) and Strings("This index lists no mods yet.") + or Strings("No mods match that search."), + math.floor(13 * m.s + 1.5), C("detail"), { textAlign = "center" }) + if total > 0 then + label(box, + Strings("Try a different search, or clear the category filter."), + math.floor(11 * m.s + 1.5), C("warn"), { textAlign = "center" }) + end + return + end + + local installed = imp:_findInstalledMap() + local thumbW = 64 * m.s + -- Explicit measured widths AND heights, same reasoning as the mods card: + -- the engine's card auto-height dropped the Details/Source/Install row + -- past the card's bottom edge on some displays. + local innerW = m.contentW - 32 + local bodyW = innerW - thumbW - 10 + local titleSize = math.floor(15 * m.s + 2.5) + local smallSize = math.floor(12 * m.s + 1.5) + local chipSize = math.floor(11 * m.s + 1.5) + local btnH = math.ceil(textHeight(chipSize)) + 14 + for _, entry in ipairs(rows) do + local action, note = findActionFor(entry, installed[entry.id]) + + local bodyH = math.ceil(textHeight(titleSize)) + + 4 + math.ceil(textHeight(smallSize)) + if note then bodyH = bodyH + 4 + wrapHeight(smallSize, note, bodyW) end + if entry.summary and entry.summary ~= "" then + bodyH = bodyH + 4 + wrapHeight(smallSize, entry.summary, bodyW) + end + local rowH = math.max(thumbW, bodyH) + local btnRowW = math.ceil(textWidth(chipSize, Strings("Details"))) + 26 + if entry.repo then + btnRowW = btnRowW + 6 + math.ceil(textWidth(chipSize, Strings("Source"))) + 26 + end + if action then + btnRowW = btnRowW + 6 + math.ceil(textWidth(chipSize, action)) + 26 + else + btnRowW = btnRowW + 6 + + math.ceil(textWidth(chipSize, Strings("Unavailable"))) + 20 + end + local btnLines = math.max(1, math.ceil(btnRowW / innerW)) + local cardH = 28 + rowH + 8 + btnLines * btnH + (btnLines - 1) * 6 + + local c = card(parent, { padding = m.cardPad, gap = 8, height = cardH }) + local row = mk({ parent = c, width = "100%", height = rowH, + positioning = "flex", flexDirection = "horizontal", + gap = 10, alignItems = "flex-start" }) + local image = imp:_findThumb(entry) + if image then + mk({ parent = row, image = image, objectFit = "contain", + width = thumbW, height = thumbW, cornerRadius = 8 }) + else + mk({ parent = row, width = thumbW, height = thumbW, + backgroundColor = C("border", 0.18), cornerRadius = 8, + text = "MOD", textColor = C("disabled"), + textSize = math.floor(10 * m.s + 1.5), textAlign = "center-center", + autoScaleText = false }) + end + local body = mk({ parent = row, width = bodyW, height = rowH, + positioning = "flex", flexDirection = "vertical", gap = 4 }) + label(body, entry.title or entry.id, titleSize, C("white"), + { width = "100%", textWrap = false, textOverflow = "ellipsis" }) + local meta = "v" .. tostring(ModIndex.displayVersion(entry)) + if entry.author then meta = meta .. " - " .. entry.author end + if entry.categories and entry.categories[1] then + meta = meta .. " - " .. entry.categories[1] + end + label(body, meta, smallSize, C("detail"), + { width = "100%", textWrap = false, textOverflow = "ellipsis" }) + if note then label(body, note, smallSize, C("green"), { width = "100%" }) end + if entry.summary and entry.summary ~= "" then + label(body, entry.summary, smallSize, C("detail"), { width = "100%" }) + end + + local btnRow = mk({ parent = c, width = "100%", + height = btnLines * btnH + (btnLines - 1) * 6, + positioning = "flex", flexDirection = "horizontal", + justifyContent = "flex-end", flexWrap = "wrap", gap = 6 }) + if not action then + pill(btnRow, Strings("Unavailable"), "gold", chipSize) + end + button(imp, btnRow, "find-det-" .. entry.id, Strings("Details"), { + size = chipSize, kind = "neutral", + action = function() imp:_findShowDetails(entry) end, + }) + if entry.repo then + button(imp, btnRow, "find-repo-" .. entry.id, Strings("Source"), { + size = chipSize, kind = "neutral", + action = function() love.system.openURL(entry.repo) end, + }) + end + if action then + button(imp, btnRow, "find-inst-" .. entry.id, action, { + size = chipSize, kind = "accent", + action = function() imp:_findConfirmInstall(entry) end, + }) + end + end +end + +-- ------- updater banner + footer + +local function buildBanner(imp, parent, m) + if not imp.Check then return end + local ok, st = pcall(imp.Check.state) + st = (ok and type(st) == "table") and st or nil + local status = st and st.status + if status ~= "available" and status ~= "downloading" + and status ~= "ready" and status ~= "needs_full" then + return + end + local c = card(parent, { + padding = { horizontal = 16, vertical = 12 }, + borderColor = C("gold", 0.5), gap = 8 * m.s, + }) + local row = mk({ parent = c, width = "100%", + positioning = "flex", flexDirection = "horizontal", + alignItems = "center", gap = 10 * m.s }) + if status == "downloading" then + label(row, Strings("Downloading update"), 13 * m.s + 1, C("detail"), + { flex = 1 }) + progressBar(c, st.progress, "gold", math.max(8, 10 * m.s)) + else + local msg, btnLabel, action + if status == "available" then + msg = st.latest and (Strings("Update v") .. st.latest .. Strings(" available")) + or Strings("An update is available") + btnLabel = Strings("Update") + action = function() pcall(imp.Check.download) end + elseif status == "needs_full" then + msg = Strings("A new version needs a fresh download") + btnLabel = Strings("Open releases") + action = function() love.system.openURL(imp.Check.releaseUrl()) end + else + msg = Strings("Update downloaded") + btnLabel = Strings("Restart to update") + action = function() require("src.core.HostShell").restart() end + end + label(row, msg, 13 * m.s + 1, C("white"), { flex = 1 }) + button(imp, row, "updater", btnLabel, { + h = m.btnH, size = 13 * m.s, kind = "primary", action = action, + }) + end +end + +local TRUST_WARNING = "if you did not get this from bryanthaboi's github " + .. "or a link from the discord that bryanthaboi himself posted, just know " + .. "it might have been tampered with. go to the discord to verify " + .. COMMUNITY_URL .. " (or click the logo above)" + +local function buildFooter(imp, parent, m) + mk({ parent = parent, width = "100%", height = 1, + backgroundColor = C("border", 0.18) }) + -- The BCG mark is dark ink; invert it to white for the dark panel. + imp.invertShader = imp.invertShader or love.graphics.newShader([[ + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 p = Texel(tex, tc); + return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a); + } + ]]) + local bw, bh = imp.bcg:getDimensions() + local scale = math.min((180 * m.s) / bw, (44 * m.s) / bh) + mk({ + parent = parent, width = bw * scale, height = bh * scale, + alignSelf = "center", + customDraw = function(el) + love.graphics.setShader(imp.invertShader) + love.graphics.setColor(1, 1, 1, imp._hot.bcg and 1 or 0.85) + love.graphics.draw(imp.bcg, el.x, el.y, 0, + el.width / bw, el.height / bh) + love.graphics.setShader() + end, + onEvent = handler(imp, "bcg", function() + love.system.openURL(COMMUNITY_URL) + end), + }) + label(parent, TRUST_WARNING, 10 * m.s + 2, C("warn"), + { width = "100%", textAlign = "center" }) + -- the link gets a full-width, center-aligned row of its own: alignSelf on + -- an auto-width label was not honored and left it hugging the margin + local linkRow = mk({ parent = parent, width = "100%", + positioning = "flex", justifyContent = "center", + padding = { bottom = 18 } }) + label(linkRow, COMMUNITY_URL, 11 * m.s + 2, + C("link", imp._hot.bois and 1 or 0.85), { + textWrap = false, + onEvent = handler(imp, "bois", function() + love.system.openURL(COMMUNITY_URL) + end), + }) +end + +-- ------- modals + +local function modalOverlay(imp, m, closeKey, onClose) + local overlay = mk({ + z = 1000, + x = 0, y = 0, width = m.W, height = m.H, + backgroundColor = rgba(4, 6, 16, 0.72), + positioning = "flex", justifyContent = "center", alignItems = "center", + onEvent = onClose and handler(imp, closeKey, onClose) or function() end, + }) + return overlay +end + +local function modalPanel(overlay, m, w, props) + local p = { + parent = overlay, + width = math.min(w, m.W - 24), + backgroundColor = rgba(12, 17, 38, 0.98), + border = 1, borderColor = C("border", 0.5), + cornerRadius = 12, + positioning = "flex", flexDirection = "vertical", + gap = 10 * m.s, padding = { horizontal = 16, vertical = 14 }, + -- swallow clicks so the overlay's close handler stays outside the panel + onEvent = function() end, + } + for k, v in pairs(props or {}) do p[k] = v end + return mk(p) +end + +-- Shared prompt: title, hand-rolled text field, hint, action row. +local function buildPrompt(imp, m, spec) + local overlay = modalOverlay(imp, m, spec.key .. "-out") + local panel = modalPanel(overlay, m, spec.w or 460 * m.s) + label(panel, spec.title, 15 * m.s + 2, C("white")) + if spec.hint then + label(panel, spec.hint, 12 * m.s + 1, C("detail")) + end + textField(imp, panel, spec.key .. "-field", spec.text or "", nil, true) + local btnRow = mk({ parent = panel, width = "100%", + positioning = "flex", flexDirection = "horizontal", + justifyContent = "flex-end", gap = 8 * m.s }) + if spec.paste then + button(imp, btnRow, spec.key .. "-paste", Strings("Paste"), { + size = 12 * m.s + 1, kind = "accent", action = spec.paste, + }) + mk({ parent = btnRow, flex = 1 }) + end + button(imp, btnRow, spec.key .. "-cancel", Strings("Cancel"), { + size = 12 * m.s + 1, kind = "neutral", action = spec.cancel, + }) + button(imp, btnRow, spec.key .. "-ok", spec.okLabel or Strings("Save"), { + size = 12 * m.s + 1, kind = "primary", action = spec.commit, + }) + if spec.footnote then + label(panel, spec.footnote, 11 * m.s + 1, C("warn")) + end +end + +local function buildConfirmModal(imp, m) + local c = imp._modConfirm + local overlay = modalOverlay(imp, m, "confirm-out") + local panel = modalPanel(overlay, m, 420 * m.s) + label(panel, c.title or Strings("Confirm"), 15 * m.s + 2, C("white")) + for _, line in ipairs(c.lines or {}) do + label(panel, line, 12 * m.s + 1, C("detail")) + end + local btnRow = mk({ parent = panel, width = "100%", + positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s }) + button(imp, btnRow, "confirm-yes", c.yesLabel or Strings("OK"), { + flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "primary", + action = function() + imp._modConfirm = nil + if c.indexEntry then + imp:_findInstall(c.indexEntry) + elseif c.kind == "update" then + imp:_confirmModUpdate(c.id, c.release) + elseif c.kind == "enableAll" then + imp:_setAllMods(true, true) + else + imp:_toggleMod(c.id, true) + end + end, + }) + button(imp, btnRow, "confirm-no", Strings("Cancel"), { + flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "neutral", + action = function() imp._modConfirm = nil end, + }) +end + +local function buildTextModal(imp, m, title, body, closeFn, scrollId) + local overlay = modalOverlay(imp, m, "textmodal-out") + local panel = modalPanel(overlay, m, 520 * m.s) + label(panel, title, 15 * m.s + 2, C("white")) + local scroller = mk({ + parent = panel, id = scrollId, width = "100%", + height = math.min(m.H * 0.5, 340 * m.s), + overflowY = "scroll", hideScrollbars = true, + positioning = "flex", flexDirection = "vertical", + padding = { right = 8 }, + }) + label(scroller, body, 12 * m.s + 1, C("detail"), { width = "100%" }) + button(imp, panel, "textmodal-close", Strings("Close"), { + w = "100%", h = m.btnH, size = 13 * m.s, kind = "neutral", + action = closeFn, + }) +end + +local function buildVersionsModal(imp, m) + local ModUpdate = require("src.mods.ModUpdate") + local v = imp._modVersions + local overlay = modalOverlay(imp, m, "versions-out") + local panel = modalPanel(overlay, m, 520 * m.s) + label(panel, Strings("Other versions: ") .. tostring(v.name), + 15 * m.s + 2, C("white")) + local info = imp:_modUpdateInfo(v.id) + local statusTxt = Strings("Installed: v") .. tostring(v.current) + local statusCol = "detail" + if info and info.status == "available" then + statusTxt = statusTxt .. " - " .. Strings("Update v") .. tostring(info.latest) + statusCol = "green" + elseif info and info.status == "current" then + statusTxt = statusTxt .. " - " .. Strings("Up to date") + statusCol = "green" + end + label(panel, statusTxt, 12 * m.s + 1, C(statusCol)) + local scroller = mk({ + parent = panel, id = "modversions", width = "100%", + height = math.min(m.H * 0.5, 320 * m.s), overflowY = "scroll", hideScrollbars = true, + positioning = "flex", flexDirection = "vertical", gap = 6 * m.s, + padding = { right = 8 }, + }) + for i, rel in ipairs(v.releases) do + local row = mk({ parent = scroller, width = "100%", + backgroundColor = C("bg", 0.5), + border = 1, borderColor = C("border", 0.35), cornerRadius = 8, + positioning = "flex", flexDirection = "horizontal", + alignItems = "center", gap = 8 * m.s, + padding = { horizontal = 10, vertical = 8 } }) + local text = "v" .. rel.version + if rel.version == v.current then text = text .. Strings(" (installed)") end + if rel.prerelease then text = text .. " pre" end + local body = mk({ parent = row, flex = 1, + positioning = "flex", flexDirection = "vertical", gap = 2 * m.s }) + label(body, text, 12 * m.s + 1, + rel.version == v.current and C("warn") or C("white"), + { textWrap = false }) + local preview = ModUpdate.previewLine(rel.body or "", 90) + if preview ~= "" then + label(body, preview, 11 * m.s + 1, C("detail"), + { textWrap = false, textOverflow = "ellipsis" }) + end + if type(rel.body) == "string" and rel.body:match("%S") then + button(imp, row, "ver-notes-" .. i, Strings("Read more"), { + size = 11 * m.s, kind = "neutral", + action = function() + imp._modReleaseNotes = { version = rel.version, + body = rel.body or "", scroll = 0 } + end, + }) + end + if rel.version ~= v.current then + button(imp, row, "ver-inst-" .. i, Strings("Install"), { + size = 11 * m.s, kind = "accent", + action = function() imp:_installModVersion(v.id, rel) end, + }) + end + end + button(imp, panel, "versions-close", Strings("Close"), { + w = "100%", h = m.btnH, size = 13 * m.s, kind = "neutral", + action = function() imp._modVersions = nil end, + }) +end + +local function buildSettingsModal(imp, m) + local model = imp._settings + local overlay = modalOverlay(imp, m, "settings-out") + local panel = modalPanel(overlay, m, 640 * m.s, { + height = math.min(m.H - 40, m.H * 0.88), + }) + local head = mk({ parent = panel, width = "100%", + positioning = "flex", flexDirection = "horizontal", + justifyContent = "space-between", alignItems = "center" }) + label(head, Strings("Settings"), 17 * m.s + 3, C("white"), { textWrap = false }) + button(imp, head, "settings-close", Strings("Close"), { + size = 12 * m.s + 1, kind = "neutral", + action = function() imp:_closeSettings() end, + }) + label(panel, Strings( + "Saved to your options file; the game applies these on its next start."), + 11 * m.s + 2, C("warn")) + local scroller = mk({ + parent = panel, id = "settings-scroll", width = "100%", + flex = 1, overflowY = "scroll", hideScrollbars = true, + positioning = "flex", flexDirection = "vertical", gap = 6 * m.s, + padding = { right = 8 }, + }) + for si, section in ipairs(model.sections) do + label(scroller, section.title, 12 * m.s + 2, C("gray"), { + width = "100%", + margin = { top = si == 1 and 0 or 12 }, + }) + for ri, row in ipairs(section.rows) do + local key = "set-" .. si .. "-" .. ri + local rowEl = mk({ parent = scroller, width = "100%", + backgroundColor = C("rowBg", 0.6), + border = 1, borderColor = C("border", 0.22), cornerRadius = 8, + positioning = "flex", flexDirection = "horizontal", + alignItems = "center", gap = 8 * m.s, + padding = { horizontal = 12, vertical = 8 } }) + label(rowEl, row.label, 13 * m.s + 1, C("white"), + { flex = 1, textWrap = false, textOverflow = "ellipsis" }) + if row.editText then + label(rowEl, row.value(), 13 * m.s + 1, C("detail"), { textWrap = false }) + button(imp, rowEl, key .. "-edit", Strings("Edit"), { + size = 11 * m.s + 1, kind = "accent", + action = function() + imp._settingsText = { row = row, text = tostring(row.value() or ""), + maxLen = row.editText.maxLen } + imp:_armTextInput() + end, + }) + else + button(imp, rowEl, key .. "-prev", "<", { + size = 13 * m.s + 1, kind = "neutral", pad = { horizontal = 10, vertical = 4 }, + action = function() + if row.step and row.step(-1) then model.save() end + end, + }) + label(rowEl, row.value(), 13 * m.s + 1, C("green"), { + width = 110 * m.s, textAlign = "center", textWrap = false, + }) + button(imp, rowEl, key .. "-next", ">", { + size = 13 * m.s + 1, kind = "neutral", pad = { horizontal = 10, vertical = 4 }, + action = function() + if row.step and row.step(1) then model.save() end + end, + }) + end + end + end +end + +local function buildModals(imp, m) + if imp._settingsText then + local st = imp._settingsText + buildPrompt(imp, m, { + key = "settext", title = st.row.label, + text = st.text, + okLabel = Strings("Save"), + commit = function() imp:_commitSettingsText() end, + cancel = function() + imp._settingsText = nil + imp:_disarmTextInput() + end, + footnote = Strings("Enter to save - Esc to cancel"), + }) + return + end + if imp._settings then + buildSettingsModal(imp, m) + return + end + if imp._rename then + buildPrompt(imp, m, { + key = "rename", title = Strings("Name save slot"), + text = imp._rename.text, + okLabel = Strings("Save"), + commit = function() imp:_commitRename() end, + cancel = function() + imp._rename = nil + imp:_disarmTextInput() + end, + footnote = Strings("Enter to save - Esc to cancel - empty clears"), + }) + return + end + if imp._indexPrompt then + buildPrompt(imp, m, { + key = "index", title = Strings("Add a mod index"), + hint = Strings("Paste the index URL, or its owner/repo."), + text = imp._indexPrompt.text or "", + okLabel = Strings("Add"), + commit = function() imp:_commitAddIndex() end, + cancel = function() + imp._indexPrompt = nil + imp:_disarmTextInput() + end, + paste = function() imp:_pasteIndexUrl() end, + footnote = Strings("Enter to add - Esc to cancel"), + }) + return + end + if imp._modConfirm then + buildConfirmModal(imp, m) + return + end + if imp._modReleaseNotes then + local ModUpdate = require("src.mods.ModUpdate") + local n = imp._modReleaseNotes + local body = ModUpdate.cleanBody(n.body or "", 0) + if body == "" then body = Strings("(No release notes.)") end + buildTextModal(imp, m, "v" .. tostring(n.version) .. Strings(" notes"), + body, function() imp._modReleaseNotes = nil end, "release-notes") + return + end + if imp._findDetails then + local ModUpdate = require("src.mods.ModUpdate") + local d = imp._findDetails + local body = ModUpdate.cleanBody(d.body or "", 0) + if body == "" then body = Strings("(No description.)") end + buildTextModal(imp, m, d.title, body, + function() imp._findDetails = nil end, "find-details") + return + end + if imp._modVersions then + buildVersionsModal(imp, m) + return + end +end + +-- ------- pad cursor overlay (drawn after FlexLove, plain love.graphics) + +local function drawPadCursor(imp) + if not imp._padCursorActive then return end + local x, y = imp._padCursor.x, imp._padCursor.y + love.graphics.push("all") + love.graphics.origin() + love.graphics.setLineWidth(1) + love.graphics.setColor(0, 0, 0, 0.45) + love.graphics.polygon("fill", + x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26, + x + 18, y + 24, x + 11, y + 14, x + 20, y + 14) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.polygon("fill", + x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, + x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) + love.graphics.setColor(0.05, 0.07, 0.12, 1) + love.graphics.polygon("line", + x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, + x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) + love.graphics.pop() +end + +-- ------- frame assembly + +function LauncherView.draw(imp) + ensureFlex(imp) + + local W, H = love.graphics.getDimensions() + if imp._lastW ~= W or imp._lastH ~= H then + imp._lastW, imp._lastH = W, H + pcall(FlexLove.resize) + -- Persisted immediate-mode state (scroll offsets and the scroll + -- manager's cached geometry) survives a resize keyed by element id, so + -- the old window's scissors and scrollbar metrics kept clipping the new + -- layout. Drop it all; losing the scroll position on a resize is the + -- lesser cost. + pcall(FlexLove.clearAllStates) + end + + -- flat backdrop, painted before the element tree renders over it + love.graphics.setColor(C("bg"):toRGBA()) + love.graphics.rectangle("fill", 0, 0, W, H) + love.graphics.setColor(1, 1, 1, 1) + + local ox, oy, sw, sh = SafeArea.rect() + local s = clamp(sh / 768, 0.62, 1.5) + local appW = math.min(sw, 1200 * s) + local m = { + W = W, H = H, s = s, + x = ox + (sw - appW) / 2, top = oy, + w = appW, h = sh, + pad = clamp(appW * 0.03, 10, 24), + chip = math.max(34, 42 * s), + logoH = clamp(sh * 0.11, 40, 96), + btnH = math.max(34, 40 * s), + cardPad = { horizontal = 16, vertical = 14 }, + twoCol = appW >= 640, + } + m.colGap = 16 * m.s + -- scrollbars are hidden (wheel and touch drag still scroll); the slim + -- gutter is breathing room so content never touches the window edge + m.gutter = 8 + m.contentW = appW - 2 * m.pad - m.gutter + m.colW = m.twoCol and math.floor((m.contentW - m.colGap) / 2) or m.contentW + + local root = mk({ + x = m.x, y = m.top, width = m.w, height = m.h, + positioning = "flex", flexDirection = "vertical", + }) + buildHeader(imp, root, m) + + -- One scroll region per tab (stable id keeps its offset across frames and + -- separate per tab), holding the panel, the updater banner and the footer. + local page = mk({ + parent = root, id = "page-" .. imp.tab, + width = "100%", flex = 1, overflowY = "scroll", hideScrollbars = true, + positioning = "flex", flexDirection = "vertical", + gap = 12 * m.s, + padding = { left = m.pad, right = m.pad + m.gutter, + top = 14 * m.s, bottom = 10 * m.s }, + }) + if imp.tab == "mods" then + buildModsPanel(imp, page, m) + elseif imp.tab == "find" then + buildFindPanel(imp, page, m) + else + buildGamePanel(imp, page, m, imp.tab) + end + buildBanner(imp, page, m) + buildFooter(imp, page, m) + + buildModals(imp, m) + + FlexLove.draw() + drawPadCursor(imp) + + -- Dev harness: POKEPORT_LAUNCHER_DUMP=1 prints the laid-out tree once + -- (id/text, x, y, w, h) so geometry bugs are read off numbers instead of + -- guessed from screenshots. + if os.getenv("POKEPORT_LAUNCHER_DUMP") == "1" and not imp._dumped + and imp._shotTimer and imp._shotTimer > 1.0 then + imp._dumped = true + local function walk(el, depth) + local tag = el.id or (el.text and ("%q"):format( + tostring(el.text):sub(1, 24))) or "-" + print(("%s%s x=%.0f y=%.0f w=%.0f h=%.0f"):format( + (" "):rep(depth), tag, el.x or -1, el.y or -1, + el.width or -1, el.height or -1)) + for _, ch in ipairs(el.children or {}) do walk(ch, depth + 1) end + end + for _, el in ipairs(FlexLove.topElements or {}) do walk(el, 0) end + end +end + +return LauncherView diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 387b0c56..d1b6553d 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -959,6 +959,7 @@ function RomImporter:startData(data, displayName) end self._handedOff = true resetPointerCursor(self) + if self._flex then require("src.import.LauncherView").detach(self) end if self.onComplete then self.onComplete(version) end end) end @@ -1339,6 +1340,74 @@ end function RomImporter:update(dt) self.pulse = self.pulse + dt self:_updatePadCursor(dt) + -- Pump the FlexLove view (input polling + the queued click actions). The + -- flag is only set once draw() has built a tree, so headless runs and the + -- test tier never touch the toolkit. + if self._flex then + require("src.import.LauncherView").update(self, dt) + end + -- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from + -- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and + -- quits, so a scripted run can see the real launcher at any window shape + -- (the frame drivers all bypass the interactive launcher). + local shot = os.getenv("POKEPORT_LAUNCHER_SHOT") + if shot and not self._shotDone then + if not self._shotSized then + self._shotSized = true + local w, h = (os.getenv("POKEPORT_WIN") or ""):match("^(%d+)x(%d+)$") + if w and love.window and love.window.setMode then + pcall(love.window.setMode, tonumber(w), tonumber(h), + { resizable = true }) + end + local tab = os.getenv("POKEPORT_LAUNCHER_TAB") + if tab and tab ~= "" then self:_switchTab(tab) end + local query = os.getenv("POKEPORT_LAUNCHER_QUERY") + if query and query ~= "" then + self.findQuery = query + self._findSearchFocus = true + end + -- POKEPORT_LAUNCHER_TYPE feeds one character per frame through the + -- real textinput path, reproducing typed (per-frame growing) text + -- rather than text set once before the first frame. + self._shotType = os.getenv("POKEPORT_LAUNCHER_TYPE") + if self._shotType and self._shotType ~= "" then + self._findSearchFocus = true + self._shotTypeAt = 0 + end + end + if self._shotType and self._shotTypeAt then + self._shotTypeAt = self._shotTypeAt + 1 + if self._shotTypeAt % 3 == 0 then + local n = math.floor(self._shotTypeAt / 3) + if n <= #self._shotType then + self:textinput(self._shotType:sub(n, n)) + end + end + if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then + self:_openSettings() + end + end + self._shotTimer = (self._shotTimer or 0) + dt + -- POKEPORT_WIN2=WxH resizes mid-run, with UI state already settled, so + -- the capture exercises the live-resize path and not just first boot. + if not self._shotResized and self._shotTimer > 0.6 then + self._shotResized = true + local w2, h2 = (os.getenv("POKEPORT_WIN2") or ""):match("^(%d+)x(%d+)$") + if w2 and love.window and love.window.setMode then + pcall(love.window.setMode, tonumber(w2), tonumber(h2), + { resizable = true }) + end + end + if self._shotTimer > 1.2 then + self._shotDone = true + love.graphics.captureScreenshot(function(imagedata) + local fd = imagedata:encode("png") + local f = io.open(shot, "wb") + if f then f:write(fd:getString()) f:close() end + love.event.quit() + end) + end + end if self.ios and love.system.getPickedFile and self.workState ~= "working" then local path = love.system.getPickedFile() if path then @@ -1409,12 +1478,7 @@ function RomImporter:_cycleTab(delta) for i, id in ipairs(order) do if id == self.tab then idx = i; break end end - idx = ((idx - 1 + delta) % #order) + 1 - self.tab = order[idx] - self._slotPress = nil - self._modPress = nil - self._findSearchFocus = false - self:_disarmTextInput() + self:_switchTab(order[((idx - 1 + delta) % #order) + 1]) end function RomImporter:_updatePadCursor(dt) @@ -1449,44 +1513,35 @@ function RomImporter:_updatePadCursor(dt) local ny = self._padCursor.y + dy * speed * dt self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.y = math.max(oy, math.min(oy + h, ny)) + -- The FlexLove view polls the real mouse for hover and wheel targeting, + -- so the pad pointer warps it along. The self-caused motion is recorded + -- as the last seen position, or the yield check above would read the warp + -- as real mouse movement and drop the pad cursor immediately. + if love.mouse.setPosition then + pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) + self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y + end end - -- Right stick scrolls the active list (save slots or mods), or the whole page - -- when it is the thing that overflows. + -- Right stick scrolls whatever the pad pointer sits over, through the + -- view's wheel path, so the page and the modal scrollers all behave like a + -- mouse wheel would. local ry = self._padAxis.righty or 0 - if math.abs(ry) > PAD_DEAD then + if math.abs(ry) > PAD_DEAD and self._flex then self:_activatePadCursor() - local step = -ry * 480 * dt - local maxPage = self._pageMax or 0 - if maxPage > 0 then - self.pageScroll = math.max(0, math.min(maxPage, (self.pageScroll or 0) + step)) - elseif self.tab == "mods" then - local maxS = self._modMax or 0 - if maxS > 0 then - local next = (self.modScroll or 0) + step - self.modScroll = math.max(0, math.min(maxS, next)) - end - elseif self.tab == "find" then - local maxS = self._findMax or 0 - if maxS > 0 then - local next = (self.findScroll or 0) + step - self.findScroll = math.max(0, math.min(maxS, next)) - end - elseif GameVersion.VERSIONS[self.tab] then - local maxS = (self._slotMax and self._slotMax[self.tab]) or 0 - if maxS > 0 then - local next = (self.slotScroll[self.tab] or 0) + step - self.slotScroll[self.tab] = math.max(0, math.min(maxS, next)) - end - end + require("src.import.LauncherView").wheelmoved(self, 0, -ry * 8 * dt) end end function RomImporter:gamepadpressed(_, button) self:_activatePadCursor() if button == "a" then - -- Instant click at the virtual pointer (same path as a mouse/touch tap). - self:mousepressed(self._padCursor.x, self._padCursor.y, 1) + -- Instant click at the virtual pointer: dispatched straight into the + -- view, since the launcher no longer hit-tests presses itself. + if self._flex then + require("src.import.LauncherView").clickAt(self, + self._padCursor.x, self._padCursor.y) + end elseif button == "leftshoulder" then self:_cycleTab(-1) elseif button == "rightshoulder" then @@ -1565,6 +1620,9 @@ function RomImporter:play(version) if not self.ready[version] then return end self._handedOff = true resetPointerCursor(self) + -- The game draws with raw love.graphics from here on; drop the view's + -- element tree and canvases before the handoff. + if self._flex then require("src.import.LauncherView").detach(self) end if self.onComplete then self.onComplete(version) end end @@ -1582,22 +1640,6 @@ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end --- set the current draw colour from a PAL triple (0-255), with optional alpha 0-1 -local function col(c, a) - love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1) -end - --- Faux-bold: the launcher's UI font ships no bold face, so 800-weight text --- (headings, buttons) is thickened with a second sub-pixel pass. -local function printfB(text, x, y, w, align) - love.graphics.printf(text, x, y, w, align) - love.graphics.printf(text, x + 0.6, y, w, align) -end -local function printB(text, x, y) - love.graphics.print(text, x, y) - love.graphics.print(text, x + 0.6, y) -end - -- UTF-8 helpers for the slot-rename field (#205). The `utf8` library only -- exists inside LOVE (plain luajit, which loads this module in tests, has -- none), so codepoint walking is done by hand -- the same lead-byte width @@ -1623,165 +1665,6 @@ local function utf8Cap(t, maxChars) return t end --- One reusable unit quad, recoloured per call, for every vertical gradient --- fill (LOVE has no gradient primitive and a per-frame newMesh would churn --- the GPU). Callers set the blend mode; this only touches colour + geometry. -local gradMesh -local function setGrad(cTop, cBot, aTop, aBot) - if not gradMesh then gradMesh = love.graphics.newMesh(4, "fan", "dynamic") end - gradMesh:setVertices({ - { 0, 0, 0, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop }, - { 1, 0, 1, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop }, - { 1, 1, 1, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot }, - { 0, 1, 0, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot }, - }) -end -local function fillGrad(x, y, w, h, cTop, cBot, aTop, aBot) - setGrad(cTop, cBot, aTop, aBot) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(gradMesh, x, y, 0, w, h) -end --- vertical gradient clipped to a rounded rectangle (via the stencil buffer) -local function fillGradRounded(x, y, w, h, r, cTop, cBot, aTop, aBot) - love.graphics.stencil(function() - love.graphics.rectangle("fill", x, y, w, h, r, r) - end, "replace", 1) - love.graphics.setStencilTest("greater", 0) - fillGrad(x, y, w, h, cTop, cBot, aTop, aBot) - love.graphics.setStencilTest() -end - --- Soft additive neon halo around a rounded rect. LOVE has no blur, so stack --- progressively larger, fainter translucent rounded rects. -local function neonGlow(x, y, w, h, r, c, strength) - strength = math.max(0, strength) - if strength == 0 then return end - love.graphics.setBlendMode("add") - local layers = 7 - for i = 1, layers do - local g = i * 2.4 - love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, - strength * 0.05 * (1 - (i - 1) / layers)) - love.graphics.rectangle("fill", x - g, y - g, w + 2 * g, h + 2 * g, r + g, r + g) - end - love.graphics.setBlendMode("alpha") -end - --- A white shine band that sweeps across an active button, clipped to its --- rounded shape. phase is 0..1 (left of the button -> right of it). -local shineMesh -local function buttonShine(x, y, w, h, r, phase) - if not shineMesh then - -- triangle strip: three columns (transparent, white, transparent) - shineMesh = love.graphics.newMesh({ - { 0, 0, 0, 0, 1, 1, 1, 0 }, - { 0, 1, 0, 1, 1, 1, 1, 0 }, - { 0.5, 0, 0.5, 0, 1, 1, 1, 0.5 }, - { 0.5, 1, 0.5, 1, 1, 1, 1, 0.5 }, - { 1, 0, 1, 0, 1, 1, 1, 0 }, - { 1, 1, 1, 1, 1, 1, 1, 0 }, - }, "strip", "static") - end - local bandW = w * 0.6 - local bx = x - bandW + phase * (w + bandW) - love.graphics.stencil(function() - love.graphics.rectangle("fill", x, y, w, h, r, r) - end, "replace", 1) - love.graphics.setStencilTest("greater", 0) - love.graphics.setBlendMode("add") - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(shineMesh, bx, y, 0, bandW, h) - love.graphics.setBlendMode("alpha") - love.graphics.setStencilTest() -end - --- Draw letterspaced text (the UI font has no tracking control): advance glyph --- by glyph. Returns the total drawn width so a caller can align to it. -local function printSpaced(font, text, x, y, spacing) - local cx = x - for i = 1, #text do - local ch = text:sub(i, i) - love.graphics.print(ch, cx, y) - cx = cx + font:getWidth(ch) + spacing - end - return math.max(0, cx - x - spacing) -end - --- Clip text to a pixel width, appending an ellipsis when it overflows (the UI --- font has no built-in truncation). Used for save-slot names / meta lines. --- Drops whole UTF-8 codepoints (never mid-byte) so Font:getWidth cannot see --- a truncated multi-byte sequence and throw "UTF-8 decoding error". -local function ellipsize(font, text, maxW) - text = tostring(text or "") - if maxW <= 0 or font:getWidth(text) <= maxW then return text end - local ell = "..." - local ew = font:getWidth(ell) - while #text > 0 and font:getWidth(text) + ew > maxW do - text = utf8Back(text) - end - return text .. ell -end - --- Stroke a rounded rectangle as a dashed outline (LOVE has no dashed line): --- sample the path into a polyline -- corners as short arcs -- then walk it, --- toggling on/off every dash/gap. Used for the "+ New save slot" button and --- the empty-slots box. Caller sets colour + line width. -local function dashedRoundRect(x, y, w, h, r, dash, gap) - r = math.min(r, w / 2, h / 2) - local seg = 4 - local pts = {} - local function arc(cx, cy, a0, a1) - for i = 0, seg do - local a = a0 + (a1 - a0) * (i / seg) - pts[#pts + 1] = cx + math.cos(a) * r - pts[#pts + 1] = cy + math.sin(a) * r - end - end - arc(x + w - r, y + r, -math.pi / 2, 0) - arc(x + w - r, y + h - r, 0, math.pi / 2) - arc(x + r, y + h - r, math.pi / 2, math.pi) - arc(x + r, y + r, math.pi, math.pi * 1.5) - pts[#pts + 1] = pts[1]; pts[#pts + 1] = pts[2] -- close the loop - local remaining, drawing = dash, true - for i = 1, #pts - 2, 2 do - local x1, y1 = pts[i], pts[i + 1] - local dx, dy = pts[i + 2] - x1, pts[i + 3] - y1 - local segLen = math.sqrt(dx * dx + dy * dy) - local pos = 0 - while pos < segLen do - local step = math.min(remaining, segLen - pos) - if drawing then - local t0, t1 = pos / segLen, (pos + step) / segLen - love.graphics.line(x1 + dx * t0, y1 + dy * t0, x1 + dx * t1, y1 + dy * t1) - end - pos = pos + step - remaining = remaining - step - if remaining <= 0.0001 then - drawing = not drawing - remaining = drawing and dash or gap - end - end - end -end - --- The redesign's standard content card: a faint top-lit blue tint fading into --- a dark interior, with a thin cool-gray border. Shared by the ROM / SAVE --- FILES / SAVE SLOT cards and the mod cards so every panel matches. -local function roundedCard(x, y, w, h, r) - fillGradRounded(x, y, w, h, r, PAL.blue, PAL.cardBlue, 0.08, 0.5) - love.graphics.setLineWidth(1) - col(PAL.cardBorder, 0.28) - love.graphics.rectangle("line", x, y, w, h, r, r) -end - --- {top, bottom} of the scrolling page viewport, or nil while the page fits and --- nothing scrolls. Written once per frame by draw(); read by the two hit tests --- (`inside` for clicks, `_ptIn` for hover) so a control scrolled out from under --- the pinned header, or past the window bottom, stops responding at the moment --- it stops being visible. Rects that live in the pinned header carry --- `pinned = true` and are exempt. -local pageBand = nil - -- Page-scroll arithmetic, kept pure (no love, no self) so the engine tier can -- pin it: given how tall the column under the tab bar wants to be and how much -- room is left under it, say whether the page scrolls, where it sits, and how @@ -1792,1232 +1675,97 @@ function RomImporter.pageScrollFor(naturalH, viewportH, scroll) return maxPage > 0, clamp(scroll or 0, 0, maxPage), maxPage end --- Every hit rect mousepressed dispatches on, cleared before any panel draws: --- each rect is rebuilt only by the panel that draws its control, so whatever a --- frame does not draw must not stay clickable. Missing the Delete rects here --- let a click on the mods tab land on the game tab's save Delete label (#433). -function RomImporter:_resetFrameRects() - self.romButtonRect = nil - self.playButtonRect = nil - self.tabRects = {} - -- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a - -- version with no panel drawn this frame) cannot inherit last frame's rows. - self.slotRects = nil - self.slotEditRects = nil - self.slotDeleteRects = nil - self.newSlotRect = nil - -- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit - -- last frame's mod toggles / Delete labels / import button. - self.modRects = nil - self.modDeleteRects = nil - self.modImportRect = nil - -- Enable all / Disable all share that header and the same rule (#647): they - -- are only drawn when the row is wide enough, so a stale rect would otherwise - -- stay clickable over whatever the next tab (or the next window size) draws. - self.modEnableAllRect = nil - self.modDisableAllRect = nil - -- Same rule as the toggles above, and it started to bite once FIND MODS gave - -- the mods tab a neighbour: these two were rebuilt by the mods panel but - -- never cleared, so switching tabs left the last mod row's Update / Versions - -- labels clickable over whatever the next tab drew there (#433's shape). - self.modUpdateRects = nil - self.modVersionsRects = nil - -- Rebuilt only by the FIND MODS panel. - self.findAddRect = nil - self.findRefreshRect = nil - self.findSearchRect = nil - self.findCatRects = nil - self.findInstallRects = nil - self.findDetailRects = nil - self.findRepoRects = nil - self.findSourceRemoveRects = nil - -- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so - -- the mods tab cannot inherit last frame's save Import/Export/open-folder hits. - self.saveImportRect = nil - self.saveExportRect = nil - self.saveFolderRect = nil - -- Rebuilt only by the active game panel; nil elsewhere so the mods tab - -- cannot inherit last frame's Touch Controls button. - self.touchControlsRect = nil -end +-- The whole launcher surface is the FlexLove view (src/import/LauncherView): +-- it rebuilds the element tree from this importer's state every frame and +-- renders it. Required lazily so a headless test require of this module +-- never loads the UI toolkit. function RomImporter:draw() - -- Full window for immersive backdrop; safe rect for interactive chrome so - -- notch / Dynamic Island / home indicator / Android cutouts are respected. - local fullW, fullH = love.graphics.getDimensions() - local ox, oy, width, height = SafeArea.rect() - local s = clamp(height / 768, 0.7, 1.6) - local pulse = self.pulse - self._s = s - - -- Hover state. Desktop mouse, or the gamepad virtual cursor on handhelds - -- (Android stays touch-only -- no hover). Panel methods read the pointer + - -- set self._anyHover through self:_hover; the cursor is set at the end. - -- Reset the per-frame hit rects so a tab with no controls (mods) cannot - -- inherit last frame's game-panel buttons. - if self._padCursorActive then - self._mx, self._my = self._padCursor.x, self._padCursor.y - else - self._mx, self._my = love.mouse.getPosition() - end - self._hoverEnabled = self._padCursorActive or not self.android - self._anyHover = false - self:_resetFrameRects() - - -- Fonts + size-dependent scenery, rebuilt only when the window / safe - -- area changes (rotation, resize, inset changes). - local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy) - if self.fontKey ~= fontKey then - self.fontKey = fontKey - local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end - self.headFont = f(19 * s) - self.detailFont = f(14 * s) - self.buttonFont = f(19 * s) - self.hintFont = f(13 * s) - self.warningFont = f(11 * s) - -- redesign faces - self.gameNameFont = f(26 * s) -- game / "Mods" heading - self.pillFont = f(13 * s) -- status pill - self.labelFont = f(12 * s) -- letterspaced ROM / SAVE FILES / SAVE SLOT - self.stateFont = f(16 * s) -- ROM state line - self.saveBtnFont = f(14 * s) -- glassy card buttons - self.chipFont = f(20 * s) -- R / B / Y tab letters - self.tabLabelFont = f(14 * s) -- active tab label - self.readyFont = f(12 * s) -- "N of 3 ready" - self.playFont = f(20 * s) -- Play button - self.slotNameFont = f(15 * s) -- save-slot player name / "NEW GAME" - - -- Background: a radial gradient (bright navy at top-centre -> near black). - -- A triangle fan from the top-centre gives the radial falloff; the screen - -- is cleared to the outer colour first so the corners it does not reach - -- match seamlessly. Sized to the full window so unsafe edges stay filled. - do - local cx, cy = fullW / 2, 0 - local rx, ry = fullW * 1.3, fullH * 1.08 - local n = 72 - local verts = { { cx, cy, 0, 0, - PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } } - for i = 0, n do - local a = (i / n) * math.pi * 2 - verts[#verts + 1] = { cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0, - PAL.bgBot[1] / 255, PAL.bgBot[2] / 255, PAL.bgBot[3] / 255, 1 } - end - self.bgMesh = love.graphics.newMesh(verts, "fan", "static") - end - - -- CRT vignette: a gentle edge darkening, centred slightly above the middle. - do - local cx, cy = fullW / 2, fullH * 0.45 - local rx, ry = fullW * 0.78, fullH * 0.78 - local n = 72 - local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } } - for i = 0, n do - local a = (i / n) * math.pi * 2 - verts[#verts + 1] = - { cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0, 0, 0, 0, 0.32 } - end - self.vignetteMesh = love.graphics.newMesh(verts, "fan", "static") - end - - -- CRT scanlines: a 1px dark line every 3px, baked into a tiny tile and - -- drawn once with a repeat-wrapped quad (one draw call, correct alpha). - if not self.scanlineImage then - local id = love.image.newImageData(1, 3) - id:setPixel(0, 0, 0, 0, 0, 0.08) - id:setPixel(0, 1, 0, 0, 0, 0) - id:setPixel(0, 2, 0, 0, 0, 0) - self.scanlineImage = love.graphics.newImage(id) - self.scanlineImage:setWrap("repeat", "repeat") - self.scanlineImage:setFilter("nearest", "nearest") - end - self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3) - end - - -- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it - -- is rendered white (the design's filter:invert(1)). Built lazily so a - -- headless require never needs a GL context. - self.invertShader = self.invertShader or love.graphics.newShader([[ - vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { - vec4 p = Texel(tex, tc); - return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a); - } - ]]) - - -- Shine shader: the same white sweep the active buttons get, but clipped to - -- the logo's own shape (a soft band brightens the pixels it crosses; fully - -- transparent pixels stay transparent). - self.shineShader = self.shineShader or love.graphics.newShader([[ - extern number shinePos; - extern number shineW; - vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { - vec4 p = Texel(tex, tc); - float band = smoothstep(shineW, 0.0, abs(tc.x - shinePos)); - return vec4(p.rgb + band * 0.55, p.a) * color; - } - ]]) - - -- background (full window — unsafe edges stay painted) - col(PAL.bgBot) - love.graphics.rectangle("fill", 0, 0, fullW, fullH) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.bgMesh) - - -- Centered content container (max ~1440 scaled units on very wide windows) - -- with a responsive side gutter; every column below derives from these. - -- Origin is the safe-area top-left so chrome clears device insets. - local appW = math.min(width, 1440 * s) - local appX = ox + (width - appW) / 2 - local padH = clamp(appW * 0.03, 12 * s, 26 * s) - local third = appW / 3 - - -- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom - local stripH = math.max(4, 6 * s) - local stripY = oy - local segs = { - { PAL.red, appX, third }, - { PAL.blue, appX + third, third }, - { PAL.gold, appX + 2 * third, appW - 2 * third }, - } - love.graphics.setBlendMode("add") - for _, seg in ipairs(segs) do - fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0) - end - love.graphics.setBlendMode("alpha") - for _, seg in ipairs(segs) do - col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH) - end - - -- Footer (Boi's Club Games logo + trust warning), measured first so the - -- content region knows where it must stop. Only its height is fixed here: - -- it is laid out from a top edge further down, which is the window bottom - -- while the page fits and the end of the scrolled content when it does not. - local warningWidth = math.min(appW - 32 * s, 640 * s) - local _, warningLines = self.warningFont:getWrap(TRUST_WARNING, warningWidth) - local warningH = #warningLines * self.warningFont:getHeight() - local bcgW, bcgH = self.bcg:getDimensions() - local bcgScale = math.min(math.min(appW - 48 * s, 190 * s) / bcgW, height * 0.06 / bcgH) - local bcgDW, bcgDH = bcgW * bcgScale, bcgH * bcgScale - local footerH = 10 * s + bcgDH + 6 * s + warningH + 12 * s - - -- Logo: centred over the strip, width clamped, gentle bob + glow pulse. The - -- resting metrics fix the tab bar's top so the layout never shifts as it bobs. - local logoW, logoH = self.logo:getDimensions() - local logoTargetW = math.max(math.min(180 * s, appW - 32 * s), - math.min(330 * s, appW - 32 * s)) - local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH) - local logoDW, logoDH = logoW * logoScale, logoH * logoScale - local logoY = stripY + stripH + 14 * s - - -- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one), - -- with "N of 3 ready" right-aligned. - local chip = 44 * s - local tabBarY = logoY + logoDH + 6 * s - local tabBarH = chip + 22 * s - - -- Self-updater banner state: computed up front so its band can be reserved - -- above the footer, then drawn after the content below. Only the four - -- actionable states surface anything. - local upStatus, upLatest, upProgress - if self.Check then - local ok, st = pcall(self.Check.state) - st = (ok and type(st) == "table") and st or nil - local status = st and st.status - if status == "available" or status == "downloading" - or status == "ready" or status == "needs_full" then - upStatus, upLatest, upProgress = status, st.latest, st.progress - end - end - local bannerActive = upStatus ~= nil - local bannerH = 46 * s - - -- Content region: from below the tab bar down to the footer, minus the - -- updater band when one is showing. - local contentTop = tabBarY + tabBarH + 16 * s - local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s - local cX = appX + padH - local cW = appW - 2 * padH - local contentBottom = oy + height - footerH - bannerBand - local cH = math.max(0, contentBottom - contentTop) - - -- Page scroll. Everything under the tab bar -- panel, updater banner and - -- footer -- is one column: too short a window scrolls it instead of letting - -- the panel run under a footer pinned to the window bottom (a stacked - -- single-column layout on a phone-shaped window overflows by a card or two). - -- The panels report their natural height as they draw, so the decision reads - -- the previous frame's measurement, the same one-frame settle the slot and - -- mod lists already rely on. While the page fits, `paged` is false and every - -- measurement below is what it always was. - local viewportH = math.max(0, oy + height - contentTop) - self._panelNaturalH = self._panelNaturalH or {} - local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH - local paged, pageScroll, maxPage = - RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll) - self.pageScroll, self._pageMax = pageScroll, maxPage - -- read by the hit tests; a scrolled control is live only inside the viewport - pageBand = paged and { contentTop, oy + height } or nil - - -- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation, - -- and it sits above the scrolling viewport. - self:_drawTabBar(cX, tabBarY, cW, tabBarH, chip) - - local panelY = contentTop - (paged and self.pageScroll or 0) - if paged then - love.graphics.setScissor(math.floor(appX), math.floor(contentTop), - math.ceil(appW), math.ceil(viewportH)) - end - - -- content: game panel for a version tab, mods panel for the mods tab - local panelH - if self.tab == "mods" then - panelH = self:_drawModsPanel(cX, panelY, cW, cH, paged) - elseif self.tab == "find" then - panelH = self:_drawFindPanel(cX, panelY, cW, cH, paged) - else - panelH = self:_drawGamePanel(self.tab, cX, panelY, cW, cH, paged) - end - panelH = panelH or 0 - self._panelNaturalH[self.tab] = panelH - - -- The updater band and the footer follow the content: pinned to the window - -- bottom while the page fits, riding at the end of the scroll when it does not. - local bandTop = paged and (panelY + panelH) or contentBottom - local footerTop = bandTop + bannerBand - - -- Self-updater banner: a compact pill centred in the reserved band just above - -- the footer, on every tab. Same green "Play" treatment on its CTA. - self.updateButton = nil - if bannerActive then - local bannerW = math.min(appW - 32 * s, 560 * s) - local bx = appX + (appW - bannerW) / 2 - local by = bandTop + math.max(0, (footerTop - bandTop - bannerH) / 2) - local r = 12 * s - local accent = PAL.gold - - neonGlow(bx, by, bannerW, bannerH, r, accent, 0.28) - fillGradRounded(bx, by, bannerW, bannerH, r, accent, PAL.bgBot, 0.14, 0.6) - love.graphics.setLineWidth(math.max(1, 1.2 * s)) - col(accent, 0.5) - love.graphics.rectangle("line", bx, by, bannerW, bannerH, r, r) - - local padX = 16 * s - local innerX = bx + padX - local innerW = bannerW - 2 * padX - - local function actionButton(label) - love.graphics.setFont(self.detailFont) - local bw = math.min(innerW * 0.62, self.detailFont:getWidth(label) + 34 * s) - local bh = bannerH - 12 * s - local abx = bx + bannerW - padX - bw - local aby = by + (bannerH - bh) / 2 - local br = 9 * s - local rect = { x = abx, y = aby, width = bw, height = bh } - local hot = self:_hover(rect) - local gp = 0.5 + 0.5 * math.sin(pulse * 2 * math.pi / 2.4) - neonGlow(abx, aby, bw, bh, br, PAL.playTop, (0.6 + 0.25 * gp) * (hot and 1.7 or 1)) - fillGradRounded(abx, aby, bw, bh, br, PAL.playTop, PAL.playBot, 1, 1) - if hot then - love.graphics.setBlendMode("add") - love.graphics.setColor(1, 1, 1, 0.12) - love.graphics.rectangle("fill", abx, aby, bw, bh, br, br) - love.graphics.setBlendMode("alpha") - end - buttonShine(abx, aby, bw, bh, br, (pulse % 2.8) / 2.8) - love.graphics.setFont(self.detailFont) - col(PAL.playInk) - printfB(label, abx, aby + (bh - self.detailFont:getHeight()) / 2, bw, "center") - return rect - end - - local function message(text, reserveW) - love.graphics.setFont(self.detailFont) - col(PAL.heading) - love.graphics.printf(text, innerX, - by + (bannerH - self.detailFont:getHeight()) / 2, - math.max(1, innerW - reserveW - 12 * s), "left") - end - - if upStatus == "available" then - local rect = actionButton("Update") - self.updateButton = { x = rect.x, y = rect.y, width = rect.width, - height = rect.height, action = "download" } - message(upLatest and ("Update v" .. upLatest .. " available") - or Strings("An update is available"), rect.width) - elseif upStatus == "needs_full" then - local rect = actionButton("Open releases") - self.updateButton = { x = rect.x, y = rect.y, width = rect.width, - height = rect.height, action = "openurl" } - message("A new version needs a fresh download", rect.width) - elseif upStatus == "ready" then - local rect = actionButton("Restart to update") - self.updateButton = { x = rect.x, y = rect.y, width = rect.width, - height = rect.height, action = "restart" } - message("Update downloaded", rect.width) - elseif upStatus == "downloading" then - love.graphics.setFont(self.hintFont) - col(PAL.detail) - love.graphics.print("Downloading update", innerX, by + 7 * s) - local h2 = math.max(8, 10 * s) - local track = by + bannerH - h2 - 8 * s - col(PAL.bgBot, 0.85) - love.graphics.rectangle("fill", innerX, track, innerW, h2, h2 / 2, h2 / 2) - local pw = innerW * clamp(upProgress or 0, 0, 1) - if pw > h2 then - neonGlow(innerX, track, pw, h2, h2 / 2, accent, 0.6) - col(accent) - love.graphics.rectangle("fill", innerX, track, pw, h2, h2 / 2, h2 / 2) - end - end - end - - -- footer: a hairline top border, the BCG mark (inverted to white, glowing - -- brighter on hover) + the trust warning with its live bois.icu link. Laid - -- out downward from footerTop, so the same code serves the pinned and the - -- scrolled position. - love.graphics.setLineWidth(1) - col(PAL.cardBorder, 0.18) - love.graphics.line(appX + padH, footerTop, appX + appW - padH, footerTop) - - local bcgX, bcgY = appX + (appW - bcgDW) / 2, footerTop + 10 * s - local warningY = bcgY + bcgDH + 6 * s - self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH } - - local bcgHot = self:_hover(self.bcgButton) - love.graphics.setShader(self.invertShader) - love.graphics.setBlendMode("add") - love.graphics.setColor(1, 1, 1, bcgHot and 0.5 or 0.22) - love.graphics.draw(self.bcg, bcgX - bcgDW * 0.02, bcgY - bcgDH * 0.02, 0, - bcgScale * 1.04, bcgScale * 1.04) - love.graphics.setBlendMode("alpha") - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.bcg, bcgX, bcgY, 0, bcgScale, bcgScale) - love.graphics.setShader() - - love.graphics.setFont(self.warningFont) - col(PAL.warning) - local wrapX = appX + (appW - warningWidth) / 2 - love.graphics.printf(TRUST_WARNING, wrapX, warningY, warningWidth, "center") - self.linkUrlRect = nil - do - local lh = self.warningFont:getHeight() - local _, lines = self.warningFont:getWrap(TRUST_WARNING, warningWidth) - for i, line in ipairs(lines) do - local sidx = line:find(COMMUNITY_URL, 1, true) - if sidx then - local before = line:sub(1, sidx - 1) - local lineW = self.warningFont:getWidth(line) - local ux = wrapX + (warningWidth - lineW) / 2 + self.warningFont:getWidth(before) - local uy = warningY + (i - 1) * lh - local uw = self.warningFont:getWidth(COMMUNITY_URL) - self.linkUrlRect = { x = ux, y = uy, width = uw, height = lh } - local linkHot = self:_hover(self.linkUrlRect) - col(linkHot and PAL.linkHover or PAL.link) - love.graphics.print(COMMUNITY_URL, ux, uy) - love.graphics.setLineWidth(1) - love.graphics.line(ux, uy + lh - 1, ux + uw, uy + lh - 1) - break - end - end - end - - -- End of the scrolling column; the logo and the page scrollbar are pinned and - -- draw outside it. - if paged then love.graphics.setScissor() end - - -- logo, over the split, with a gentle bob + gold glow + sweeping shine - local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s - local lx, ly = ox + (width - logoDW) / 2, logoY + bob - love.graphics.setBlendMode("add") - love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6))) - love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0, - logoScale * 1.05, logoScale * 1.05) - love.graphics.setBlendMode("alpha") - local shineW = 0.16 - self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW)) - self.shineShader:send("shineW", shineW) - love.graphics.setShader(self.shineShader) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale) - love.graphics.setShader() - - -- page scrollbar: the same thin thumb the lists use, against the app edge - if paged then - local thumbH = math.max(24 * s, viewportH * (viewportH / naturalH)) - local thumbY = contentTop + (viewportH - thumbH) * (self.pageScroll / maxPage) - col(PAL.cardBorder, 0.35) - love.graphics.rectangle("fill", appX + appW - padH * 0.5, thumbY, 3 * s, thumbH, - 1.5 * s, 1.5 * s) - end - - -- CRT scanlines + vignette, over everything - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.scanlineImage, self.scanlineQuad, 0, 0) - love.graphics.draw(self.vignetteMesh) - love.graphics.setColor(1, 1, 1, 1) - - -- drag-to-scroll the save-slot list (polls the pointer; no move/release - -- events reach the launcher, so click-vs-drag is resolved here) - self:_updateSlotDrag() - - -- save-slot rename modal (#205), drawn over everything - if self._rename then - col(PAL.bgBot, 0.72) - love.graphics.rectangle("fill", 0, 0, fullW, fullH) - local dw = math.min(appW - 32 * s, 420 * s) - local dh = 128 * s - local dx = appX + (appW - dw) / 2 - local dy = oy + (height - dh) / 2 - local rr = 12 * s - neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4) - fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85) - love.graphics.setLineWidth(math.max(1, 1.2 * s)) - col(PAL.green, 0.5) - love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) - - love.graphics.setFont(self.slotNameFont) - col(PAL.white) - love.graphics.print(Strings("Name save slot"), dx + 16 * s, dy + 14 * s) - - -- the field: bordered strip, current text, blinking caret on the pulse - local fx, fy = dx + 16 * s, dy + 44 * s - local fw, fh = dw - 32 * s, 30 * s - col(PAL.bgBot, 0.9) - love.graphics.rectangle("fill", fx, fy, fw, fh, 8 * s, 8 * s) - love.graphics.setLineWidth(math.max(1, s)) - col(PAL.cardBorder, 0.45) - love.graphics.rectangle("line", fx, fy, fw, fh, 8 * s, 8 * s) - love.graphics.setFont(self.detailFont) - col(PAL.heading) - local shown = ellipsize(self.detailFont, self._rename.text, fw - 20 * s) - love.graphics.print(shown, fx + 10 * s, fy + (fh - self.detailFont:getHeight()) / 2) - if (self.pulse * 2 % 1) < 0.5 then - local cx = fx + 10 * s + self.detailFont:getWidth(shown) + 2 * s - col(PAL.green) - love.graphics.rectangle("fill", cx, fy + 6 * s, math.max(1, 1.5 * s), - fh - 12 * s) - end - - love.graphics.setFont(self.hintFont) - col(PAL.detail) - printfB(Strings("Enter to save - Esc to cancel - empty clears"), - dx + 16 * s, dy + dh - 30 * s, dw - 32 * s, "left") - end - - -- "Add an index" prompt: the same field as the rename modal, sized for a URL - -- and with the caret pinned to the tail so a long one stays readable while - -- it is typed. - if self._indexPrompt then - col(PAL.bgBot, 0.72) - love.graphics.rectangle("fill", 0, 0, fullW, fullH) - local dw = math.min(appW - 32 * s, 520 * s) - local dh = 176 * s - local dx = appX + (appW - dw) / 2 - local dy = oy + (height - dh) / 2 - local rr = 12 * s - neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4) - fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9) - love.graphics.setLineWidth(math.max(1, 1.2 * s)) - col(PAL.modDot, 0.55) - love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) - - love.graphics.setFont(self.slotNameFont) - col(PAL.white) - love.graphics.print(Strings("Add a mod index"), dx + 16 * s, dy + 14 * s) - love.graphics.setFont(self.hintFont) - col(PAL.detail) - love.graphics.printf( - Strings("Paste the index URL, or its owner/repo."), - dx + 16 * s, dy + 40 * s, dw - 32 * s, "left") - - local fx, fy = dx + 16 * s, dy + 66 * s - local fw, fh = dw - 32 * s, 32 * s - col(PAL.bgBot, 0.9) - love.graphics.rectangle("fill", fx, fy, fw, fh, 8 * s, 8 * s) - love.graphics.setLineWidth(math.max(1, s)) - col(PAL.cardBorder, 0.45) - love.graphics.rectangle("line", fx, fy, fw, fh, 8 * s, 8 * s) - love.graphics.setFont(self.hintFont) - col(PAL.heading) - -- keep the END of the URL visible: the interesting half is the tail - local text = self._indexPrompt.text or "" - local maxW = fw - 20 * s - local shown = text - while #shown > 0 and self.hintFont:getWidth(shown) > maxW do - shown = shown:sub(2) - end - love.graphics.print(shown, fx + 10 * s, - fy + (fh - self.hintFont:getHeight()) / 2) - if (self.pulse * 2 % 1) < 0.5 then - col(PAL.modDot) - love.graphics.rectangle("fill", - fx + 10 * s + self.hintFont:getWidth(shown) + 2 * s, - fy + 7 * s, math.max(1, 1.5 * s), fh - 14 * s) - end - - -- PASTE under the field: a touch screen has no ctrl+V, and an index URL - -- is not something anyone retypes on a soft keyboard (#578). This rect - -- is the one click mousepressed honors while the prompt is up; pinned so - -- page-scroll banding never eats the tap. - self._indexPasteRect = self:_chipButton(fx + fw - 84 * s, fy + fh + 8 * s, - Strings("Paste"), { w = 84 * s, h = 28 * s, kind = "accent" }) - self._indexPasteRect.pinned = true - - love.graphics.setFont(self.hintFont) - col(PAL.warning) - printfB(Strings("Enter to add - Esc to cancel"), - dx + 16 * s, dy + dh - 32 * s, dw - 32 * s, "left") - end - - -- Mod confirm / versions / release-notes / index-details overlays - if self._modConfirm or self._modVersions or self._modReleaseNotes - or self._findDetails then - col(PAL.bgBot, 0.72) - love.graphics.rectangle("fill", 0, 0, fullW, fullH) - end - if self._modConfirm then - local c = self._modConfirm - local dw = math.min(appW - 32 * s, 400 * s) - local lineH = self.hintFont:getHeight() + 4 * s - local dh = 36 * s + (#c.lines) * lineH + 56 * s - local dx = appX + (appW - dw) / 2 - local dy = oy + (height - dh) / 2 - local rr = 12 * s - fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) - love.graphics.setLineWidth(math.max(1, 1.2 * s)) - col((c.kind == "update") and PAL.green or PAL.gold, 0.65) - love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) - love.graphics.setFont(self.slotNameFont) - col(PAL.white) - love.graphics.printf(c.title or "Confirm", dx + 16 * s, dy + 14 * s, - dw - 32 * s, "left") - love.graphics.setFont(self.hintFont) - col(PAL.detail) - local ty = dy + 42 * s - for _, line in ipairs(c.lines) do - love.graphics.printf(line, dx + 16 * s, ty, dw - 32 * s, "left") - ty = ty + lineH - end - local btnH = 34 * s - local btnW = (dw - 48 * s) / 2 - local by = dy + dh - btnH - 14 * s - self._modConfirmYes = { x = dx + 16 * s, y = by, width = btnW, height = btnH } - self._modConfirmNo = { x = dx + dw - 16 * s - btnW, y = by, - width = btnW, height = btnH } - local yhot = self:_hover(self._modConfirmYes) - local nhot = self:_hover(self._modConfirmNo) - fillGradRounded(self._modConfirmYes.x, by, btnW, btnH, 8 * s, - PAL.playTop, PAL.playBot, yhot and 1 or 0.85, yhot and 1 or 0.85) - col(PAL.disabled, nhot and 0.55 or 0.35) - love.graphics.rectangle("fill", self._modConfirmNo.x, by, btnW, btnH, 8 * s, 8 * s) - love.graphics.setFont(self.saveBtnFont) - col(PAL.white) - printfB(c.yesLabel or "OK", self._modConfirmYes.x, - by + (btnH - self.saveBtnFont:getHeight()) / 2, btnW, "center") - col(PAL.detail) - printfB("Cancel", self._modConfirmNo.x, by + (btnH - self.saveBtnFont:getHeight()) / 2, - btnW, "center") - elseif self._modReleaseNotes then - local n = self._modReleaseNotes - local ModUpdate = require("src.mods.ModUpdate") - local dw = math.min(appW - 32 * s, 480 * s) - local dh = math.min(height - 48 * s, 360 * s) - local dx = appX + (appW - dw) / 2 - local dy = oy + (height - dh) / 2 - local rr = 12 * s - fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) - love.graphics.setLineWidth(math.max(1, 1.2 * s)) - col(PAL.green, 0.5) - love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) - love.graphics.setFont(self.slotNameFont) - col(PAL.white) - love.graphics.printf("v" .. tostring(n.version) .. " notes", - dx + 16 * s, dy + 12 * s, dw - 32 * s, "left") - local body = ModUpdate.cleanBody(n.body or "", 0) - if body == "" then body = "(No release notes.)" end - love.graphics.setFont(self.hintFont) - col(PAL.detail) - local textTop = dy + 44 * s - local textH = dh - 44 * s - 52 * s - love.graphics.setScissor(math.floor(dx + 16 * s), math.floor(textTop), - math.ceil(dw - 32 * s), math.ceil(textH)) - love.graphics.printf(body, dx + 16 * s, textTop - (n.scroll or 0), - dw - 32 * s, "left") - love.graphics.setScissor() - local closeW = self.hintFont:getWidth("Close") + 28 * s - local closeH = 30 * s - self._modReleaseNotesClose = { - x = dx + (dw - closeW) / 2, y = dy + dh - closeH - 12 * s, - width = closeW, height = closeH, - } - local chot = self:_hover(self._modReleaseNotesClose) - col(PAL.disabled, chot and 0.55 or 0.35) - love.graphics.rectangle("fill", self._modReleaseNotesClose.x, - self._modReleaseNotesClose.y, closeW, closeH, 8 * s, 8 * s) - col(PAL.detail) - printfB("Close", self._modReleaseNotesClose.x, - self._modReleaseNotesClose.y + (closeH - self.hintFont:getHeight()) / 2, - closeW, "center") - elseif self._findDetails then - -- The index's description markdown, stripped by the same cleanBody a - -- release changelog goes through. There is no markdown renderer in the - -- engine and a listing does not warrant one: the point is to read what the - -- author wrote before installing, not to reproduce their formatting. - local d = self._findDetails - local ModUpdate = require("src.mods.ModUpdate") - local dw = math.min(appW - 32 * s, 520 * s) - local dh = math.min(height - 48 * s, 420 * s) - local dx = appX + (appW - dw) / 2 - local dy = oy + (height - dh) / 2 - local rr = 12 * s - fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94) - love.graphics.setLineWidth(math.max(1, 1.2 * s)) - col(PAL.modDot, 0.5) - love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) - love.graphics.setFont(self.slotNameFont) - col(PAL.white) - love.graphics.printf(ellipsize(self.slotNameFont, d.title, dw - 32 * s), - dx + 16 * s, dy + 12 * s, dw - 32 * s, "left") - local body = ModUpdate.cleanBody(d.body or "", 0) - if body == "" then body = "(No description.)" end - love.graphics.setFont(self.hintFont) - col(PAL.detail) - local textTop = dy + 44 * s - local textH = dh - 44 * s - 52 * s - local _, lines = self.hintFont:getWrap(body, dw - 32 * s) - local bodyH = #lines * self.hintFont:getHeight() - d.max = math.max(0, bodyH - textH) - d.scroll = clamp(d.scroll or 0, 0, d.max) - love.graphics.setScissor(math.floor(dx + 16 * s), math.floor(textTop), - math.ceil(dw - 32 * s), math.ceil(textH)) - love.graphics.printf(body, dx + 16 * s, textTop - d.scroll, - dw - 32 * s, "left") - love.graphics.setScissor() - local closeW = self.hintFont:getWidth("Close") + 28 * s - local closeH = 30 * s - self._findDetailsClose = { - x = dx + (dw - closeW) / 2, y = dy + dh - closeH - 12 * s, - width = closeW, height = closeH, - } - local chot = self:_hover(self._findDetailsClose) - col(PAL.disabled, chot and 0.55 or 0.35) - love.graphics.rectangle("fill", self._findDetailsClose.x, - self._findDetailsClose.y, closeW, closeH, 8 * s, 8 * s) - col(PAL.detail) - printfB("Close", self._findDetailsClose.x, - self._findDetailsClose.y + (closeH - self.hintFont:getHeight()) / 2, - closeW, "center") - elseif self._modVersions then - local ModUpdate = require("src.mods.ModUpdate") - local v = self._modVersions - local dw = math.min(appW - 40 * s, 520 * s) - local pad = 16 * s - local headerH = 56 * s - local rowH = 52 * s - local footerH = 48 * s - local listN = math.min(6, math.max(0, #v.releases)) - local listH = math.max(rowH, listN * rowH) - local dh = headerH + listH + footerH - dh = math.min(dh, height - 40 * s) - -- recompute how many rows fit under the clamped dialog height - local fitN = math.max(1, math.floor((dh - headerH - footerH) / rowH)) - listN = math.min(listN, fitN) - listH = listN * rowH - dh = headerH + listH + footerH - local dx = appX + (appW - dw) / 2 - local dy = oy + (height - dh) / 2 - local rr = 12 * s - fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96) - love.graphics.setLineWidth(math.max(1, 1.2 * s)) - col(PAL.green, 0.5) - love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) - - love.graphics.setFont(self.slotNameFont) - col(PAL.white) - love.graphics.printf("Other versions: " .. tostring(v.name), - dx + pad, dy + 10 * s, dw - pad * 2, "left") - love.graphics.setFont(self.hintFont) - local info = self:_modUpdateInfo(v.id) - local statusTxt = "Installed: v" .. tostring(v.current) - local statusCol = PAL.detail - if info and info.status == "available" then - statusTxt = statusTxt .. " - Update v" .. tostring(info.latest) - statusCol = PAL.playTop - elseif info and info.status == "current" then - statusTxt = statusTxt .. " - Up to date" - statusCol = PAL.playTop - end - col(statusCol) - love.graphics.printf(statusTxt, dx + pad, dy + 34 * s, dw - pad * 2, "left") - - self._modVersionRects = {} - self._modVersionNotesRects = {} - local listTop = dy + headerH - local notesW = self.hintFont:getWidth("Read more") + 20 * s - local installW = self.hintFont:getWidth("Install") + 20 * s - local btnH = 26 * s - -- clip the list to the band above the footer so nothing can paint over Close - love.graphics.setScissor(math.floor(dx + 2 * s), math.floor(listTop), - math.ceil(dw - 4 * s), math.ceil(listH)) - for i = 1, listN do - local rel = v.releases[i] - local ly = listTop + (i - 1) * rowH - local rect = { x = dx + 12 * s, y = ly + 2 * s, width = dw - 24 * s, - height = rowH - 6 * s, release = rel } - col(PAL.bgBot, 0.45) - love.graphics.rectangle("fill", rect.x, rect.y, rect.width, rect.height, 8 * s, 8 * s) - love.graphics.setLineWidth(1) - col(PAL.cardBorder, 0.35) - love.graphics.rectangle("line", rect.x, rect.y, rect.width, rect.height, 8 * s, 8 * s) - - love.graphics.setFont(self.hintFont) - local label = "v" .. rel.version - if rel.version == v.current then label = label .. " (installed)" end - if rel.prerelease then label = label .. " pre" end - col(rel.version == v.current and PAL.warning or PAL.white) - love.graphics.print(label, rect.x + 12 * s, rect.y + 6 * s) - - -- one-line ellipsized preview only (never wrap changelog into the row) - local btnStackW = 0 - local hasNotes = type(rel.body) == "string" and rel.body:match("%S") - local canInstall = rel.version ~= v.current - if hasNotes then btnStackW = btnStackW + notesW end - if canInstall then btnStackW = btnStackW + (hasNotes and 8 * s or 0) + installW end - local previewW = math.max(24 * s, rect.width - 24 * s - btnStackW - 12 * s) - local preview = ModUpdate.previewLine(rel.body or "", 90) - if preview ~= "" then - col(PAL.detail) - love.graphics.print( - ellipsize(self.hintFont, preview, previewW), - rect.x + 12 * s, - rect.y + 6 * s + self.hintFont:getHeight() + 2 * s) - end - - local btnY = rect.y + (rect.height - btnH) / 2 - local bx = rect.x + rect.width - 10 * s - if canInstall then - bx = bx - installW - local irect = self:_chipButton(bx, btnY, "Install", { - w = installW, h = btnH, id = v.id, kind = "accent", - }) - irect.release = rel - self._modVersionRects[#self._modVersionRects + 1] = irect - bx = bx - 8 * s - end - if hasNotes then - bx = bx - notesW - local nrect = self:_chipButton(bx, btnY, "Read more", { - w = notesW, h = btnH, id = v.id, kind = "neutral", - }) - nrect.release = rel - self._modVersionNotesRects[#self._modVersionNotesRects + 1] = nrect - end - end - love.graphics.setScissor() - - -- opaque footer so list content can never bleed under Close - local footerY = dy + dh - footerH - col(PAL.slotBg, 1) - love.graphics.rectangle("fill", dx + 2 * s, footerY, dw - 4 * s, footerH - 2 * s) - local closeW = self.hintFont:getWidth("Close") + 32 * s - local closeH = 32 * s - self._modVersionsClose = { - x = dx + (dw - closeW) / 2, - y = footerY + (footerH - closeH) / 2 - 2 * s, - width = closeW, height = closeH, - } - local chot = self:_hover(self._modVersionsClose) - col(PAL.disabled, chot and 0.55 or 0.35) - love.graphics.rectangle("fill", self._modVersionsClose.x, - self._modVersionsClose.y, closeW, closeH, 8 * s, 8 * s) - love.graphics.setFont(self.hintFont) - col(PAL.detail) - printfB("Close", self._modVersionsClose.x, - self._modVersionsClose.y + (closeH - self.hintFont:getHeight()) / 2, - closeW, "center") - end - - -- pointer cursor over any interactive element (desktop only) - if self._hoverEnabled and not self._padCursorActive - and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then - if self._anyHover then - if not self.handCursor then - local ok, cursor = pcall(love.mouse.getSystemCursor, "hand") - if ok then self.handCursor = cursor end - end - if self.handCursor then love.mouse.setCursor(self.handCursor) end - else - resetPointerCursor(self) - end - end - - -- Gamepad virtual cursor (drawn last so it sits above the CRT overlay). - if self._padCursorActive then - local x, y = self._padCursor.x, self._padCursor.y - local hot = self._anyHover - love.graphics.push("all") - love.graphics.origin() - love.graphics.setLineWidth(1) - -- Drop shadow - love.graphics.setColor(0, 0, 0, 0.45) - love.graphics.polygon("fill", - x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26, - x + 18, y + 24, x + 11, y + 14, x + 20, y + 14) - -- Pointer body - if hot then - love.graphics.setColor(0.25, 0.95, 0.55, 1) - else - love.graphics.setColor(1, 1, 1, 1) - end - love.graphics.polygon("fill", - x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, - x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) - love.graphics.setColor(0.05, 0.07, 0.12, 1) - love.graphics.polygon("line", - x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, - x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) - love.graphics.pop() - end + require("src.import.LauncherView").draw(self) end -local function inside(r, x, y) - if not (r and x >= r.x and x <= r.x + r.width and y >= r.y and y <= r.y + r.height) then - return false - end - -- Page-scroll mode: only the header is pinned, so any other rect is a - -- scrolled one and is live only where the viewport actually shows it. - if pageBand and not r.pinned and (y < pageBand[1] or y > pageBand[2]) then - return false - end - return true -end - --- A Delete label is armed by one click and commits on a second one on the same --- target; it disarms on any other press and after this many seconds, because --- nothing in the launcher can undo a delete (#433). +-- Nothing in the launcher can undo a delete, so every Delete control asks +-- twice: the first press arms it, a second press on the SAME target inside +-- the window commits, and any other queued action disarms it (#433; the view +-- routes every non-delete action through a disarm). local DELETE_CONFIRM_SECONDS = 4 -local function armedDelete(a, kind, id, version) - return a ~= nil and a.kind == kind and a.id == id and a.version == version - and (love.timer.getTime() - a.t) <= DELETE_CONFIRM_SECONDS -end - -function RomImporter:mousepressed(x, y, button) - if self._rename then return end -- the rename modal swallows all clicks - -- The add-index prompt swallows clicks too, except its PASTE button: a - -- touch screen has no ctrl+V, so the button is the only paste path (#578). - if self._indexPrompt then - if button == 1 and inside(self._indexPasteRect, x, y) then - self:_pasteIndexUrl() - end - return - end - -- Mod confirm / versions / release-notes modals swallow clicks too. - if self._modConfirm then - if button ~= 1 then return end - if inside(self._modConfirmYes, x, y) then - local c = self._modConfirm - self._modConfirm = nil - -- An index install carries its whole entry: the confirm is the only - -- place the compatibility warnings were shown, so the install must not - -- be reachable by any other route. - if c.indexEntry then - self:_findInstall(c.indexEntry) - elseif c.kind == "update" then - self:_confirmModUpdate(c.id, c.release) - elseif c.kind == "enableAll" then - self:_setAllMods(true, true) - else - self:_toggleMod(c.id, true) - end - elseif inside(self._modConfirmNo, x, y) then - self._modConfirm = nil - end - return - end - if self._modReleaseNotes then - if button ~= 1 then return end - if inside(self._modReleaseNotesClose, x, y) then - self._modReleaseNotes = nil - end - return - end - if self._findDetails then - if button ~= 1 then return end - if inside(self._findDetailsClose, x, y) then - self._findDetails = nil - end - return - end - if self._modVersions then - if button ~= 1 then return end - if inside(self._modVersionsClose, x, y) then - self._modVersions = nil - return - end - for _, r in ipairs(self._modVersionNotesRects or {}) do - if inside(r, x, y) and r.release then - self._modReleaseNotes = { - version = r.release.version, - body = r.release.body or "", - scroll = 0, - } - return - end - end - for _, r in ipairs(self._modVersionRects or {}) do - if inside(r, x, y) and r.release then - self:_installModVersion(self._modVersions.id, r.release) - return - end - end - return - end - -- Whether a press can be ARMED and resolved on release, which needs a - -- pollable pointer: always on desktop, on Android only where love.touch is. - local armDrag = (not self.android) or self.touchPollable - -- right-click a save-slot row to rename it (#205); desktop only (touch - -- has no secondary button) - if button == 2 then - if not self.android and self.workState ~= "working" then - for _, r in ipairs(self.slotRects or {}) do - if inside(r, x, y) then - self:_beginRename(self.panelVersion, r.id) - return - end - end - end - return - end - if button ~= 1 then return end - -- Any press that is not the second click on an armed Delete disarms it, so - -- take the arm off self up front and let the Delete loops below re-arm. - local armed = self._confirmDelete +function RomImporter:pressDelete(kind, id, version, commit) + local a = self._confirmDelete self._confirmDelete = nil - if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then - love.system.openURL(COMMUNITY_URL) - return + if a ~= nil and a.kind == kind and a.id == id and a.version == version + and (love.timer.getTime() - a.t) <= DELETE_CONFIRM_SECONDS then + commit() + return true end - -- Self-updater banner (touch routes here through love.touchpressed too). - -- Kept ahead of the "working" guard so it stays live during a ROM import. - if inside(self.updateButton, x, y) then - local action = self.updateButton.action - if action == "download" and self.Check then - pcall(self.Check.download) - elseif action == "restart" then - HostShell.restart() - elseif action == "openurl" and self.Check then - love.system.openURL(self.Check.releaseUrl()) - end - return - end - -- Tab chips switch panels even mid-import so the player can look around - -- while a ROM extracts. - for _, t in ipairs(self.tabRects or {}) do - if inside(t, x, y) then - self.tab = t.id - self._slotPress = nil -- drop any half-started slot drag on tab change - self._modPress = nil -- and any half-started mod toggle press - self._pagePress = nil -- and any half-started page pan - self._findSearchFocus = false -- and the search caret, now off screen - self:_disarmTextInput() - -- Each tab is its own column of a different length; carrying one tab's - -- offset into another lands somewhere arbitrary. - self.pageScroll = 0 - return - end - end - if self.workState == "working" then return end - -- Active game panel's controls (only the shown version has live hit rects). - if inside(self.playButtonRect, x, y) then - self:play(self.panelVersion); return - end - if inside(self.romButtonRect, x, y) then - local version = self.panelVersion - if self.ready[version] then self:reimport(version) else self:choose(version) end - return - end - -- SAVE FILES card: Import save / Export save, and the open-folder affordance - -- shown on the notice line after a successful export. - if inside(self.saveImportRect, x, y) then - self:chooseSaveImport(self.panelVersion); return - end - if inside(self.saveExportRect, x, y) then - self:exportSave(self.panelVersion); return - end - if inside(self.saveFolderRect, x, y) then - if self.saveFolderRect.dir then - love.system.openURL(fileUrl(self.saveFolderRect.dir)) - end - return - end - if inside(self.touchControlsRect, x, y) then - if self.onEditTouchControls then self.onEditTouchControls() end - return - end - -- SAVE SLOT rows / Edit / Delete. The two labels are checked first so a tap - -- on either never also selects the row. A press only ARMS a row click: - -- _updateSlotDrag commits it on release when the pointer did not move (a - -- moved pointer scrolls instead). Android arms too wherever love.touch can - -- be polled; without that there is nothing to resolve a release with, so it - -- keeps selecting on press. Edit and Delete fire immediately (small fixed - -- targets, no scroll conflict). - for _, r in ipairs(self.slotDeleteRects or {}) do - if inside(r, x, y) then - if armedDelete(armed, "slot", r.id, self.panelVersion) then - self:_deleteSlot(self.panelVersion, r.id) - else - self._confirmDelete = { kind = "slot", id = r.id, - version = self.panelVersion, t = love.timer.getTime() } - end - return - end - end - for _, r in ipairs(self.slotEditRects or {}) do - if inside(r, x, y) then - if self.onEditSave then self.onEditSave(self.panelVersion, r.id) end - return - end - end - for _, r in ipairs(self.slotRects or {}) do - if inside(r, x, y) then - if not armDrag then - self:_selectSlot(self.panelVersion, r.id) - else - self._slotPress = { version = self.panelVersion, id = r.id, y0 = y, - scroll0 = self.slotScroll[self.panelVersion] or 0, - pageScroll0 = self.pageScroll or 0, moved = false } - end - return - end - end - if inside(self.newSlotRect, x, y) then - self:_newSlot(self.panelVersion); return - end - -- Mods panel: the import button dispatches on press (fixed header, no scroll - -- conflict); Delete fires immediately; a toggle switch, which lives in the - -- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from - -- a drag-scroll (Android, with no pointer polling, toggles on press). - if inside(self.modImportRect, x, y) then - self:chooseMod(); return - end - -- Enable all / Disable all sit in that same fixed header, so they dispatch on - -- press like the import button rather than arming a drag (#647). - if inside(self.modEnableAllRect, x, y) then - self:_setAllMods(true); return - end - if inside(self.modDisableAllRect, x, y) then - self:_setAllMods(false); return - end - for _, r in ipairs(self.modDeleteRects or {}) do - if inside(r, x, y) then - if armedDelete(armed, "mod", r.id, nil) then - self:_deleteMod(r.id) - else - self._confirmDelete = { kind = "mod", id = r.id, t = love.timer.getTime() } - end - return - end - end - for _, r in ipairs(self.modUpdateRects or {}) do - if inside(r, x, y) then - self:_modGithubAction(r.id, "update") - return - end - end - for _, r in ipairs(self.modVersionsRects or {}) do - if inside(r, x, y) then - self:_modGithubAction(r.id, "versions") - return - end - end - for _, r in ipairs(self.modRects or {}) do - if inside(r, x, y) then - if not armDrag then - self:_toggleMod(r.id) - else - self._modPress = { id = r.id, y0 = y, scroll0 = self.modScroll or 0, - pageScroll0 = self.pageScroll or 0, moved = false } - end - return - end - end - -- FIND MODS panel. Everything here dispatches on press: none of it is a - -- toggle that a drag-scroll could be mistaken for, and the search field wants - -- focus the instant it is touched. - if inside(self.findAddRect, x, y) then - self:_promptAddIndex(); return - end - if inside(self.findRefreshRect, x, y) then - self._findSearchFocus = false - self:_disarmTextInput() - self:_refreshFind(true) - return - end - if inside(self.findSearchRect, x, y) then - self._findSearchFocus = true - self:_armTextInput() - return - end - for _, r in ipairs(self.findSourceRemoveRects or {}) do - if inside(r, x, y) then self:_removeIndex(r.id); return end - end - for _, r in ipairs(self.findCatRects or {}) do - if inside(r, x, y) then - -- the "All" chip carries the empty id; every other chip toggles itself - -- off when it is already the filter, so a second tap is the way back - self.findCategory = (r.id ~= "" and self.findCategory ~= r.id) and r.id or nil - self.findScroll = 0 - return - end - end - for _, r in ipairs(self.findDetailRects or {}) do - if inside(r, x, y) and r.entry then self:_findShowDetails(r.entry); return end - end - for _, r in ipairs(self.findRepoRects or {}) do - if inside(r, x, y) and r.entry and r.entry.repo then - love.system.openURL(r.entry.repo) - return - end - end - for _, r in ipairs(self.findInstallRects or {}) do - if inside(r, x, y) and r.entry then self:_findConfirmInstall(r.entry); return end - end - -- A press anywhere else on the tab drops the search caret, so the field does - -- not silently keep eating keystrokes once the player has moved on. - if self.tab == "find" and self._findSearchFocus then - self._findSearchFocus = false - self:_disarmTextInput() - end - -- Nothing was hit. On a scrolling page that is a press on empty background, - -- which is the natural place to grab and pan from. - if armDrag and (self._pageMax or 0) > 0 then - self._pagePress = { y0 = y, scroll0 = self.pageScroll or 0 } + self._confirmDelete = { kind = kind, id = id, version = version, + t = love.timer.getTime() } + return false +end + +-- Pointer input is polled by the FlexLove view (mouse and touch alike), so +-- the host-forwarded press events are inert. The methods stay because +-- main.lua forwards to them unconditionally while the launcher is up. +function RomImporter:mousepressed() end +function RomImporter:touchpressed() end +function RomImporter:touchmoved() end +function RomImporter:touchreleased() end + +-- Switch the active tab (chips, shoulder buttons). The find search caret and +-- the soft keyboard drop with the panel they belonged to; each tab's scroll +-- offset persists inside the view's per-tab scroll container. +function RomImporter:_switchTab(id) + self.tab = id + self._findSearchFocus = false + self:_disarmTextInput() +end + +-- ------- settings gear (options.lua + enabled mods' option schemas) + +function RomImporter:_openSettings() + local ok, model = pcall(function() + return require("src.import.LauncherSettings").open() + end) + if ok and model then self._settings = model end +end + +function RomImporter:_closeSettings() + if self._settings then self._settings.save() end + self._settings = nil +end + +function RomImporter:_commitSettingsText() + local st = self._settingsText + self._settingsText = nil + self:_disarmTextInput() + if st and st.row.setText then + st.row.setText(st.text) + if self._settings then self._settings.save() end end end -function RomImporter:touchpressed(id, x, y) - if self._activeTouch ~= nil and self._activeTouch ~= id then - self._pagePress = nil - self._slotPress = nil - self._modPress = nil - self._activeTouch = nil - end - if self._activeTouch ~= nil then return end - self._activeTouch = id - self:mousepressed(x, y, 1, id) -end - -function RomImporter:touchmoved(id, _, y) - if self._activeTouch ~= id then return end - self:_updateDrag(true, y) -end - -function RomImporter:touchreleased(id, _, y) - if self._activeTouch == nil then return end - self:_updateDrag(false, y) - self._activeTouch = nil +-- The view's open-folder affordance needs the same file:// encoding the old +-- notice line used. +function RomImporter:fileUrl(path) + return fileUrl(path) end function RomImporter:keypressed(key) + if self._settingsText then + if key == "backspace" then + self._settingsText.text = utf8Back(self._settingsText.text) + elseif key == "return" or key == "kpenter" then + self:_commitSettingsText() + elseif key == "escape" then + self._settingsText = nil + self:_disarmTextInput() + end + return + end + if self._settings then + if key == "escape" then self:_closeSettings() end + return + end if self._rename then if key == "backspace" then self._rename.text = utf8Back(self._rename.text) @@ -3078,506 +1826,6 @@ function RomImporter:keypressed(key) end end end - --- ------- Redesign panel rendering (FirstRun.dc.html) ------------------------ --- These run inside draw(): they read the per-frame pointer through self:_hover --- and self._s, and set the hit rects mousepressed dispatches (self.tabRects, --- self.romButtonRect, self.playButtonRect, self.panelVersion). - -function RomImporter:_ptIn(r) - local mx, my = self._mx, self._my - if not (r and mx >= r.x and mx <= r.x + r.width and my >= r.y and my <= r.y + r.height) then - return false - end - -- Same clip the click path applies, so nothing glows outside the viewport. - if pageBand and not r.pinned and (my < pageBand[1] or my > pageBand[2]) then - return false - end - return true -end - -function RomImporter:_hover(r) - local hot = self._hoverEnabled and self:_ptIn(r) or false - if hot then self._anyHover = true end - return hot -end - --- A glassy white-on-dark button (ROM import + the disabled SAVE FILES pair). --- Returns its hit rect when live, or nil when disabled (inert). -function RomImporter:_glassyButton(x, y, w, h, label, font, enabled) - local s = self._s - local r = 10 * s - love.graphics.setFont(font) - if enabled == false then - col(PAL.disabled, 0.25) - love.graphics.rectangle("fill", x, y, w, h, r, r) - love.graphics.setLineWidth(1) - col(PAL.disabledInk, 0.3) - love.graphics.rectangle("line", x, y, w, h, r, r) - col(PAL.disabledInk) - printfB(label, x, y + (h - font:getHeight()) / 2, w, "center") - return nil - end - local rect = { x = x, y = y, width = w, height = h } - local hot = self:_hover(rect) - fillGradRounded(x, y, w, h, r, PAL.white, PAL.white, hot and 0.24 or 0.16, 0.04) - love.graphics.setLineWidth(1) - col(PAL.white, 0.18) - love.graphics.rectangle("line", x, y, w, h, r, r) - col(PAL.white) - printfB(label, x, y + (h - font:getHeight()) / 2, w, "center") - return rect -end - --- Compact pill button for row actions (Edit / Delete / Update / Versions). --- kind: "neutral" (default), "accent" (green), "danger" (red), "dangerArmed" --- (filled confirm). Returns the hit rect; opts.id is copied onto it. -function RomImporter:_chipButton(x, y, label, opts) - opts = opts or {} - local s = self._s - local font = opts.font or self.hintFont - local padX = opts.padX or (12 * s) - local h = opts.h or (font:getHeight() + 10 * s) - love.graphics.setFont(font) - local w = opts.w or (font:getWidth(label) + 2 * padX) - local r = opts.r or math.min(8 * s, h / 2) - local kind = opts.kind or "neutral" - local rect = { x = x, y = y, width = w, height = h, id = opts.id } - local hot = self:_hover(rect) - - if kind == "dangerArmed" then - fillGradRounded(x, y, w, h, r, PAL.chooseTop, PAL.chooseBot, - hot and 1 or 0.92, hot and 1 or 0.92) - col(PAL.white) - elseif kind == "danger" then - col(PAL.chooseTop, hot and 0.28 or 0.14) - love.graphics.rectangle("fill", x, y, w, h, r, r) - love.graphics.setLineWidth(math.max(1, s)) - col(PAL.chooseTop, hot and 0.95 or 0.7) - love.graphics.rectangle("line", x, y, w, h, r, r) - col(hot and PAL.white or PAL.chooseTop) - elseif kind == "accent" then - col(PAL.playTop, hot and 0.28 or 0.12) - love.graphics.rectangle("fill", x, y, w, h, r, r) - love.graphics.setLineWidth(math.max(1, s)) - col(PAL.playTop, hot and 0.95 or 0.65) - love.graphics.rectangle("line", x, y, w, h, r, r) - col(hot and PAL.white or PAL.playTop) - else - fillGradRounded(x, y, w, h, r, PAL.white, PAL.white, - hot and 0.22 or 0.12, 0.04) - love.graphics.setLineWidth(math.max(1, s)) - col(PAL.white, hot and 0.35 or 0.18) - love.graphics.rectangle("line", x, y, w, h, r, r) - col(PAL.white) - end - printfB(label, x, y + (h - font:getHeight()) / 2, w, "center") - return rect -end - --- The tall green Play button (ready) or a disabled placeholder. Sets --- self.playButtonRect. -function RomImporter:_playButton(x, y, w, h, gameName, ready, locked) - local s, pulse = self._s, self.pulse - local r = 12 * s - love.graphics.setFont(self.playFont) - if ready then - local rect = { x = x, y = y, width = w, height = h } - local hot = self:_hover(rect) - local g = 0.5 + 0.5 * math.sin(pulse * 2 * math.pi / 2.4) - neonGlow(x, y, w, h, r, PAL.playTop, (0.7 + 0.25 * g) * (hot and 1.6 or 1)) - fillGradRounded(x, y, w, h, r, PAL.playTop, PAL.playBot, 1, 1) - if hot then - love.graphics.setBlendMode("add") - love.graphics.setColor(1, 1, 1, 0.12) - love.graphics.rectangle("fill", x, y, w, h, r, r) - love.graphics.setBlendMode("alpha") - end - buttonShine(x, y, w, h, r, (pulse % 2.8) / 2.8) - local label = "Play " .. gameName - local tw = self.playFont:getWidth(label) - local tri = self.playFont:getHeight() * 0.55 - local groupW = tri + 12 * s + tw - local gx = x + (w - groupW) / 2 - local gy = y + h / 2 - col(PAL.playInk) - love.graphics.polygon("fill", gx, gy - tri / 2, gx, gy + tri / 2, gx + tri * 0.9, gy) - printB(label, gx + tri + 12 * s, y + (h - self.playFont:getHeight()) / 2) - self.playButtonRect = rect - else - col(PAL.disabled, 0.3) - love.graphics.rectangle("fill", x, y, w, h, r, r) - love.graphics.setLineWidth(1) - col(PAL.disabledInk, 0.3) - love.graphics.rectangle("line", x, y, w, h, r, r) - col(PAL.disabledInk) - local label = locked and "Coming soon" or Strings("Import a ROM to play") - printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center") - self.playButtonRect = nil - end -end - --- The R/B/Y/divider/MODS chip row. Only the active tab shows its label + --- underline; the rest are dimmed. Rebuilds self.tabRects (chip squares). -function RomImporter:_drawTabBar(x, y, w, h, chip) - local s, pulse = self._s, self.pulse - local tabs = { - { id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot, - under = PAL.red, label = Strings("RED"), ink = PAL.white }, - { id = "blue", letter = "B", top = PAL.chipBlueTop, bot = PAL.chipBlueBot, - under = PAL.blue, label = Strings("BLUE"), ink = PAL.white }, - { id = "yellow", letter = "Y", top = PAL.chipGoldTop, bot = PAL.chipGoldBot, - under = PAL.gold, label = Strings("YELLOW"), ink = PAL.chipInkGold }, - { id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot, - under = PAL.modDot, label = Strings("MODS") }, - -- Browsing a community index sits beside the installed list rather than - -- inside it: one answers "what do I have", the other "what is out there", - -- and the second is empty until the player adds an index of their own. - { id = "find", find = true, top = PAL.chipModTop, bot = PAL.chipModBot, - under = PAL.modDot, label = Strings("FIND MODS") }, - } - local gap = 10 * s - local r = 12 * s - local chipY = y + (h - chip) / 2 - 2 * s - local underY = y + h - 3 * s - local cursorX = x - for _, t in ipairs(tabs) do - if t.mods then - -- divider between the game chips and MODS - col(PAL.cardBorder, 0.25) - love.graphics.rectangle("fill", cursorX, y + (h - 34 * s) / 2, - math.max(1, 1 * s), 34 * s) - cursorX = cursorX + gap + 6 * s - end - local active = self.tab == t.id - -- chip body - fillGradRounded(cursorX, chipY, chip, chip, r, t.top, t.bot, 1, 1) - if t.mods then - local d = 5 * s - local gd = 3 * s - local grid = 3 * d + 2 * gd - local gx = cursorX + (chip - grid) / 2 - local gy = chipY + (chip - grid) / 2 - col(PAL.modDot) - for row = 0, 2 do - for c2 = 0, 2 do - love.graphics.rectangle("fill", gx + c2 * (d + gd), gy + row * (d + gd), d, d) - end - end - elseif t.find then - -- magnifier: a ring plus a handle running down-right out of it - local cr = chip * 0.20 - local ccx = cursorX + chip / 2 - cr * 0.35 - local ccy = chipY + chip / 2 - cr * 0.35 - col(PAL.modDot) - love.graphics.setLineWidth(math.max(1.5, 2 * s)) - love.graphics.circle("line", ccx, ccy, cr) - local d = cr * 0.72 - love.graphics.line(ccx + d, ccy + d, ccx + d + cr * 0.9, ccy + d + cr * 0.9) - love.graphics.setLineWidth(1) - else - love.graphics.setFont(self.chipFont) - col(t.ink) - printfB(t.letter, cursorX, chipY + (chip - self.chipFont:getHeight()) / 2, chip, "center") - end - if not active then - col(PAL.bgBot, 0.62) - love.graphics.rectangle("fill", cursorX, chipY, chip, chip, r, r) - end - -- pinned: the tab bar never scrolls, so it stays live above the viewport - self.tabRects[#self.tabRects + 1] = - { x = cursorX, y = chipY, width = chip, height = chip, id = t.id, pinned = true } - local segEnd = cursorX + chip - if active then - love.graphics.setFont(self.tabLabelFont) - col(PAL.white) - local labelX = cursorX + chip + gap - local lw = printSpaced(self.tabLabelFont, t.label, labelX, - y + (h - self.tabLabelFont:getHeight()) / 2, 2 * s) - segEnd = labelX + lw - neonGlow(cursorX, underY, segEnd - cursorX, 3 * s, 2 * s, t.under, 0.45) - col(t.under) - love.graphics.rectangle("fill", cursorX, underY, segEnd - cursorX, 3 * s) - end - cursorX = segEnd + gap - end - -- "N of 3 ready" (Red + Blue + Yellow once in GameVersion.ORDER) - local ready = 0 - for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end - love.graphics.setFont(self.readyFont) - local label = Strings("%d of 3 ready", ready) - local lw = self.readyFont:getWidth(label) - if x + w - lw > cursorX + 8 * s then - col(PAL.labelGray) - love.graphics.print(label, x + w - lw, y + h - self.readyFont:getHeight() - 6 * s) - end - love.graphics.setLineWidth(1) - col(PAL.cardBorder, 0.22) - love.graphics.line(x, y + h, x + w, y + h) -end - --- One version's game panel: header (name + status pill), then a responsive --- two-column grid (left: ROM + SAVE FILES cards + Play; right: SAVE SLOT). --- `paged`: the whole page is scrolling (see draw()), so nothing stretches to --- fill `h` -- Play sits right under the SAVE FILES card instead of being pinned --- to the column bottom, and the slot card takes its natural height. Returns --- the panel's natural height either way, which is what draw() measures the page --- against on the next frame. -function RomImporter:_drawGamePanel(version, x, y, w, h, paged) - local s, pulse = self._s, self.pulse - self.panelVersion = version - -- Defensive: only lock when the version is absent from GameVersion (never - -- solely because id == "yellow"). - local info = GameVersion.info(version) - local locked = info == nil - local gameName = info and (info.launcherName or info.displayName) - or tostring(version) - local ready = (not locked) and self.ready[version] or false - - -- header: name + status pill - love.graphics.setFont(self.gameNameFont) - col(PAL.white) - printB(gameName, x, y) - local nameW = self.gameNameFont:getWidth(gameName) - local pill - if ready then pill = { text = "GOOD TO GO", c = PAL.green } - elseif locked then pill = { text = "COMING SOON", c = PAL.disabledInk } - else pill = { text = "ROM REQUIRED", c = PAL.gold } end - love.graphics.setFont(self.pillFont) - local pw = self.pillFont:getWidth(pill.text) + 24 * s - local ph = self.pillFont:getHeight() + 8 * s - local px = x + nameW + 14 * s - local py = y + (self.gameNameFont:getHeight() - ph) / 2 - col(pill.c, 0.1) - love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2) - love.graphics.setLineWidth(1) - col(pill.c, 0.55) - love.graphics.rectangle("line", px, py, pw, ph, ph / 2, ph / 2) - col(pill.c) - printfB(pill.text, px, py + (ph - self.pillFont:getHeight()) / 2, pw, "center") - - local headerH = math.max(self.gameNameFont:getHeight(), ph) - local bodyTop = y + headerH + 14 * s - local bodyH = math.max(0, (y + h) - bodyTop) - - -- responsive grid: two columns when they comfortably fit, else stacked - local colGap = 18 * s - local twoCol = w >= (300 * s * 2 + colGap) - local colW = twoCol and (w - colGap) / 2 or w - local leftX = x - local rightX = twoCol and (x + colW + colGap) or x - - -- ROM card contents by state (rehomes the existing import flow) - local dropHint = self.android and "Copy the .gb/.gbc via USB." - or Strings("Or drop the .gb/.gbc file here.") - local accent = version == "yellow" and PAL.gold - or (version == "red" and PAL.red or PAL.blue) - local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress - if locked then - romState, romDetail = "Not supported yet", "Support for this game is on the way." - romBtnLabel, romBtnEnabled = "Import unavailable", false - else - local importing = self.importing == version - local erroring = self.workState == "error" and self.errorVersion == version - local notice = self.notice and self.notice.version == version and self.notice - if importing and (self.workState == "working" or self.workState == "complete") then - romState = self.status or "Importing" - romDetail = self.detail or "" - romProgress = self.progress or 0 - elseif ready then - romState = self.romName[version] or Strings("ROM imported") - romDetail = "Verified." - romBtnLabel, romBtnEnabled = "Re-import ROM", true - elseif erroring then - romState = "Import failed" - romDetail = self.detail or Strings("That ROM could not be imported.") - romBtnLabel, romBtnEnabled = "Import ROM", true - elseif notice then - romState = "No ROM imported" - romDetail = trim((notice.status or "") .. " " .. (notice.detail or "")) - romBtnLabel, romBtnEnabled = "Import ROM", true - elseif self.returning[version] then - romState = "Update required" - romDetail = "This build needs a few more things from your " - .. info.label .. " ROM. Re-import to continue." - romBtnLabel, romBtnEnabled = "Re-import ROM", true - else - romState = "No ROM imported" - romDetail = "The ROM is verified before any files are created. " .. dropHint - romBtnLabel, romBtnEnabled = "Import ROM", true - end - end - - -- card metrics - local pad = 16 * s - local innerW = colW - 2 * pad - local labelH = self.labelFont:getHeight() - love.graphics.setFont(self.stateFont) - local _, stl = self.stateFont:getWrap(romState, innerW) - local stateH = math.max(1, #stl) * self.stateFont:getHeight() - love.graphics.setFont(self.hintFont) - local _, dtl = self.hintFont:getWrap(romDetail, innerW) - local detailH = math.max(1, #dtl) * self.hintFont:getHeight() - local btnH = math.max(40 * s, self.saveBtnFont:getHeight() + 22 * s) - local romCardH = pad + labelH + 10 * s + stateH + 5 * s + detailH + 14 * s + btnH + pad - - -- SAVE FILES card: Import save is live once the ROM is imported (playable); - -- Export save is live only when the active slot actually holds a save. The - -- hint line doubles as the last import/export outcome (green ok / red error). - local sfImportEnabled, sfExportEnabled = false, false - if not locked then - self:_ensureSlots(version) - sfImportEnabled = ready and true or false - local activeId = self.activeSlot[version] - for _, sl in ipairs(self.slots[version] or {}) do - if sl.id == activeId and sl.exists then sfExportEnabled = true; break end - end - end - local sfNotice = (not locked) and self.saveNotice[version] or nil - local sfHintText, sfHintCol - if sfNotice then - sfHintText, sfHintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red) - elseif locked then - sfHintText, sfHintCol = "Not available yet.", PAL.warning - elseif self.android then - sfHintText, sfHintCol = - "Import or export a .sav with the system file picker.", PAL.warning - else - sfHintText, sfHintCol = - "Import a .sav to a new slot, or export the active slot.", PAL.warning - end - - local sfBtnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) - love.graphics.setFont(self.hintFont) - local _, sfHl = self.hintFont:getWrap(sfHintText, innerW) - local sfHintH = math.max(1, #sfHl) * self.hintFont:getHeight() - local sfFolderH = (sfNotice and sfNotice.dir) and (self.hintFont:getHeight() + 4 * s) or 0 - local saveFilesH = pad + labelH + 10 * s + sfBtnH + 6 * s + sfHintH + sfFolderH + pad - local playH = math.max(50 * s, self.playFont:getHeight() + 30 * s) - -- Touch Controls editor entry (layout + permanent disable). Drawn whenever - -- the host supplied onEditTouchControls; height reserved only then so a - -- scripted/headless importer without the callback stays compact. - local touchBtnH = self.onEditTouchControls - and math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) or 0 - local touchGap = self.onEditTouchControls and (12 * s) or 0 - - -- vertical placement of the left column - local romY = bodyTop - local saveFilesY = romY + romCardH + 12 * s - local leftNaturalH = romCardH + 12 * s + saveFilesH + touchGap + touchBtnH - + 12 * s + playH - local playY, touchY - if twoCol and not paged then - -- Play pinned to the column bottom; Touch Controls sits just above it - playY = bodyTop + bodyH - playH - touchY = playY - touchGap - touchBtnH - else - touchY = saveFilesY + saveFilesH + touchGap - playY = touchY + touchBtnH + 12 * s - end - - -- ROM card - roundedCard(leftX, romY, colW, romCardH, 16 * s) - local ix, iy = leftX + pad, romY + pad - love.graphics.setFont(self.labelFont) - col(PAL.labelGray) - printSpaced(self.labelFont, "ROM", ix, iy, 2 * s) - iy = iy + labelH + 10 * s - love.graphics.setFont(self.stateFont) - col(PAL.white) - printfB(romState, ix, iy, innerW, "left") - iy = iy + stateH + 5 * s - love.graphics.setFont(self.hintFont) - col(PAL.detail) - love.graphics.printf(romDetail, ix, iy, innerW, "left") - iy = iy + detailH + 14 * s - if romProgress ~= nil then - local barH = math.max(8, 10 * s) - local track = iy + (btnH - barH) / 2 - col(PAL.bgBot, 0.85) - love.graphics.rectangle("fill", ix, track, innerW, barH, barH / 2, barH / 2) - local pw2 = innerW * clamp(romProgress, 0, 1) - if pw2 > barH then - neonGlow(ix, track, pw2, barH, barH / 2, accent, 0.6) - col(accent) - love.graphics.rectangle("fill", ix, track, pw2, barH, barH / 2, barH / 2) - end - else - self.romButtonRect = - self:_glassyButton(ix, iy, innerW, btnH, romBtnLabel, self.saveBtnFont, romBtnEnabled) - end - - -- SAVE FILES card: Import save (new slot) + Export save (active slot), with an - -- outcome/hint line under them and an open-folder affordance after an export. - roundedCard(leftX, saveFilesY, colW, saveFilesH, 16 * s) - ix, iy = leftX + pad, saveFilesY + pad - love.graphics.setFont(self.labelFont) - col(PAL.labelGray) - printSpaced(self.labelFont, "SAVE FILES", ix, iy, 2 * s) - iy = iy + labelH + 10 * s - local bGap = 10 * s - local halfW = (innerW - bGap) / 2 - self.saveImportRect = - self:_glassyButton(ix, iy, halfW, sfBtnH, "Import save", self.saveBtnFont, sfImportEnabled) - self.saveExportRect = self:_glassyButton(ix + halfW + bGap, iy, halfW, sfBtnH, - "Export save", self.saveBtnFont, sfExportEnabled) - iy = iy + sfBtnH + 6 * s - love.graphics.setFont(self.hintFont) - col(sfHintCol) - love.graphics.printf(sfHintText, ix, iy, innerW, "left") - iy = iy + sfHintH - if sfNotice and sfNotice.dir then - iy = iy + 4 * s - love.graphics.setFont(self.hintFont) - local label = Strings("Open folder") - local lw = self.hintFont:getWidth(label) - local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(), - dir = sfNotice.dir } - local fhot = self:_hover(frect) - col(fhot and PAL.linkHover or PAL.link) - love.graphics.print(label, ix, iy) - love.graphics.setLineWidth(1) - love.graphics.line(ix, iy + self.hintFont:getHeight() - 1, ix + lw, - iy + self.hintFont:getHeight() - 1) - self.saveFolderRect = frect - end - - -- Touch Controls: open the drag-to-reposition / disable editor (#327). - if self.onEditTouchControls and touchBtnH > 0 then - self.touchControlsRect = self:_glassyButton( - leftX, touchY, colW, touchBtnH, "Touch Controls", self.saveBtnFont, true) - end - - -- Play button - self:_playButton(leftX, playY, colW, playH, gameName, ready, locked) - - -- SAVE SLOT card (right column, or stacked below Play when single-column). - -- Skip only when the version is absent from GameVersion (no save backend). - local slotNaturalH = 0 - if not locked then - if twoCol then - _, slotNaturalH = self:_drawSaveSlotPanel(version, rightX, bodyTop, colW, bodyH, paged) - else - local slotY = playY + playH + 12 * s - local slotH = math.max(160 * s, (bodyTop + bodyH) - slotY) - _, slotNaturalH = self:_drawSaveSlotPanel(version, leftX, slotY, colW, slotH, paged) - end - end - - -- Natural height: side by side the two columns overlap, stacked they add up. - -- Measured from the panel's own top (y), so draw() can compare it against the - -- viewport without knowing anything about the cards inside. - local bodyNaturalH - if twoCol then - bodyNaturalH = math.max(leftNaturalH, slotNaturalH) - elseif locked then - bodyNaturalH = leftNaturalH - else - bodyNaturalH = leftNaturalH + 12 * s + slotNaturalH - end - return (bodyTop - y) + bodyNaturalH -end - -- Reload a version's slot list + active id from SaveData (the source of truth). -- Cheap enough to call on any mutation; the per-frame draw only calls it lazily -- through _ensureSlots so a still list costs nothing after the first paint. @@ -3659,6 +1907,11 @@ function RomImporter:_commitRename() end function RomImporter:textinput(text) + if self._settingsText then + local st = self._settingsText + st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL) + return + end if self._indexPrompt then -- URLs never contain a literal space, and a pasted one usually arrives -- with a stray newline attached @@ -3699,337 +1952,12 @@ function RomImporter:_newSlot(version) self.slotScroll[version] = math.huge end --- Poll the pointer once per frame to drive drag-scroll + deferred click on the --- save-slot list. main.lua forwards neither move nor release events to the --- launcher, so a press only ARMS a click (see mousepressed) and this resolves --- it: a pointer that moved past the threshold scrolls; one that did not, on --- release, selects. Desktop only -- Android selects on press instead. --- Where the pointer is this frame and whether it is held, read by polling --- because no move event ever reaches the launcher: the mouse on desktop, the --- first active touch on Android. A nil y means "nothing to read" -- the --- release branches below do not need one. -function RomImporter:_pointerHold() - if not self.android then return love.mouse.isDown(1), self._my end - if not self.touchPollable then return false, nil end - local ok, list = pcall(love.touch.getTouches) - if not ok or type(list) ~= "table" or list[1] == nil then return false, nil end - local ok2, _, ty = pcall(love.touch.getPosition, list[1]) - if not ok2 or type(ty) ~= "number" then return false, nil end - return true, ty -end - -function RomImporter:_updateDrag(down, py) - py = py or self._my - local maxPage = self._pageMax or 0 - - -- A press on empty background pans the page while it overflows. Nothing is - -- armed by it, so there is no release action to resolve. - local pp = self._pagePress - if pp then - if down then - if maxPage > 0 then - self.pageScroll = clamp(pp.scroll0 - (py - pp.y0), 0, maxPage) - end - else - self._pagePress = nil - end - end - - local p = self._slotPress - if p then - if down then - local d = py - p.y0 - if math.abs(d) > 4 * (self._s or 1) then p.moved = true end - if p.moved then - -- Paged, the list has no scroll of its own: the drag pans the page, so - -- a swipe that starts on a slot row behaves like one starting beside it. - if maxPage > 0 then - self.pageScroll = clamp(p.pageScroll0 - d, 0, maxPage) - else - local maxS = (self._slotMax and self._slotMax[p.version]) or 0 - self.slotScroll[p.version] = clamp(p.scroll0 - d, 0, maxS) - end - end - else - if not p.moved then self:_selectSlot(p.version, p.id) end - self._slotPress = nil - end - end - -- The same click-vs-drag resolution for the mods list: a moved pointer scrolls - -- the list, a still one toggles the armed mod on release. - local mp = self._modPress - if mp then - if down then - local d = py - mp.y0 - if math.abs(d) > 4 * (self._s or 1) then mp.moved = true end - if mp.moved then - if maxPage > 0 then - self.pageScroll = clamp(mp.pageScroll0 - d, 0, maxPage) - else - self.modScroll = clamp(mp.scroll0 - d, 0, self._modMax or 0) - end - end - else - if not mp.moved then self:_toggleMod(mp.id) end - self._modPress = nil - end - end -end - -function RomImporter:_updateSlotDrag() - if self.ios or (self.android and not self.touchPollable) then return end - local down, py = self:_pointerHold() - self:_updateDrag(down, py) -end - --- Mouse wheel over a game tab scrolls its save-slot list (installed onto the --- global love.wheelmoved in new(); see the chain there). Clamped to the last --- content extent draw computed for that version. -function RomImporter:wheelmoved(_, dy) - local step = 48 * (self._s or 1) - -- An open modal owns the wheel: the page behind it is not what the player is - -- looking at, and a long description is the one thing here that needs it. - if self._findDetails then - self._findDetails.scroll = clamp( - (self._findDetails.scroll or 0) - dy * step, 0, self._findDetails.max or 0) - return - end - -- An overflowing page scrolls as a whole; the panels' own lists are flattened - -- in that mode, so there is never a second scroll region competing for this. - local maxPage = self._pageMax or 0 - if maxPage > 0 then - self.pageScroll = clamp((self.pageScroll or 0) - dy * step, 0, maxPage) - return - end - if self.tab == "mods" then - local maxS = self._modMax or 0 - if maxS <= 0 then return end - self.modScroll = clamp((self.modScroll or 0) - dy * step, 0, maxS) - return - end - if self.tab == "find" then - local maxS = self._findMax or 0 - if maxS <= 0 then return end - self.findScroll = clamp((self.findScroll or 0) - dy * step, 0, maxS) - return - end - local version = self.panelVersion - if not version or self.tab ~= version then return end - local maxS = (self._slotMax and self._slotMax[version]) or 0 - if maxS <= 0 then return end - self.slotScroll[version] = clamp((self.slotScroll[version] or 0) - dy * step, 0, maxS) -end - --- SAVE SLOT card: header ("SAVE SLOT" + "N slots"), a scrollable list of slot --- rows (name + meta, LOADED pill on the active one), and a dashed "+ New save --- slot" button pinned to the bottom. Empty registries show a dashed hint box. --- `paged` (the whole launcher page is scrolling, see draw()) drops the inner --- scroll region: the card grows to its natural height, every row is drawn, and --- the page's own scrollbar is the only one on screen. Returns the height the --- card actually took, which is what the caller measures the page against. -function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged) - local s = self._s - local pad = 16 * s - self:_ensureSlots(version) - local slots = self.slots[version] or {} - local active = self.activeSlot[version] - local n = #slots - - -- Row metrics up front: the natural height needs them, and the natural height - -- decides the card's height before anything is drawn. - local labelH = self.labelFont:getHeight() - local newBtnH = math.max(38 * s, self.saveBtnFont:getHeight() + 18 * s) - local nameH = self.slotNameFont:getHeight() - local metaH = self.labelFont:getHeight() - local rowPadV = 10 * s - local chipBtnH = self.hintFont:getHeight() + 10 * s - -- LOADED sits top-right; Edit/Delete sit bottom-right -- row must fit both - -- without stacking on the same y (chip buttons are taller than the old text). - local loadedH = self.warningFont:getHeight() + 6 * s - local rowH = math.max(rowPadV * 2 + nameH + 4 * s + metaH, - rowPadV + loadedH + 4 * s + chipBtnH + rowPadV) - local rowGap = 8 * s - local rr = 12 * s - local btnGap = 8 * s - -- an empty registry shows a fixed-height dashed hint box instead of rows - local totalH = (n > 0) and (n * rowH + (n - 1) * rowGap) or (96 * s) - local naturalH = pad + labelH + 12 * s + totalH + 10 * s + newBtnH + pad - if paged then h = naturalH end - - roundedCard(x, y, w, h, 16 * s) - - -- header: "SAVE SLOT" (left) + "N slots" / "1 slot" (right) - love.graphics.setFont(self.labelFont) - col(PAL.labelGray) - printSpaced(self.labelFont, "SAVE SLOT", x + pad, y + pad, 2 * s) - local countTxt = (n == 1) and "1 slot" or (n .. " slots") - local cw = self.labelFont:getWidth(countTxt) - love.graphics.print(countTxt, x + w - pad - cw, y + pad) - - local listTop = y + pad + labelH + 12 * s - - -- "+ New save slot" pinned to the card bottom; the list fills the gap above. - local newBtnY = y + h - pad - newBtnH - local listBottom = newBtnY - 10 * s - local listH = math.max(0, listBottom - listTop) - local rx, rw = x + pad, w - 2 * pad - - if n == 0 then - -- empty state: a dashed box with the centred hint - love.graphics.setLineWidth(math.max(1, 1 * s)) - col(PAL.cardBorder, 0.45) - dashedRoundRect(rx, listTop, rw, listH, 12 * s, 7 * s, 5 * s) - love.graphics.setFont(self.hintFont) - col(PAL.warning) - love.graphics.printf("No saves yet - start a new game or import one.", - rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2, - rw - 24 * s, "center") - self.slotRects = {} - self.slotDeleteRects = {} - self.slotEditRects = {} - elseif listH > 0 then - -- clamp scroll against the current content extent, and stash the max so the - -- wheel handler (which has no geometry) can clamp against the same value. - -- Paged, listH already equals totalH, so this is 0 and the wheel falls - -- through to the page scroll. - local maxScroll = math.max(0, totalH - listH) - self._slotMax = self._slotMax or {} - self._slotMax[version] = maxScroll - local scroll = clamp(self.slotScroll[version] or 0, 0, maxScroll) - self.slotScroll[version] = scroll - - self.slotRects = {} - self.slotDeleteRects = {} - self.slotEditRects = {} - -- Paged, the page viewport's scissor is already set and nothing here - -- overflows the card, so leave it alone rather than replace and clear it. - if not paged then - love.graphics.setScissor(math.floor(rx), math.floor(listTop), - math.ceil(rw), math.ceil(listH)) - end - for i, slot in ipairs(slots) do - local ry = listTop - scroll + (i - 1) * (rowH + rowGap) - if ry + rowH >= listTop and ry <= listBottom then - local selected = slot.id == active - if selected then neonGlow(rx, ry, rw, rowH, rr, PAL.green, 0.5) end - fillGradRounded(rx, ry, rw, rowH, rr, PAL.slotBg, PAL.slotBg, 0.6, 0.6) - love.graphics.setLineWidth(math.max(1, (selected and 1.5 or 1) * s)) - col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22) - love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr) - - -- Edit + Delete chip buttons (bottom-right row). Delete arms on the - -- first click and asks "Sure?" on the second; width stays on "Delete" - -- so the row never reflows (#433). - love.graphics.setFont(self.hintFont) - local darmed = armedDelete(self._confirmDelete, "slot", slot.id, version) - local delLabel = darmed and "Sure?" or "Delete" - local delW = self.hintFont:getWidth("Delete") + 24 * s - local delX = rx + rw - 12 * s - delW - local delY = ry + rowH - rowPadV - chipBtnH - local drect = self:_chipButton(delX, delY, delLabel, { - w = delW, h = chipBtnH, id = slot.id, - kind = darmed and "dangerArmed" or "danger", - }) - local rightReserve = delW + 18 * s - - -- Edit, immediately left of Delete: opens the bundled save editor on - -- this slot's file. Only when the host supplied onEditSave and the - -- slot actually holds a save. - local erect = nil - if self.onEditSave and slot.exists then - local edW = self.hintFont:getWidth("Edit") + 24 * s - local edX = delX - btnGap - edW - erect = self:_chipButton(edX, delY, "Edit", { - w = edW, h = chipBtnH, id = slot.id, kind = "accent", - }) - rightReserve = rightReserve + edW + btnGap + 6 * s - end - - -- LOADED pill top-right (above the button row, never over Delete) - local pillW = 0 - if selected then - love.graphics.setFont(self.warningFont) - local pText = "LOADED" - local pw = self.warningFont:getWidth(pText) + 14 * s - local ph = loadedH - local ppx = rx + rw - 12 * s - pw - local ppy = ry + rowPadV - col(PAL.green) - love.graphics.rectangle("fill", ppx, ppy, pw, ph, ph / 2, ph / 2) - col(PAL.playInk) - printfB(pText, ppx, ppy + (ph - self.warningFont:getHeight()) / 2, pw, "center") - pillW = pw + 10 * s - end - - love.graphics.setFont(self.slotNameFont) - col(PAL.white) - -- a custom label (#205) wins over the player name; both ellipsize - local name = slot.label or slot.name or Strings("NEW GAME") - printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)), - rx + 12 * s, ry + rowPadV) - - local metaTxt - if slot.exists and slot.meta then - metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, slot.meta.timeText or "0:00", - slot.meta.dexCount or 0) - else - metaTxt = "empty slot" - end - love.graphics.setFont(self.labelFont) - col(PAL.warning) - love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve), - rx + 12 * s, ry + rowPadV + nameH + 4 * s) - - -- clip the hit rect to the visible list band so a partly-scrolled row - -- is only clickable where it actually shows - local vy = math.max(ry, listTop) - local vy2 = math.min(ry + rowH, listBottom) - if vy2 > vy then - self.slotRects[#self.slotRects + 1] = - { x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id } - end - local dvy = math.max(drect.y, listTop) - local dvy2 = math.min(drect.y + drect.height, listBottom) - if dvy2 > dvy then - self.slotDeleteRects[#self.slotDeleteRects + 1] = - { x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id } - end - if erect then - local evy = math.max(erect.y, listTop) - local evy2 = math.min(erect.y + erect.height, listBottom) - if evy2 > evy then - self.slotEditRects[#self.slotEditRects + 1] = - { x = erect.x, y = evy, width = erect.width, height = evy2 - evy, - id = slot.id } - end - end - end - end - if not paged then love.graphics.setScissor() end - - -- thin scrollbar thumb when the list overflows - if maxScroll > 0 then - local trackH = listH - local thumbH = math.max(24 * s, trackH * (listH / totalH)) - local thumbY = listTop + (trackH - thumbH) * (scroll / maxScroll) - col(PAL.cardBorder, 0.35) - love.graphics.rectangle("fill", rx + rw - 3 * s, thumbY, 3 * s, thumbH, - 1.5 * s, 1.5 * s) - end - end - - -- "+ New save slot" (dashed, transparent) pinned to the bottom - local nrect = { x = rx, y = newBtnY, width = rw, height = newBtnH } - local nhot = self:_hover(nrect) - love.graphics.setLineWidth(math.max(1, 1.4 * s)) - col(PAL.cardBorder, nhot and 0.7 or 0.45) - dashedRoundRect(nrect.x, nrect.y, nrect.width, nrect.height, 10 * s, 6 * s, 5 * s) - love.graphics.setFont(self.saveBtnFont) - col(PAL.detail, nhot and 1 or 0.9) - printfB("+ New save slot", nrect.x, - nrect.y + (newBtnH - self.saveBtnFont:getHeight()) / 2, nrect.width, "center") - self.newSlotRect = nrect - return h, naturalH +-- Mouse wheel: forwarded into the FlexLove view (installed onto the global +-- love.wheelmoved in new(); see the chain there). Scroll containers and the +-- modal scrollers all resolve inside the toolkit. +function RomImporter:wheelmoved(dx, dy) + if not self._flex then return end + require("src.import.LauncherView").wheelmoved(self, dx, dy) end -- Reload the mods list from LauncherMods (the source of truth: it reads the @@ -4341,337 +2269,6 @@ function RomImporter:_installModVersion(modId, release) end end --- The status-chip label + colour for a mod row (deriveList's status verdict). -local function modStatusChip(status) - if status == "ok" then return "Ready", PAL.green end - if status == "conflict" then return "Conflict", PAL.red end - return "Incompatible", PAL.gold -- "warn": bad range or missing dependency -end - --- MODS panel. Header ("Mods" + "N of M enabled" + "Import mod .zip"), an --- install-result / drag-drop notice line, then a scrollable list of mod cards --- (name + badge chip + description, a status chip, and a toggle switch). An --- empty install shows a friendly dashed hint box. --- `paged` behaves as it does on the game panel: no inner scroll region, the --- card list is drawn whole, and the returned natural height is what draw() --- measures the page against. -function RomImporter:_drawModsPanel(x, y, w, h, paged) - local s = self._s - self:_ensureMods() - local mods = self.mods or {} - - -- header: "Mods" + "N of M enabled" (left) and "Import mod .zip" (right) - love.graphics.setFont(self.gameNameFont) - col(PAL.white) - printB("Mods", x, y) - local nameW = self.gameNameFont:getWidth("Mods") - local headerH = self.gameNameFont:getHeight() - - local enabledCount = 0 - for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end - love.graphics.setFont(self.hintFont) - col(PAL.warning) - local countText = Strings("%d of %d enabled", enabledCount, #mods) - local countX = x + nameW + 14 * s - love.graphics.print(countText, countX, - y + (headerH - self.hintFont:getHeight()) / 2) - - local btnLabel = "Import mod .zip" - local btnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) - local btnW = math.min(w * 0.5, self.saveBtnFont:getWidth(btnLabel) + 40 * s) - local btnX = x + w - btnW - local btnY = y + (headerH - btnH) / 2 - self.modImportRect = - self:_glassyButton(btnX, btnY, btnW, btnH, btnLabel, self.saveBtnFont, true) - - -- Enable all / Disable all (#647): bulk switches beside Import mod .zip, so - -- coming back from a cable-club session (which asks for a vanilla fingerprint, - -- src/link/Fingerprint.lua) costs one click instead of one switch per mod. - -- The header is a single row shared with the count, so the chips are dropped - -- rather than overlapped when the column is too narrow to hold them (a - -- phone-width layout); the per-mod switches below are always the full path. - self.modEnableAllRect, self.modDisableAllRect = nil, nil - if #mods > 0 then - local enaLabel, disLabel = Strings("Enable all"), Strings("Disable all") - love.graphics.setFont(self.hintFont) - local bulkH = self.hintFont:getHeight() + 10 * s - local bulkY = y + (headerH - bulkH) / 2 - local enaW = self.hintFont:getWidth(enaLabel) + 24 * s - local disW = self.hintFont:getWidth(disLabel) + 24 * s - local bulkGap = 8 * s - local room = btnX - (countX + self.hintFont:getWidth(countText) + bulkGap) - if room >= enaW + disW + 2 * bulkGap then - local disX = btnX - bulkGap - disW - local enaX = disX - bulkGap - enaW - self.modEnableAllRect = - self:_chipButton(enaX, bulkY, enaLabel, { w = enaW, h = bulkH }) - self.modDisableAllRect = - self:_chipButton(disX, bulkY, disLabel, { w = disW, h = bulkH }) - end - end - - local top = y + headerH + 14 * s - - -- notice line: the last install/delete result, else the platform hint - love.graphics.setFont(self.hintFont) - if self.modNotice then - col(self.modNotice.ok and PAL.green or PAL.red) - love.graphics.printf(self.modNotice.text, x, top, w, "left") - else - col(PAL.warning) - love.graphics.printf(self.android and "Or copy a mod .zip via USB." - or Strings("Or drop a mod .zip onto the window."), x, top, w, "left") - end - top = top + self.hintFont:getHeight() + 12 * s - - local listH = math.max(0, (y + h) - top) - - -- empty state: a dashed box with a centred hint - if #mods == 0 then - local boxH = paged and (120 * s) or math.min(listH, 120 * s) - love.graphics.setLineWidth(math.max(1, 1 * s)) - col(PAL.cardBorder, 0.45) - dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) - love.graphics.setFont(self.hintFont) - col(PAL.warning) - local emptyHint = self.android - and "No mods installed - tap Import mod .zip to add one." - or Strings("No mods installed - drop a mod .zip here to add one.") - love.graphics.printf(emptyHint, - x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") - self.modRects = {} - self.modDeleteRects = {} - self.modUpdateRects = {} - self.modVersionsRects = {} - self._modMax = 0 - return (top - y) + boxH - end - - -- card metrics: status + toggle top-right; action chip-buttons in one row - -- under the body (Update / Versions / Delete when github is set). - local padH, padV = 16 * s, 14 * s - local cardGap, cardR = 10 * s, 14 * s - local tw, th = 52 * s, 28 * s - local innerW = w - 2 * padH - local chipH = self.hintFont:getHeight() + 8 * s - local btnH = self.hintFont:getHeight() + 10 * s - local btnGap = 8 * s - - love.graphics.setFont(self.stateFont) - local nameH = self.stateFont:getHeight() - - -- pre-pass: per-card layout + total height, so scroll can clamp to content - local layout, total = {}, 0 - for i, m in ipairs(mods) do - local chipText = modStatusChip(m.status) - local chipW = self.hintFont:getWidth(chipText) + 20 * s - local delW = self.hintFont:getWidth("Delete") + 24 * s - local verW = self.hintFont:getWidth("Versions") + 24 * s - local hasGh = m.github and m.github ~= "" - local info = hasGh and self:_modUpdateInfo(m.id) or nil - local updLabel = "Check for updates" - local updateKind = "neutral" - -- checkLine: always on the mod row for github mods so the check result - -- is visible without relying on the top-of-panel notice. - local checkLine = nil - local checkLineColor = PAL.detail - if info and info.status == "available" then - updLabel = "Update" - updateKind = "accent" - checkLine = "Checked for updates - v" .. tostring(info.latest) .. " available" - checkLineColor = PAL.playTop - elseif info and info.status == "current" then - updLabel = "Check again" - checkLine = "Checked for updates - up to date" - checkLineColor = PAL.playTop - elseif info and info.status == "error" then - checkLine = "Checked for updates - failed" - checkLineColor = PAL.chooseTop - elseif hasGh then - checkLine = "Not checked for updates yet" - checkLineColor = PAL.warning - end - local updW = self.hintFont:getWidth(updLabel) + 24 * s - local btnRowW = delW - if hasGh then btnRowW = updW + btnGap + verW + btnGap + delW end - local clusterW = math.max(chipW, tw) - local leftW = math.max(40 * s, innerW - clusterW - 14 * s) - local descH = 0 - if m.description ~= "" then - love.graphics.setFont(self.hintFont) - local _, dl = self.hintFont:getWrap(m.description, leftW) - descH = math.max(1, #dl) * self.hintFont:getHeight() - end - local clusterH = chipH + 6 * s + th - local metaH = self.hintFont:getHeight() + 2 * s - if checkLine then - metaH = metaH + self.hintFont:getHeight() + 2 * s - end - if descH > 0 then - metaH = metaH + self.hintFont:getHeight() + 2 * s + descH - end - local bodyH = math.max(nameH + 4 * s + metaH, clusterH) - local cardH = padV * 2 + bodyH + 10 * s + btnH - layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW, - chipText = chipText, chipW = chipW, delW = delW, - updW = updW, verW = verW, hasGh = hasGh, clusterH = clusterH, - btnRowW = btnRowW, bodyH = bodyH, updLabel = updLabel, - updateKind = updateKind, checkLine = checkLine, - checkLineColor = checkLineColor } - total = total + cardH - end - total = total + (#mods - 1) * cardGap - - -- Paged, the list band is the list itself: nothing to clip, nothing to scroll - -- here, and the page's scrollbar covers the overflow. - if paged then listH = total end - local maxScroll = math.max(0, total - listH) - self._modMax = maxScroll - local scroll = clamp(self.modScroll or 0, 0, maxScroll) - self.modScroll = scroll - self.modRects = {} - self.modDeleteRects = {} - self.modUpdateRects = {} - self.modVersionsRects = {} - - if not paged then - love.graphics.setScissor(math.floor(x), math.floor(top), - math.ceil(w), math.ceil(listH)) - end - local cy = top - scroll - for i, m in ipairs(mods) do - local L = layout[i] - local cardH = L.h - if cy + cardH >= top and cy <= top + listH then - roundedCard(x, cy, w, cardH, cardR) - local nx = x + padH - local ny = cy + padV - - -- name (ellipsized to leave room for the badge chip) + badge chip - love.graphics.setFont(self.warningFont) - local badgeTW = self.warningFont:getWidth(m.badge) - local badgeW = badgeTW + 12 * s - local badgeH = self.warningFont:getHeight() + 6 * s - love.graphics.setFont(self.stateFont) - col(PAL.white) - local drawnName = ellipsize(self.stateFont, m.name, L.leftW - badgeW - 8 * s) - printB(drawnName, nx, ny) - local bxx = nx + self.stateFont:getWidth(drawnName) + 8 * s - local byy = ny + (nameH - badgeH) / 2 - love.graphics.setLineWidth(1) - col(PAL.cardBorder, 0.5) - love.graphics.rectangle("line", bxx, byy, badgeW, badgeH, 5 * s, 5 * s) - love.graphics.setFont(self.warningFont) - col(m.experimental and PAL.gold or PAL.warning) - love.graphics.print(m.badge, bxx + 6 * s, - byy + (badgeH - self.warningFont:getHeight()) / 2) - - -- version + check status line + description under the name - love.graphics.setFont(self.hintFont) - col(PAL.detail) - local metaY = ny + nameH + 4 * s - love.graphics.print("v" .. tostring(m.version or "?"), nx, metaY) - metaY = metaY + self.hintFont:getHeight() + 2 * s - if L.checkLine then - col(L.checkLineColor or PAL.detail) - love.graphics.print( - ellipsize(self.hintFont, L.checkLine, L.leftW), nx, metaY) - metaY = metaY + self.hintFont:getHeight() + 2 * s - end - if m.description ~= "" then - col(PAL.detail) - love.graphics.printf(m.description, nx, metaY, L.leftW, "left") - end - - -- right cluster: status chip + toggle (action buttons are a bottom row) - local clusterX = x + w - padH - L.clusterW - local clusterY = cy + padV - local _, chipColor = modStatusChip(m.status) - local chipX = clusterX + (L.clusterW - L.chipW) / 2 - col(chipColor, 0.1) - love.graphics.rectangle("fill", chipX, clusterY, L.chipW, chipH, chipH / 2, chipH / 2) - love.graphics.setLineWidth(1) - col(chipColor, 0.55) - love.graphics.rectangle("line", chipX, clusterY, L.chipW, chipH, chipH / 2, chipH / 2) - love.graphics.setFont(self.hintFont) - col(chipColor) - printfB(L.chipText, chipX, - clusterY + (chipH - self.hintFont:getHeight()) / 2, L.chipW, "center") - - -- toggle switch (ON = green gradient + glow, knob right; OFF = gray, left) - local tx = clusterX + (L.clusterW - tw) / 2 - local ty = clusterY + chipH + 6 * s - local rr = th / 2 - local trect = { x = tx - 6 * s, y = ty - 6 * s, - width = tw + 12 * s, height = th + 12 * s, id = m.id } - self:_hover(trect) - if m.enabled then - neonGlow(tx, ty, tw, th, rr, PAL.green, 0.45) - fillGradRounded(tx, ty, tw, th, rr, PAL.playTop, PAL.playBot, 1, 1) - else - col(PAL.disabled, 0.35) - love.graphics.rectangle("fill", tx, ty, tw, th, rr, rr) - end - local kd = th - 6 * s - local kcx = m.enabled and (tx + tw - 3 * s - kd / 2) or (tx + 3 * s + kd / 2) - col(PAL.white) - love.graphics.circle("fill", kcx, ty + th / 2, kd / 2) - - -- Action chip-buttons in one right-aligned row under the body - local btnY = cy + cardH - padV - btnH - local btnX = x + w - padH - L.btnRowW - local darmed = armedDelete(self._confirmDelete, "mod", m.id, nil) - local function clipHit(rect, bucket) - if not rect then return end - local vy = math.max(rect.y, top) - local vy2 = math.min(rect.y + rect.height, top + listH) - if vy2 > vy then - bucket[#bucket + 1] = { - x = rect.x, y = vy, width = rect.width, height = vy2 - vy, - id = rect.id, - } - end - end - if L.hasGh then - local urect = self:_chipButton(btnX, btnY, L.updLabel, { - w = L.updW, h = btnH, id = m.id, kind = L.updateKind or "neutral", - }) - clipHit(urect, self.modUpdateRects) - btnX = btnX + L.updW + btnGap - local vrect = self:_chipButton(btnX, btnY, "Versions", { - w = L.verW, h = btnH, id = m.id, kind = "neutral", - }) - clipHit(vrect, self.modVersionsRects) - btnX = btnX + L.verW + btnGap - end - local drect = self:_chipButton(btnX, btnY, darmed and "Sure?" or "Delete", { - w = L.delW, h = btnH, id = m.id, - kind = darmed and "dangerArmed" or "danger", - }) - clipHit(drect, self.modDeleteRects) - - -- toggle hit rect clipped to the visible list band - local vy = math.max(trect.y, top) - local vy2 = math.min(trect.y + trect.height, top + listH) - if vy2 > vy then - self.modRects[#self.modRects + 1] = - { x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id } - end - end - cy = cy + cardH + cardGap - end - if not paged then love.graphics.setScissor() end - - -- thin scrollbar thumb when the list overflows - if maxScroll > 0 then - local thumbH = math.max(24 * s, listH * (listH / total)) - local thumbY = top + (listH - thumbH) * (scroll / maxScroll) - col(PAL.cardBorder, 0.35) - love.graphics.rectangle("fill", x + w - 3 * s, thumbY, 3 * s, thumbH, 1.5 * s, 1.5 * s) - end - return (top - y) + total -end - -- ------- FIND MODS: browsing a community mod index ------------------------- -- -- The index is metadata only (src/mods/ModIndex.lua): it says where a mod's @@ -4915,369 +2512,4 @@ function RomImporter:_findInstall(entry) end end --- The label + colour for an entry's install state, given what is installed. -local function findActionFor(entry, installedVersion) - local ModIndex = require("src.mods.ModIndex") - if not ModIndex.canInstall(entry) then - return nil, "Not installable from this index" - end - if not installedVersion then return "Install", nil end - local listed = ModIndex.displayVersion(entry) - local ModUpdate = require("src.mods.ModUpdate") - if type(installedVersion) == "string" - and ModUpdate.isNewer(installedVersion, listed) then - return "Update", "Installed v" .. installedVersion - end - return "Reinstall", "Installed v" .. tostring(installedVersion) -end - --- FIND MODS panel. Header ("Find Mods" + count + Refresh / Add an index), --- notice line, the source list, a search field and category chips, then the --- listing. With no index added at all it collapses to a single dashed prompt. --- `paged` behaves as everywhere else: no inner scroll region, the list is drawn --- whole, and the returned natural height is what draw() measures the page on. -function RomImporter:_drawFindPanel(x, y, w, h, paged) - local s = self._s - self._findThumbFetched = false - self:_ensureFind() - self:_ensureMods() - local ModIndex = require("src.mods.ModIndex") - local sources = self.findSources or {} - local rows = self:_findRows() - local total = #((self.findIndex and self.findIndex.mods) or {}) - - self.findCatRects = {} - self.findInstallRects = {} - self.findDetailRects = {} - self.findRepoRects = {} - self.findSourceRemoveRects = {} - - -- header - love.graphics.setFont(self.gameNameFont) - col(PAL.white) - printB("Find Mods", x, y) - local nameW = self.gameNameFont:getWidth("Find Mods") - local headerH = self.gameNameFont:getHeight() - if #sources > 0 then - love.graphics.setFont(self.hintFont) - col(PAL.warning) - local countLabel = (#rows == total) - and Strings("%d mods listed", total) - or Strings("%d of %d mods", #rows, total) - love.graphics.print(countLabel, x + nameW + 14 * s, - y + (headerH - self.hintFont:getHeight()) / 2) - end - - local btnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) - local btnY = y + (headerH - btnH) / 2 - local addLabel = (#sources == 0) and "Add an index" or "Add index" - local addW = math.min(w * 0.45, self.saveBtnFont:getWidth(addLabel) + 40 * s) - local addX = x + w - addW - self.findAddRect = - self:_glassyButton(addX, btnY, addW, btnH, addLabel, self.saveBtnFont, true) - if #sources > 0 then - local refLabel = "Refresh" - local refW = math.min(w * 0.3, self.saveBtnFont:getWidth(refLabel) + 36 * s) - self.findRefreshRect = self:_glassyButton(addX - refW - 8 * s, btnY, - refW, btnH, refLabel, self.saveBtnFont, true) - end - - local top = y + headerH + 14 * s - - -- notice line: the last add / refresh / install result, else the standing - -- reminder that a listing is not a review - love.graphics.setFont(self.hintFont) - if self.findNotice then - col(self.findNotice.ok and PAL.green or PAL.red) - love.graphics.printf(self.findNotice.text, x, top, w, "left") - else - col(PAL.warning) - love.graphics.printf( - Strings("Mods here are listed, not reviewed - read the source and trust the author."), - x, top, w, "left") - end - top = top + self.hintFont:getHeight() + 12 * s - - -- no index: one dashed prompt and nothing else. This is the whole tab until - -- the player names a feed. - if #sources == 0 then - local boxH = 150 * s - love.graphics.setLineWidth(math.max(1, 1 * s)) - col(PAL.cardBorder, 0.45) - dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) - love.graphics.setFont(self.stateFont) - col(PAL.heading) - printfB(Strings("No mod index added"), x + 16 * s, top + boxH / 2 - - self.stateFont:getHeight(), w - 32 * s, "center") - love.graphics.setFont(self.hintFont) - col(PAL.warning) - love.graphics.printf( - Strings("Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."), - x + 24 * s, top + boxH / 2 + 4 * s, w - 48 * s, "center") - self._findMax = 0 - return (top - y) + boxH - end - - -- source rows: which indexes are feeding this list, each with a Remove - local srcH = self.hintFont:getHeight() + 10 * s - for _, source in ipairs(sources) do - love.graphics.setFont(self.hintFont) - col(PAL.detail) - local remW = self.hintFont:getWidth("Remove") + 20 * s - love.graphics.print( - ellipsize(self.hintFont, source.label or source.feed, w - remW - 20 * s), - x + 2 * s, top + 5 * s) - local rrect = self:_chipButton(x + w - remW, top, "Remove", { - w = remW, h = srcH, id = source.feed, kind = "danger", - }) - self.findSourceRemoveRects[#self.findSourceRemoveRects + 1] = rrect - top = top + srcH + 4 * s - end - top = top + 6 * s - - -- search field: click to focus, type to filter. Not a modal -- the results - -- have to move while the player types or the field is guesswork. - local fieldH = 30 * s - local focused = self._findSearchFocus == true - col(PAL.bgBot, 0.9) - love.graphics.rectangle("fill", x, top, w, fieldH, 8 * s, 8 * s) - love.graphics.setLineWidth(math.max(1, s)) - col(focused and PAL.green or PAL.cardBorder, focused and 0.7 or 0.45) - love.graphics.rectangle("line", x, top, w, fieldH, 8 * s, 8 * s) - love.graphics.setFont(self.detailFont) - local query = self.findQuery or "" - if query == "" and not focused then - col(PAL.disabledInk) - love.graphics.print(Strings("Search mods"), x + 10 * s, - top + (fieldH - self.detailFont:getHeight()) / 2) - else - col(PAL.heading) - local shown = ellipsize(self.detailFont, query, w - 20 * s) - love.graphics.print(shown, x + 10 * s, - top + (fieldH - self.detailFont:getHeight()) / 2) - if focused and (self.pulse * 2 % 1) < 0.5 then - col(PAL.green) - love.graphics.rectangle("fill", - x + 10 * s + self.detailFont:getWidth(shown) + 2 * s, - top + 6 * s, math.max(1, 1.5 * s), fieldH - 12 * s) - end - end - self.findSearchRect = { x = x, y = top, width = w, height = fieldH } - self:_hover(self.findSearchRect) - top = top + fieldH + 10 * s - - -- category chips: "All" plus whatever the feeds actually use - local cats = (self.findIndex and self.findIndex.categories) or {} - if #cats > 0 then - local chipH = self.hintFont:getHeight() + 8 * s - local cx, cy = x, top - local function catChip(label, id, active) - local cw = self.hintFont:getWidth(label) + 20 * s - if cx + cw > x + w and cx > x then - cx = x - cy = cy + chipH + 6 * s - end - local rect = { x = cx, y = cy, width = cw, height = chipH, id = id } - local hot = self:_hover(rect) - col(active and PAL.green or PAL.cardBorder, active and 0.18 or 0.10) - love.graphics.rectangle("fill", cx, cy, cw, chipH, chipH / 2, chipH / 2) - love.graphics.setLineWidth(1) - col(active and PAL.green or PAL.cardBorder, active and 0.6 or 0.35) - love.graphics.rectangle("line", cx, cy, cw, chipH, chipH / 2, chipH / 2) - love.graphics.setFont(self.hintFont) - col(active and PAL.green or (hot and PAL.heading or PAL.detail)) - printfB(label, cx, cy + (chipH - self.hintFont:getHeight()) / 2, cw, "center") - self.findCatRects[#self.findCatRects + 1] = rect - cx = cx + cw + 6 * s - end - catChip("All", "", self.findCategory == nil) - for _, c in ipairs(cats) do - catChip(c, c, self.findCategory == c) - end - top = cy + chipH + 12 * s - end - - local listH = math.max(0, (y + h) - top) - - if #rows == 0 then - local boxH = paged and (110 * s) or math.min(listH, 110 * s) - love.graphics.setLineWidth(math.max(1, 1 * s)) - col(PAL.cardBorder, 0.45) - dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) - love.graphics.setFont(self.hintFont) - col(PAL.warning) - local hint = (total == 0) - and Strings("This index lists no mods yet.") - or Strings("No mods match that search.") - love.graphics.printf(hint, x + 16 * s, - top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") - self._findMax = 0 - return (top - y) + boxH - end - - -- card metrics. The thumbnail column is fixed whether or not a given entry - -- has one, so rows stay aligned down the list. - local padH, padV = 16 * s, 14 * s - local cardGap, cardR = 10 * s, 14 * s - local thumbW = 64 * s - local innerW = w - 2 * padH - local chipH = self.hintFont:getHeight() + 8 * s - local rowBtnH = self.hintFont:getHeight() + 10 * s - local btnGap = 8 * s - local installed = self:_findInstalledMap() - - love.graphics.setFont(self.stateFont) - local nameH = self.stateFont:getHeight() - - local layout, totalH = {}, 0 - for i, entry in ipairs(rows) do - local action, note = findActionFor(entry, installed[entry.id]) - local detW = self.hintFont:getWidth("Details") + 24 * s - local repoW = entry.repo and (self.hintFont:getWidth("Source") + 24 * s) or 0 - local actW = action and (self.hintFont:getWidth(action) + 24 * s) or 0 - local btnRowW = detW - if repoW > 0 then btnRowW = btnRowW + btnGap + repoW end - if actW > 0 then btnRowW = btnRowW + btnGap + actW end - local leftX = thumbW + 12 * s - local textW = math.max(60 * s, innerW - leftX) - local summaryH = 0 - if entry.summary ~= "" then - local _, sl = self.hintFont:getWrap(entry.summary, textW) - summaryH = math.max(1, #sl) * self.hintFont:getHeight() - end - local metaH = self.hintFont:getHeight() + 2 * s -- version + author - if note then metaH = metaH + self.hintFont:getHeight() + 2 * s end - if summaryH > 0 then metaH = metaH + 2 * s + summaryH end - local bodyH = math.max(nameH + 4 * s + metaH, thumbW) - local cardH = padV * 2 + bodyH + 10 * s + rowBtnH - layout[i] = { h = cardH, textW = textW, leftX = leftX, action = action, - note = note, detW = detW, repoW = repoW, actW = actW, - btnRowW = btnRowW, summaryH = summaryH } - totalH = totalH + cardH - end - totalH = totalH + (#rows - 1) * cardGap - - if paged then listH = totalH end - local maxScroll = math.max(0, totalH - listH) - self._findMax = maxScroll - local scroll = clamp(self.findScroll or 0, 0, maxScroll) - self.findScroll = scroll - - if not paged then - love.graphics.setScissor(math.floor(x), math.floor(top), - math.ceil(w), math.ceil(listH)) - end - local cy = top - scroll - for i, entry in ipairs(rows) do - local L = layout[i] - local cardH = L.h - if cy + cardH >= top and cy <= top + listH then - roundedCard(x, cy, w, cardH, cardR) - local nx = x + padH - local ny = cy + padV - - -- thumbnail, or a placeholder tile so the text column never shifts - local image = self:_findThumb(entry) - if image then - local iw, ih = image:getDimensions() - local fit = math.min(thumbW / iw, thumbW / ih) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(image, nx + (thumbW - iw * fit) / 2, - ny + (thumbW - ih * fit) / 2, 0, fit, fit) - else - col(PAL.cardBorder, 0.18) - love.graphics.rectangle("fill", nx, ny, thumbW, thumbW, 8 * s, 8 * s) - love.graphics.setFont(self.warningFont) - col(PAL.disabledInk) - printfB("MOD", nx, ny + (thumbW - self.warningFont:getHeight()) / 2, - thumbW, "center") - end - - local tx = nx + L.leftX - love.graphics.setFont(self.stateFont) - col(PAL.white) - printB(ellipsize(self.stateFont, entry.title or entry.id, L.textW), tx, ny) - - love.graphics.setFont(self.hintFont) - col(PAL.detail) - local metaY = ny + nameH + 4 * s - local meta = "v" .. tostring(ModIndex.displayVersion(entry)) - if entry.author then meta = meta .. " - " .. entry.author end - if entry.categories[1] then meta = meta .. " - " .. entry.categories[1] end - love.graphics.print(ellipsize(self.hintFont, meta, L.textW), tx, metaY) - metaY = metaY + self.hintFont:getHeight() + 2 * s - if L.note then - col(PAL.playTop) - love.graphics.print(ellipsize(self.hintFont, L.note, L.textW), tx, metaY) - metaY = metaY + self.hintFont:getHeight() + 2 * s - end - if L.summaryH > 0 then - col(PAL.detail) - love.graphics.printf(entry.summary, tx, metaY + 2 * s, L.textW, "left") - end - - -- An entry the index could not resolve a download for still shows: a - -- broken upstream is worth seeing, and hiding it reads as "no such mod". - if not L.action then - local warnChipW = self.hintFont:getWidth("Unavailable") + 20 * s - local wx = x + w - padH - warnChipW - col(PAL.gold, 0.1) - love.graphics.rectangle("fill", wx, cy + padV, warnChipW, chipH, - chipH / 2, chipH / 2) - love.graphics.setLineWidth(1) - col(PAL.gold, 0.55) - love.graphics.rectangle("line", wx, cy + padV, warnChipW, chipH, - chipH / 2, chipH / 2) - love.graphics.setFont(self.hintFont) - col(PAL.gold) - printfB("Unavailable", wx, - cy + padV + (chipH - self.hintFont:getHeight()) / 2, - warnChipW, "center") - end - - -- action row, clipped to the visible band exactly like the mods panel - local by = cy + cardH - padV - rowBtnH - local bx = x + w - padH - L.btnRowW - local function clipHit(rect, bucket) - if not rect then return end - local vy = math.max(rect.y, top) - local vy2 = math.min(rect.y + rect.height, top + listH) - if vy2 > vy then - bucket[#bucket + 1] = { x = rect.x, y = vy, width = rect.width, - height = vy2 - vy, id = rect.id, entry = entry } - end - end - local drect = self:_chipButton(bx, by, "Details", { - w = L.detW, h = rowBtnH, id = entry.id, kind = "neutral", - }) - clipHit(drect, self.findDetailRects) - bx = bx + L.detW + btnGap - if L.repoW > 0 then - local rrect = self:_chipButton(bx, by, "Source", { - w = L.repoW, h = rowBtnH, id = entry.id, kind = "neutral", - }) - clipHit(rrect, self.findRepoRects) - bx = bx + L.repoW + btnGap - end - if L.action then - local arect = self:_chipButton(bx, by, L.action, { - w = L.actW, h = rowBtnH, id = entry.id, kind = "accent", - }) - clipHit(arect, self.findInstallRects) - end - end - cy = cy + cardH + cardGap - end - if not paged then love.graphics.setScissor() end - - if maxScroll > 0 then - local thumbH = math.max(24 * s, listH * (listH / totalH)) - local thumbY = top + (listH - thumbH) * (scroll / maxScroll) - col(PAL.cardBorder, 0.35) - love.graphics.rectangle("fill", x + w - 3 * s, thumbY, 3 * s, thumbH, - 1.5 * s, 1.5 * s) - end - return (top - y) + totalH -end - return RomImporter diff --git a/tests/engine/launcher_delete_confirm.lua b/tests/engine/launcher_delete_confirm.lua index dc60dc84..7c1e31ea 100644 --- a/tests/engine/launcher_delete_confirm.lua +++ b/tests/engine/launcher_delete_confirm.lua @@ -1,7 +1,10 @@ --- Launcher Delete affordance (src/import/RomImporter.lua): the per-frame hit --- rects a draw() clears, and the two-click arm that guards both save-slot and --- mod deletes (#433). Drives RomImporter:mousepressed / :_resetFrameRects on a --- bare instance, so no window, no cache and no real save files are involved. +-- Launcher Delete affordance (src/import/RomImporter.lua): the two-click arm +-- that guards both save-slot and mod deletes (#433). Every Delete control in +-- the FlexLove view routes through RomImporter:pressDelete, and every other +-- queued action clears self._confirmDelete (LauncherView's queueAction), so +-- the guarantees live on this seam: the first press only arms, the second +-- press on the SAME target commits, any other target or a cleared arm asks +-- again, and a stale arm expires instead of committing much later. -- luajit tests/engine/launcher_delete_confirm.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -10,125 +13,88 @@ local T = require("tests.harness") local check, eq = T.check, T.eq love = love or require("tests.love_stub") --- mousepressed timestamps the arm and expires it, so the clock has to move +-- pressDelete timestamps the arm and expires it, so the clock has to move local clock = 1000 love.timer.getTime = function() return clock end local RomImporter = require("src.import.RomImporter") -local function rect(id, y) - return { x = 100, y = y or 200, width = 40, height = 14, id = id } -end - --- Only the fields mousepressed reads on its way to the Delete loops, plus --- recorders in place of the two destructive calls. local function launcher() local self = setmetatable({}, RomImporter) - self.android = false - self.panelVersion = "red" - self.tab = "red" - self.slotScroll = {} - self.deletedSlots = {} - self.deletedMods = {} - self.selected = {} - self._deleteSlot = function(_, version, id) - table.insert(self.deletedSlots, version .. "/" .. id) - end - self._deleteMod = function(_, id) table.insert(self.deletedMods, id) end - self._selectSlot = function(_, version, id) - table.insert(self.selected, version .. "/" .. id) - end + self.deleted = {} return self end -local function clickDelete(self, r) - self:mousepressed(r.x + 2, r.y + 2, 1) -end - --- ------- a frame that draws no panel leaves no Delete rect behind - -do - local self = launcher() - self.slotDeleteRects = { rect("slot1") } - self.modDeleteRects = { rect("bigmod", 260) } - self.slotRects = { rect("slot1") } - self.modRects = { rect("bigmod", 260) } - self:_resetFrameRects() - eq(self.slotDeleteRects, nil, "a frame reset drops the save Delete rects") - eq(self.modDeleteRects, nil, "a frame reset drops the mod Delete rects") - eq(self.slotRects, nil, "and the slot rows they sit on") - eq(self.modRects, nil, "and the mod toggles") - - -- the reporter's click: mods tab is up, the press lands where the game tab - -- drew Delete last time it was shown - self.tab = "mods" - clickDelete(self, rect("slot1")) - eq(#self.deletedSlots, 0, "a press on a stale Delete spot deletes nothing") +local function press(self, kind, id, version) + return self:pressDelete(kind, id, version, function() + table.insert(self.deleted, tostring(kind) .. "/" .. tostring(version) + .. "/" .. tostring(id)) + end) end -- ------- a save Delete needs two clicks on the same row do local self = launcher() - local r = rect("slot1") - self.slotDeleteRects = { r } - clickDelete(self, r) - eq(#self.deletedSlots, 0, "the first click on Delete does not delete") + eq(press(self, "slot", "slot1", "red"), false, + "the first click on Delete does not delete") + eq(#self.deleted, 0, "nothing was committed by the arm") check(self._confirmDelete ~= nil and self._confirmDelete.id == "slot1", "the first click arms that row") - clickDelete(self, r) - eq(self.deletedSlots[1], "red/slot1", "the second click on it deletes") + eq(press(self, "slot", "slot1", "red"), true, + "the second click on it deletes") + eq(self.deleted[1], "slot/red/slot1", "the commit ran for that row") eq(self._confirmDelete, nil, "the arm is spent") end --- ------- the arm is per row, per version, and any other press clears it +-- ------- the arm is per row, per version do local self = launcher() - local one, two = rect("slot1", 200), rect("slot2", 230) - self.slotDeleteRects = { one, two } - clickDelete(self, one) - clickDelete(self, two) - eq(#self.deletedSlots, 0, "a click on another row's Delete only arms that row") + press(self, "slot", "slot1", "red") + eq(press(self, "slot", "slot2", "red"), false, + "a click on another row's Delete only arms that row") eq(self._confirmDelete.id, "slot2", "the arm moved to the row just clicked") - self.slotRects = { rect("slot3", 260) } - clickDelete(self, one) -- re-arm slot1 - self:mousepressed(102, 262, 1) -- press somewhere else entirely - clickDelete(self, one) - eq(#self.deletedSlots, 0, "a press elsewhere disarms, so Delete asks again") + press(self, "slot", "slot1", "red") -- re-arm slot1 + eq(press(self, "slot", "slot1", "blue"), false, + "an arm from one game's tab cannot fire on another") + eq(#self.deleted, 0, "no cross-target pair ever committed") +end - self:mousepressed(102, 262, 1) -- clear the arm left by the pair above - clickDelete(self, one) - self.panelVersion = "blue" - clickDelete(self, one) - eq(#self.deletedSlots, 0, "an arm from one game's tab cannot fire on another") +-- ------- any other action press disarms (the view clears the arm) + +do + local self = launcher() + press(self, "slot", "slot1", "red") + self._confirmDelete = nil -- what queueAction does on any + -- non-delete action press + eq(press(self, "slot", "slot1", "red"), false, + "a press elsewhere disarms, so Delete asks again") + eq(#self.deleted, 0, "and the cleared arm never committed") end -- ------- a stale arm expires instead of committing much later do local self = launcher() - local r = rect("slot1") - self.slotDeleteRects = { r } - clickDelete(self, r) + press(self, "slot", "slot1", "red") clock = clock + 30 - clickDelete(self, r) - eq(#self.deletedSlots, 0, "an arm older than the confirm window is dead") - clickDelete(self, r) - eq(self.deletedSlots[1], "red/slot1", "and the fresh pair still deletes") + eq(press(self, "slot", "slot1", "red"), false, + "an arm older than the confirm window is dead") + eq(press(self, "slot", "slot1", "red"), true, + "and the fresh pair still deletes") end --- ------- mods delete arms the same way +-- ------- mods delete arms the same way (version is nil for mods) do local self = launcher() - local r = rect("bigmod", 260) - self.modDeleteRects = { r } - clickDelete(self, r) - eq(#self.deletedMods, 0, "the first click on a mod's Delete does not delete") - clickDelete(self, r) - eq(self.deletedMods[1], "bigmod", "the second click removes the mod") + eq(press(self, "mod", "bigmod", nil), false, + "the first click on a mod's Delete does not delete") + eq(press(self, "mod", "bigmod", nil), true, + "the second click removes the mod") + eq(self.deleted[1], "mod/nil/bigmod", "the mod commit ran") end T.finish("launcher delete confirm") diff --git a/tests/engine/launcher_text_input_bug578.lua b/tests/engine/launcher_text_input_bug578.lua index 2e8d7403..222232b8 100644 --- a/tests/engine/launcher_text_input_bug578.lua +++ b/tests/engine/launcher_text_input_bug578.lua @@ -75,14 +75,10 @@ check(ri.findNotice and ri.findNotice.ok == false, -- ---- PASTE chip: same entry point the touch screen uses ------------------- ri:_promptAddIndex() --- the chip rect is what draw() published last frame (pinned: modal chrome --- ignores the page-scroll band); mousepressed hit-tests it while the --- prompt is up and everywhere else the prompt swallows the press -ri._indexPasteRect = { x = 10, y = 10, width = 60, height = 24, pinned = true } +-- the prompt's Paste button (LauncherView) queues _pasteIndexUrl, the same +-- funnel ctrl/cmd+V uses, so both paths share the strip and the cap clipboard = " https://example.com/mods/index.json\n" -ri:mousepressed(200, 200, 1) -eq(ri._indexPrompt.text, "", "a press outside the chip pastes nothing") -ri:mousepressed(20, 20, 1) +ri:_pasteIndexUrl() eq(ri._indexPrompt.text, "https://example.com/mods/index.json", "the PASTE chip lands the clipboard with whitespace stripped (#578)") @@ -90,7 +86,7 @@ eq(ri._indexPrompt.text, "https://example.com/mods/index.json", -- overflow MAX_INDEX_URL (200) ri._indexPrompt.text = "" clipboard = string.rep("a", 300) -ri:mousepressed(20, 20, 1) +ri:_pasteIndexUrl() eq(#ri._indexPrompt.text, 200, "the PASTE chip enforces MAX_INDEX_URL") -- and through ctrl/cmd+V, which used to skip the cap entirely @@ -117,22 +113,25 @@ ri:keypressed("return") eq(lastArm(), false, "committing the rename disarms setTextInput") eq(renamed and renamed[3], "OLD!", "the commit reaches SaveData.renameSlot") --- ---- find-search field: arm on rect press, disarm on escape --------------- +-- ---- find-search field: arm on focus, disarm on escape / tab change ------- -ri.findSearchRect = { x = 100, y = 100, width = 80, height = 20 } -ri:mousepressed(110, 110, 1) -check(ri._findSearchFocus == true, "pressing the search field takes focus") -eq(lastArm(), true, "and arms setTextInput") +-- the search field's click handler (LauncherView) takes focus and arms; +-- drive the same pair the handler queues +ri._findSearchFocus = true +ri:_armTextInput() +eq(lastArm(), true, "focusing the search field arms setTextInput") ri:keypressed("escape") check(ri._findSearchFocus == false, "escape drops the search caret") eq(lastArm(), false, "and disarms setTextInput") --- a press elsewhere on the find tab also drops the caret and disarms -ri:mousepressed(110, 110, 1) -eq(lastArm(), true, "refocus for the click-away case") -ri:mousepressed(400, 400, 1) -check(ri._findSearchFocus == false, "a click away drops the caret") +-- switching tabs (chips, shoulder buttons) also drops the caret and disarms +ri._findSearchFocus = true +ri:_armTextInput() +eq(lastArm(), true, "refocus for the tab-change case") +ri:_switchTab("mods") +check(ri._findSearchFocus == false, "a tab change drops the caret") eq(lastArm(), false, "and disarms setTextInput") +ri.tab = "find" -- ---- desktop contract (#529): disarm never lowers off Android ------------- diff --git a/tests/rom_importer_double_pick_test.lua b/tests/rom_importer_double_pick_test.lua index 769406f9..e8462e2d 100644 --- a/tests/rom_importer_double_pick_test.lua +++ b/tests/rom_importer_double_pick_test.lua @@ -145,14 +145,18 @@ for os, forwards in pairs(touchForwardsToImporter) do os .. ": the synthesized mouse press is dropped only where touch already forwarded") end +-- The FlexLove view polls love.touch itself and dedupes a tap's synthesized +-- mouse click in its action layer (LauncherView queueAction), so the +-- host-forwarded touch events are inert stubs: they must accept any id +-- without capturing state or throwing. local touch = importer("iOS") touch:touchpressed(101, 20, 20) -check(touch._activeTouch == 101, "iOS touch press captures the active touch") +touch:touchmoved(101, 22, 22) touch:touchreleased(202, 20, 20) -check(touch._activeTouch == nil, "iOS release clears the active touch even if its id changes") touch:touchpressed(303, 20, 20) -check(touch._activeTouch == 303, "iOS accepts the next touch after release") touch:touchreleased(303, 20, 20) +check(touch._activeTouch == nil, + "touch events stay inert: the view's own polling owns touch input") love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index dfbd0dcc..000d2a31 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -864,11 +864,12 @@ do end do - -- #497: the editor drew a desktop layout into a phone window. Kit.layout - -- scaled off height alone, and a phone in portrait (720x1560) is TALLER - -- than the 768px desktop reference while being barely half as wide, so the - -- scale came back clamped at 1.6 and every right-aligned cluster in the - -- chrome landed on top of the block to its left. Both axes now pay. + -- #497 shrank the layout to fit a phone's width; #715 replaced that with + -- reflow. The scale never dips below the 0.9 readability floor now: a + -- narrow window keeps readable fonts and 26px tap targets and the panels + -- stack / drop columns / scroll instead of shrinking. The width term + -- (width/640) only stops a portrait phone from inflating to the 1.6 cap + -- its height alone would buy. local Kit = require("Kit") local Theme = require("Theme") local function about(got, want, msg) @@ -876,19 +877,21 @@ do msg .. string.format(" (got %.4f, want %.4f)", got, want)) end - about(Kit.layout(720, 1560), 0.72, "portrait phone scales off its width") - check(Kit.layout(720, 1560) < 1.0, - "a portrait phone no longer draws a larger-than-desktop layout") + about(Kit.layout(720, 1560), 720 / 640, + "portrait phone scales off its width, gently") + check(Kit.layout(720, 1560) >= 0.9, + "a portrait phone never drops below the readability floor") about(Kit.layout(1560, 720), 720 / 768, "landscape phone still scales off height") - about(Kit.layout(360, 640), 0.62, "a tiny window stops at the floor") + about(Kit.layout(360, 640), 0.9, + "a tiny window stops at the readable floor and reflows instead of shrinking") + about(Kit.layout(500, 800), 0.9, "500px wide sits on the floor too") - -- desktop and laptop sizes have to be pixel-identical to before the fix: - -- everything at or above the 1000px reference width lands on the height - -- term, exactly as it always did + -- desktop and laptop sizes keep the height-only scale they always had for _, size in ipairs({ { 1280, 800 }, { 1024, 768 }, { 1920, 1080 }, - { 1440, 900 }, { 2560, 1440 } }) do - about(Kit.layout(size[1], size[2]), Theme.clamp(size[2] / 768, 0.7, 1.6), - ("%dx%d keeps its old height-only scale"):format(size[1], size[2])) + { 1440, 900 }, { 2560, 1440 }, { 900, 700 } }) do + about(Kit.layout(size[1], size[2]), + Theme.clamp(math.min(size[1] / 640, size[2] / 768), 0.9, 1.6), + ("%dx%d keeps its height-based scale"):format(size[1], size[2])) end end @@ -917,8 +920,13 @@ do end end + -- 720x1280 / 1280x720 are the #715 report's shapes (Android, both + -- orientations): the Map tab used to lay its viewport out at a negative + -- width in portrait and crash on the scissor. The desktop sizes pin that + -- the responsive reflow does not disturb the layouts that already worked. for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 }, - { 1280, 800 } }) do + { 1280, 800 }, { 720, 1280 }, { 1280, 720 }, + { 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do love.graphics.getDimensions = function() return size[1], size[2] end App.load(tmpPath, { version = "red" }) local S = App.getState() @@ -928,6 +936,8 @@ do local ok, err = pcall(App.draw) check(ok, ("the %s tab draws at %s: %s"):format(tab, label, tostring(err))) end + check((S._mapViewW or 0) >= 0 and (S._mapViewH or 0) >= 0, + ("the map viewport stays non-negative at %s (#715)"):format(label)) S.tab = "party" Ops.selectParty(S, 1) local ok, err = pcall(App.draw) @@ -947,5 +957,192 @@ do for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end +do + -- #715 reflow audit. Kit records every control that could take a click + -- while Kit.audit is set (shielded widgets are skipped, since a modal + -- legitimately covers what it shields). The sweep below drives every tab + -- at the window shapes the reflow has to serve and FAILS if any two + -- controls overlap or any control escapes the window, which is exactly + -- the "buttons covering things" class of bug the shrink-to-fit layout + -- kept producing. Rects clip to the region that bounds their hit test, + -- so a row scrolled out of a list is not a phantom overlap. + local Kit = require("Kit") + + local function clipped(r) + local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h + if r.clip then + x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y) + x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h) + end + if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end + return x1, y1, x2, y2 + end + + local function overlap(a, b) + local ax1, ay1, ax2, ay2 = clipped(a) + if not ax1 then return false end + local bx1, by1, bx2, by2 = clipped(b) + if not bx1 then return false end + return math.min(ax2, bx2) - math.max(ax1, bx1) > 1 + and math.min(ay2, by2) - math.max(ay1, by1) > 1 + end + + local function auditFrame(label, W, H) + local rects = Kit.audit + local controls = {} + for _, r in ipairs(rects) do + if r.class == "control" then controls[#controls + 1] = r end + end + check(#controls > 0, label .. ": the frame dispatched controls at all") + local collisions, escapes = 0, 0 + for i = 1, #controls do + local a = controls[i] + local x1, y1, x2, y2 = clipped(a) + if x1 and (x1 < -0.5 or y1 < -0.5 or x2 > W + 0.5 or y2 > H + 0.5) then + escapes = escapes + 1 + print((" escape: %s (%.0f,%.0f %.0fx%.0f)") + :format(a.label, a.x, a.y, a.w, a.h)) + end + for j = i + 1, #controls do + if overlap(a, controls[j]) then + collisions = collisions + 1 + print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)") + :format(a.label, controls[j].label, a.x, a.y, + controls[j].x, controls[j].y)) + end + end + end + check(collisions == 0, label .. ": no two controls overlap") + check(escapes == 0, label .. ": every control stays inside the window") + end + + local tmpPath = os.tmpname() .. "-audit-save.lua" + local data = SaveData.newGame() + data.party = {} + for i = 1, require("src.pokemon.Party").MAX do + data.party[i] = MonOps.create(Data, i % 2 == 0 and "PIDGEY" or "CHARIZARD", + 10 * i) + end + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(data)) + f:close() + + local oldDimensions = love.graphics.getDimensions + local sizes = { { 500, 800 }, { 720, 1280 }, { 1280, 720 }, + { 1024, 768 }, { 900, 700 }, { 1920, 1080 } } + for _, size in ipairs(sizes) do + local W, H = size[1], size[2] + love.graphics.getDimensions = function() return W, H end + App.load(tmpPath, { version = "red" }) + local S = App.getState() + -- populate the panels the fresh save leaves empty, so their controls + -- (quantity rows, box cells, dock rows, flags) are exercised too + Ops.selectParty(S, 1) + Ops.boxAdd(S); Ops.boxAdd(S) + Ops.addToBag(S, S.cat.items[1]) + Ops.addToPc(S, S.cat.items[2]) + Ops.setFlag(S, "EVENT_GOT_POKEDEX", true) + for _, tab in ipairs({ "party", "boxes", "items", "events", "map", "dex" }) do + S.tab = tab + Kit.audit = {} + local ok, err = pcall(App.draw) + check(ok, ("%dx%d %s draws: %s"):format(W, H, tab, tostring(err))) + if ok then auditFrame(("%dx%d %s"):format(W, H, tab), W, H) end + Kit.audit = nil + end + -- the species picker dialog reflows too; frame 2, since the opening + -- frame is fully shielded by design (#541) and would audit empty + S.tab = "party" + Ops.openSpeciesPicker(S, Kit) + App.draw() + Kit.audit = {} + local ok, err = pcall(App.draw) + check(ok, ("%dx%d species picker draws: %s"):format(W, H, tostring(err))) + if ok then auditFrame(("%dx%d species picker"):format(W, H), W, H) end + Kit.audit = nil + Ops.closeSpeciesPicker(S, Kit) + end + love.graphics.getDimensions = oldDimensions + + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + +do + -- Box add flow: the Boxes panel's "+ Add mon here" and its dashed empty + -- cells open the SAME species picker the inspector uses, in box-add mode, + -- and the committed species lands in the selected box as a Lv5 mon built + -- by the same MonOps path Ops.partyAdd uses. + local Kit = require("Kit") + local BoxesMod = require("src.pokemon.Boxes") + local tmpPath = os.tmpname() .. "-boxadd-save.lua" + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(SaveData.newGame())) + f:close() + + App.load(tmpPath, { version = "red" }) + local S = App.getState() + S.tab = "boxes" + + check(Ops.openBoxAddPicker(S, Kit) == true, "box-add picker opens") + check(S.speciesPicker ~= nil, "the picker is up") + eq(S.speciesPicker.mode, "box-add", "and it is in box-add mode") + eq(Kit.focus, "species-picker", "with the search field focused (#529)") + + local ok, err = pcall(App.draw) + check(ok, "the box-add picker draws headlessly: " .. tostring(err)) + + App.textinput("PIKACHU") + App.draw() + App.keypressed("return") + local box = Ops.boxes(S)[S.selectedBox] + check(S.speciesPicker == nil, "committing closes the picker") + eq(#box, 1, "the commit added exactly one mon to the box") + local mon = box[1] + eq(mon.species, "PIKACHU", "the picked species landed in the box") + eq(mon.level, 5, "as a Lv5 mon, matching partyAdd's default") + check(mon.stats and mon.stats.hp and mon.stats.hp > 0, + "with real Gen1 stats from MonOps.create") + eq(mon.ot, S.save.player.name, "owned by the save's player") + eq(mon.otId, S.save.player.id, "with the player's trainer id") + check(S.editingMon == mon, "and the inspector now points at it") + check(S.dirty, "and the save is dirty") + + -- Escape leaves without adding anything + Ops.openBoxAddPicker(S, Kit) + App.textinput("BULBASAUR") + App.draw() + App.keypressed("escape") + check(S.speciesPicker == nil, "Escape closes the box-add picker") + eq(#box, 1, "Escape added nothing") + + -- an unusable (mod-partial) record refuses instead of crashing (#541) + Data.pokemon.TESTMON_BOXADD = { name = "TESTMON", dex = 0, + baseStats = { hp = 40 }, growthRate = "MEDIUM_FAST", + types = { "NORMAL" }, learnset = {} } + S.cat = Catalog.build(Data) + S.dirty = false + check(Ops.boxAddSpecies(S, "TESTMON_BOXADD") == false, + "a record without usable base stats is refused") + eq(#box, 1, "and nothing was added") + check(S.status:match("base stats") ~= nil, "and the refusal explains itself") + check(S.dirty == false, "and the save stays clean") + Data.pokemon.TESTMON_BOXADD = nil + S.cat = Catalog.build(Data) + + -- a full box refuses to even open the picker + while #box < BoxesMod.CAPACITY do Ops.boxAdd(S) end + check(Ops.openBoxAddPicker(S, Kit) == false, "a full box refuses the picker") + check(S.speciesPicker == nil, "and it stays closed") + check(S.status:match("full") ~= nil, "and says why") + + -- ...and a commit raced against a filling box refuses too + check(Ops.boxAddSpecies(S, "PIKACHU") == false, + "boxAddSpecies refuses a full box") + + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + print(string.format("save editor tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tests/save_editor_wheel_bug595_test.lua b/tests/save_editor_wheel_bug595_test.lua index 9c0b2485..9402e509 100644 --- a/tests/save_editor_wheel_bug595_test.lua +++ b/tests/save_editor_wheel_bug595_test.lua @@ -49,6 +49,49 @@ Kit.blockClicks = false Kit.endFrame() eq(Kit.wheelY, 0, "an unclaimed notch retires with the frame") +-- #715: a phone has no wheel, so Kit.scroll also follows a held pointer +-- dragging vertically over the list body. Kit.beginFrame polls +-- love.mouse.isDown for this (neither host routes mousereleased), so the +-- stub grows one here. +local held = false +love.mouse = love.mouse or {} +love.mouse.getPosition = love.mouse.getPosition or function() return 0, 0 end +love.mouse.isDown = function() return held end + +held = true +Kit.beginFrame(50, 90, false, 0) +eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0, + "the press frame starts a drag without moving the list") +Kit.endFrame() + +Kit.beginFrame(50, 60, false, 0) -- dragged 30px up, 10 rows / 100px = 3 rows +eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 3, + "dragging upward reveals lower rows") +Kit.endFrame() + +Kit.beginFrame(50, -2910, false, 0) -- a wild fling clamps like the wheel does +eq(Kit.scroll(0, 0, 100, 100, 3, 250, 10), 240, + "a drag past the end clamps to the last page") +Kit.endFrame() + +held = false +Kit.beginFrame(50, 60, false, 0) +eq(Kit.scroll(0, 0, 100, 100, 3, 250, 10), 3, + "releasing the pointer ends the drag") +Kit.endFrame() + +held = true +Kit.beginFrame(500, 500, false, 0) -- press outside the list body +Kit.scroll(0, 0, 100, 100, 0, 250, 10) +Kit.endFrame() +Kit.beginFrame(500, 400, false, 0) +eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0, + "a drag that never entered the list does not scroll it") +Kit.endFrame() +held = false +Kit.beginFrame(0, 0, false, 0) +Kit.endFrame() + -- The wheel has to reach the Items lists without touching the map camera, -- which is the only thing App.wheelmoved used to drive (#595). Loading the -- whole editor needs data/generated/, so pin the routing at the source seam diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 9f6f75fd..85450aa3 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -12,6 +12,7 @@ -- Vertical rhythm (scaled by Kit's height/768 factor, everything else flexes): -- 0 6px tri-colour version rail, identical to the launcher's -- 6 64px title bar identity, file chip, Save / Reload / Open / Close +-- (104px when the bar reflows to two rows, #715) -- 70 66px tab rail 6 tab tiles + right-aligned validation pill -- 136 flex content one panel per tab, 20px gutters -- -38 38px status bar the last Ops message + the keyboard map @@ -339,16 +340,49 @@ local function drawFileChip(x, y, w, h) Kit.text("mono", shown, cx, y + (h - Kit.textHeight("mono")) / 2, PAL.detail) end -local function drawTitleBar(x, y, w, h) +-- Measure the title bar's right-aligned action cluster. Shared by App.draw +-- (which must size the bar before drawing it) and drawTitleBar, so the +-- two-row decision and the layout can never disagree (#715). +local function titleButtons() + local s = Kit.scale + local gap = 8 * s + local b = { + gap = gap, + closeW = 22 * s + Kit.textWidth("button", S._quitArmed and "Discard?" or "Close"), + openW = 22 * s + Kit.textWidth("button", "Open..."), + reloadW = 22 * s + Kit.textWidth("button", "Reload"), + } + b.saveLabel, b.saveKind, b.saveEnabled = "SAVED", "disabled", false + if not S.allowSave then + b.saveLabel = "SAVE LOCKED" + elseif S.dirty then + b.saveLabel, b.saveKind, b.saveEnabled = "SAVE", "primary", true + end + b.saveW = 30 * s + Kit.textWidth("button", b.saveLabel) + b.total = b.saveW + b.reloadW + b.openW + b.closeW + 3 * gap + return b +end + +-- Whether the identity block plus the action cluster fit on one 64px row. +-- When they do not, the bar reflows to two rows (identity + file chip above, +-- buttons below) instead of shrinking or overlapping (#715). +local function titleNeedsTwoRows(w) + local s = Kit.scale + return titleButtons().total > w - 2 * (22 * s) - (34 * s) - 10 * s +end + +local function drawTitleBar(x, y, w, h, twoRow) local s = Kit.scale local pad = 22 * s Theme.col(PAL.cardBorder, 0.22) love.graphics.rectangle("fill", x, y + h - 1, w, 1) + -- the identity row is the whole bar in one-row mode, the top slice in two + local rowH = twoRow and (h * 0.55) or h local cx = x + pad -- SE badge, the same rounded-square chip shape the launcher's tabs use local badge = 34 * s - local by = y + (h - badge) / 2 + local by = y + (rowH - badge) / 2 Theme.gradRounded(cx, by, badge, badge, 9 * s, PAL.chipTop, PAL.chipBot, 1, 1) Kit.textCenter("tab", "SE", cx, by + (badge - Kit.textHeight("tab")) / 2, badge, { 159, 180, 221 }) @@ -359,34 +393,30 @@ local function drawTitleBar(x, y, w, h) -- this bar that must always be reachable, so on a phone the identity block, -- the version chip and the file chip are what yield. Measuring them last -- is why they used to paint straight through the buttons (#497). + local b = titleButtons() local btnH = 38 * s - local btnY = y + (h - btnH) / 2 + local btnY = twoRow and (y + rowH + (h - rowH - btnH) / 2) or (y + (h - btnH) / 2) local rightEdge = x + w - pad - local gap = 8 * s - local closeW = 22 * s + Kit.textWidth("button", "Close") - local openW = 22 * s + Kit.textWidth("button", "Open...") - local reloadW = 22 * s + Kit.textWidth("button", "Reload") + local gap = b.gap + local saveLabel, saveKind, saveEnabled = b.saveLabel, b.saveKind, b.saveEnabled - local saveLabel, saveKind, saveEnabled = "SAVED", "disabled", false - if not S.allowSave then - saveLabel = "SAVE LOCKED" - elseif S.dirty then - saveLabel, saveKind, saveEnabled = "SAVE", "primary", true - end - local saveW = 30 * s + Kit.textWidth("button", saveLabel) - - local closeX = rightEdge - closeW - local openX = closeX - gap - openW - local reloadX = openX - gap - reloadW - local saveX = reloadX - gap - saveW + -- clamped at the left pad so a window narrower than the cluster overflows + -- to the right (clipped) instead of stacking buttons on each other + local saveX = math.max(x + pad, rightEdge - b.total) + local reloadX = saveX + b.saveW + gap + local openX = reloadX + b.reloadW + gap + local closeX = openX + b.openW + gap + -- the identity row yields to the buttons in one-row mode; in two-row mode + -- the buttons are on their own row and the identity keeps the full width + local identityLimit = twoRow and (rightEdge + 14 * s) or saveX local wordH = Kit.textHeight("wordmark") local brandH = Kit.textHeight("brand") - local blockY = y + (h - (wordH + 2 * s + brandH)) / 2 + local blockY = y + (rowH - (wordH + 2 * s + brandH)) / 2 local wordW = math.max( Theme.spacedWidth(Kit.fonts.wordmark, "SAVE EDITOR", 2 * s), Theme.spacedWidth(Kit.fonts.brand, "GEN1RECOMP", 1 * s)) - if cx + wordW + 12 * s < saveX then + if cx + wordW + 12 * s < identityLimit then love.graphics.setFont(Kit.fonts.wordmark) Theme.col(PAL.heading, 1) Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s) @@ -404,8 +434,8 @@ local function drawTitleBar(x, y, w, h) local c = (S.version == "blue") and PAL.blue or PAL.red local cw = Kit.textWidth("chip", name) + 16 * s local ch = 22 * s - local cy = y + (h - ch) / 2 - if cx + cw + 12 * s < saveX then + local cy = y + (rowH - ch) / 2 + if cx + cw + 12 * s < identityLimit then Theme.col(c, 0.1) love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s) Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1) @@ -418,22 +448,22 @@ local function drawTitleBar(x, y, w, h) -- Save is the only green-filled control in the chrome; a corrupt load -- renders it steel with the reason parked in the status bar rather than -- hiding it (rule 3 of the design spec). - if Kit.button(saveX, btnY, saveW, btnH, saveLabel, + if Kit.button(saveX, btnY, b.saveW, btnH, saveLabel, { kind = saveKind, enabled = saveEnabled or not S.allowSave, glow = S.dirty and S.allowSave and 0.6 or nil }) then App.save() end - if Kit.button(reloadX, btnY, reloadW, btnH, "Reload") then App.reload() end - if Kit.button(openX, btnY, openW, btnH, "Open...") then App.chooseAndOpen() end - if Kit.button(closeX, btnY, closeW, btnH, + if Kit.button(reloadX, btnY, b.reloadW, btnH, "Reload") then App.reload() end + if Kit.button(openX, btnY, b.openW, btnH, "Open...") then App.chooseAndOpen() end + if Kit.button(closeX, btnY, b.closeW, btnH, S._quitArmed and "Discard?" or "Close", { kind = S._quitArmed and "danger" or "ghost" }) then App.close() end - local chipW = (saveX - 14 * s) - cx + local chipW = (identityLimit - 14 * s) - cx if chipW > 80 * s then - drawFileChip(cx, y + (h - 38 * s) / 2, chipW, 38 * s) + drawFileChip(cx, y + (rowH - 38 * s) / 2, chipW, 38 * s) end end @@ -607,9 +637,18 @@ local function drawStatusBar(x, y, w, h) "+R reload . Esc clear selection . Close returns to the launcher") or (ctrl .. "+S save . " .. ctrl .. "+R reload . Esc clear selection . arrows pan map . wheel scrolls lists") + -- The status message is the load-bearing half of this bar (every Ops verb + -- narrates through it); the keyboard map is decoration. On a phone the + -- two used to overlap because the hint was drawn unconditionally and the + -- status ellipsized against a negative budget (#715), so now the hint only + -- draws when the status still keeps a readable share of the bar. local hintW = Kit.textWidth("tiny", hint) - Kit.textRight("tiny", hint, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2, PAL.faint) local avail = w - 2 * pad - hintW - 14 * s + if avail >= 120 * s then + Kit.textRight("tiny", hint, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2, PAL.faint) + else + avail = w - 2 * pad + end Kit.text("mono", Kit.ellipsize("mono", S.status or "", avail), x + pad, y + (h - Kit.textHeight("mono")) / 2, PAL.detail) end @@ -636,12 +675,17 @@ function App.draw() Theme.field(width, height) local railH = 6 * s - local titleH = 64 * s + -- The title bar reflows to two rows (identity above, buttons below) when + -- the window is too narrow for both on one, instead of the buttons and the + -- identity painting through each other (#715). The taller bar simply + -- costs the content column height, which scrolls. + local titleTwoRow = titleNeedsTwoRows(width) + local titleH = (titleTwoRow and 104 or 64) * s local tabH = 66 * s local statusH = 38 * s Theme.versionRail(0, 0, width, railH) - drawTitleBar(0, railH, width, titleH) + drawTitleBar(0, railH, width, titleH, titleTwoRow) drawTabRail(0, railH + titleH, width, tabH) local contentY = railH + titleH + tabH diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 9890e824..d542ed54 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -65,6 +65,18 @@ function Kit.beginFrame(mx, my, clicked, wheel) Kit.mouseX, Kit.mouseY = mx, my Kit.mouseClicked = clicked Kit.wheelY = wheel or 0 + -- Held-button state is polled, not evented: the editor is hosted both + -- standalone and inside the launcher, and neither routes mousereleased + -- here. Touch drag scrolling (#715) rides this poll, so it works in both + -- hosts without new plumbing. The stub has no love.mouse.isDown; a frame + -- without it simply has no drags. + local down = false + if love and love.mouse and love.mouse.isDown then + down = love.mouse.isDown(1) and true or false + end + Kit.mouseDown = down + if not down then Kit._drag = nil end + Kit.resetClip() if love and love.timer and love.timer.getTime then Kit.time = love.timer.getTime() end @@ -83,11 +95,23 @@ end -- (720x1560) is TALLER than the desktop reference and barely half as wide, so -- a height-only scale drew a 1.6x desktop layout into a 720px window and every -- right-aligned cluster in the chrome landed on top of the block to its left. --- The layout needs roughly 1000 logical px of width, so the window now pays --- for both axes. Every desktop and landscape size still lands on the height --- term, which is why they stay pixel-identical to before. +-- +-- The #497 answer was to shrink the whole layout down to fit the width +-- (floor 0.62), which #715 showed is its own failure: a small window got a +-- complete but unreadably tiny desktop layout, and the panels still assumed +-- their columns fit. Shrink-to-fit is gone. The scale now never dips below +-- 0.9, so text and the 26px tap targets stay readable everywhere, and a +-- narrow window is answered by REFLOW instead: every panel compares its real +-- pixel width against what its columns need (Party/Boxes/Items stack their +-- cards, Dex/Events drop grid columns, the chrome wraps its button row) and +-- whatever no longer fits vertically scrolls through Kit.scroll / +-- Kit.scrollPixels. The width term survives only to keep a portrait phone +-- from inflating to the 1.6 cap its height alone would buy: 640 real px is +-- the narrowest the single-row chrome fits at scale 1. Desktop sizes +-- (width >= 640 * height / 768) still land on the height term, so they stay +-- pixel-identical to before. function Kit.layout(width, height) - local s = Theme.clamp(math.min(width / 1000, height / 768), 0.62, 1.6) + local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) local key = ("%dx%d"):format(width, height) if Kit._fontKey ~= key then Kit._fontKey = key @@ -128,11 +152,36 @@ function Kit.blur() end -- ------------------------------------------------------------- hit testing +-- A widget inside a scrolled clip region can sit at coordinates outside the +-- visible rect (#715: stacked panels scroll in pixels), so the active clip +-- bounds the hit: what the user cannot see cannot take the tap. function Kit.hit(x, y, w, h) + local c = Kit._clipRect + if c and not (Kit.mouseX >= c.x and Kit.mouseX <= c.x + c.w + and Kit.mouseY >= c.y and Kit.mouseY <= c.y + c.h) then + return false + end return Kit.mouseX >= x and Kit.mouseX <= x + w and Kit.mouseY >= y and Kit.mouseY <= y + h end +-- ------------------------------------------------------------ layout audit +-- #715 reflow tests: when a test sets Kit.audit to a table, every control +-- that could take a click this frame appends its rect (plus the clip that +-- bounds it), so a window-size sweep can assert that no two controls +-- overlap and none escapes the window. Shielded widgets are skipped: under +-- a modal they cannot take the tap, and the modal legitimately covers them. +Kit.audit = nil + +local function audit(class, x, y, w, h, label) + local a = Kit.audit + if not a or Kit.blockClicks then return end + local c = Kit._clipRect + a[#a + 1] = { class = class, x = x, y = y, w = w, h = h, + label = tostring(label or ""), + clip = c and { x = c.x, y = c.y, w = c.w, h = c.h } or nil } +end + function Kit.hover(x, y, w, h) return Kit.hit(x, y, w, h) end @@ -224,6 +273,7 @@ end -- true when the row was clicked this frame. function Kit.row(x, y, w, h, selected, accent, r) r = r or 12 * Kit.scale + audit("row", x, y, w, h, "row") if not G then return Kit.press(x, y, w, h) end accent = accent or PAL.green if selected then Theme.glow(x, y, w, h, r, accent, 0.45) end @@ -280,6 +330,9 @@ local KINDS = { function Kit.button(x, y, w, h, label, opts) opts = opts or {} local enabled = opts.enabled ~= false + -- disabled buttons audit too: rule 3 keeps them visible, so they still + -- must not paint over a neighbour (#715) + audit("control", x, y, w, h, label) local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] local r = opts.radius or 10 * Kit.scale local hot = enabled and Kit.hover(x, y, w, h) @@ -327,6 +380,7 @@ end -- A pill toggle (badges, dex SEEN/OWN, event sub-tabs). `on` colours it; -- returns true when clicked. function Kit.chip(x, y, w, h, label, on, onColor, offColor) + audit("control", x, y, w, h, label) local c = on and (onColor or PAL.green) or (offColor or PAL.steel) if G then local r = 6 * Kit.scale @@ -367,6 +421,7 @@ end -- routes love.textinput / love.keypressed in through Kit.textinput / -- Kit.keypressed. Returns the (possibly edited) value; the caller stores it. function Kit.textfield(id, x, y, w, h, value, placeholder) + audit("control", x, y, w, h, id) value = tostring(value or "") if Kit.press(x, y, w, h) then Kit.focus = id end local focused = (Kit.focus == id) @@ -428,8 +483,12 @@ function Kit.pager(x, y, w, offset, total, perPage) local shown = math.min(perPage, math.max(0, total - offset)) local label = ("%d-%d of %d"):format(total > 0 and offset + 1 or 0, offset + shown, total) - Kit.text("mono", label, x + 2 * bw + 20 * Kit.scale, - y + (h - Kit.textHeight("mono")) / 2, PAL.caption) + -- the counter clips to the width the caller granted: a panel parking a + -- button on the pager line passes a reduced w and the text yields instead + -- of running underneath it (#715) + local labelX = x + 2 * bw + 20 * Kit.scale + Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)), + labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption) return offset, h end @@ -443,37 +502,143 @@ end -- panel under an open species picker would scroll through the modal. local SCROLL_ROWS = 3 -function Kit.scroll(x, y, w, h, offset, total, perPage) +-- `step` is optional and exists for grids: a 4-column dex page must move in +-- multiples of 4 or the columns shear. Lists leave it nil and keep the old +-- behaviour bit for bit (wheel notch = 3 rows, drag = 1 row per row height). +function Kit.scroll(x, y, w, h, offset, total, perPage, step) local maxOffset = math.max(0, (total or 0) - (perPage or 0)) offset = Theme.clamp(offset or 0, 0, maxOffset) - if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return offset end + if Kit.blockClicks then return offset end + + -- Touch drag (#715): a phone has no wheel and the pagers are small + -- targets, so a held pointer dragging vertically over the list body + -- scrolls it. The drag is keyed to the rect it started in and follows the + -- pointer even once it leaves, like every native scroll view; the press + -- frame itself still dispatches as a click, which is the pre-existing + -- press-on-down contract, so a tap keeps selecting rows. + local dragStep = math.max(1, step or 1) + if Kit.mouseDown and maxOffset > 0 and h > 0 and (perPage or 0) > 0 then + local key = math.floor(x) .. ":" .. math.floor(y) + local d = Kit._drag + if not d and Kit.hit(x, y, w, h) then + Kit._drag = { key = key, startY = Kit.mouseY, base = offset } + elseif d and d.key == key then + local visRows = math.max(1, math.floor(perPage / dragStep)) + local rowPx = math.max(1, h / visRows) + local moved = math.floor((d.startY - Kit.mouseY) / rowPx + 0.5) * dragStep + offset = Theme.clamp(d.base + moved, 0, maxOffset) + end + end + + if (Kit.wheelY or 0) == 0 then return offset end if not Kit.hit(x, y, w, h) then return offset end -- LOVE reports wheel-up as positive y; up moves the window toward the top -- of the list, which is a smaller offset. - local rows = math.max(1, math.min(SCROLL_ROWS, perPage or SCROLL_ROWS)) - local step = (Kit.wheelY > 0) and -rows or rows + local rows = step or math.max(1, math.min(SCROLL_ROWS, perPage or SCROLL_ROWS)) + local notch = (Kit.wheelY > 0) and -rows or rows Kit.wheelY = 0 - return Theme.clamp(offset + step, 0, maxOffset) + return Theme.clamp(offset + notch, 0, maxOffset) end --- Clip drawing to a rect (list bodies). No-ops under the headless stub. -function Kit.pushClip(x, y, w, h) - -- A compact mobile viewport can leave a panel with no room for a list. - -- LÖVE rejects negative scissor dimensions, so treat an exhausted clip - -- region as empty instead of passing invalid geometry through to it. - Kit._clipActive = G and G.setScissor ~= nil - if Kit._clipActive then - if w <= 0 or h <= 0 then - G.setScissor(0, 0, 0, 0) - else - G.setScissor(math.floor(x), math.floor(y), math.ceil(w), math.ceil(h)) +-- Pixel-unit sibling of Kit.scroll for a whole stacked card column (#715 +-- reflow): `offset` is a pixel offset into `contentH` pixels of laid-out +-- content shown through an `h`-pixel viewport. Same three rules as +-- Kit.scroll (pointer-inside only, notch consumed, shielded by +-- Kit.blockClicks), same drag contract (a tap still dispatches as a click). +-- Call it AFTER the content so any inner Kit.scroll list gets first claim on +-- a wheel notch or drag that lands over it. +function Kit.scrollPixels(x, y, w, h, offset, contentH) + local maxOffset = math.max(0, (contentH or 0) - math.max(0, h)) + offset = Theme.clamp(offset or 0, 0, maxOffset) + if Kit.blockClicks then return offset end + + if Kit.mouseDown and maxOffset > 0 and h > 0 then + local key = "px:" .. math.floor(x) .. ":" .. math.floor(y) + local d = Kit._drag + if not d and Kit.hit(x, y, w, h) then + Kit._drag = { key = key, startY = Kit.mouseY, base = offset } + elseif d and d.key == key then + offset = Theme.clamp(d.base + (d.startY - Kit.mouseY), 0, maxOffset) end end + + if (Kit.wheelY or 0) == 0 then return offset end + if not Kit.hit(x, y, w, h) then return offset end + local notch = 48 * Kit.scale + local delta = (Kit.wheelY > 0) and -notch or notch + Kit.wheelY = 0 + return Theme.clamp(offset + delta, 0, maxOffset) +end + +-- Thin overlay scrollbar along the right edge of a list body, drawn after +-- the rows so it stays visible. Pure indicator (the drag above and the +-- pager are the controls): on a phone the old layout looked "stuck" because +-- nothing said the list continued past the fold (#715). +function Kit.scrollbar(x, y, w, h, offset, total, perPage) + if not G then return end + total, perPage = total or 0, perPage or 0 + if total <= perPage or h <= 0 or perPage <= 0 then return end + local bw = 3 * Kit.scale + local bx = x + w - bw + Theme.col(PAL.cardBorder, 0.22) + G.rectangle("fill", bx, y, bw, h, bw / 2, bw / 2) + local maxOffset = total - perPage + local th = math.max(18 * Kit.scale, h * perPage / total) + local ty = y + (h - th) * (Theme.clamp(offset or 0, 0, maxOffset) / maxOffset) + Theme.col(PAL.blue, 0.55) + G.rectangle("fill", bx, ty, bw, th, bw / 2, bw / 2) +end + +-- Clip drawing to a rect (list bodies, scrolled cards). A stack since #715: +-- a stacked panel scrolls its whole column inside one clip and the lists +-- inside it push their own, so pushes nest by intersecting with the rect +-- above and a pop restores that rect rather than clearing the scissor. The +-- tracked rect also bounds Kit.hit, so a widget scrolled out of view is +-- inert instead of taking taps aimed at whatever is drawn where it left. +-- Under the headless stub the scissor is a no-op but the rect tracking (and +-- so the hit fencing) still runs. +local clipStack = {} + +local function applyClip(rect) + Kit._clipRect = rect + if not (G and G.setScissor) then return end + if not rect then + G.setScissor() + elseif rect.w <= 0 or rect.h <= 0 then + -- A compact mobile viewport can leave a panel with no room for a list. + -- LÖVE rejects negative scissor dimensions, so treat an exhausted clip + -- region as empty instead of passing invalid geometry through to it. + G.setScissor(0, 0, 0, 0) + else + G.setScissor(math.floor(rect.x), math.floor(rect.y), + math.ceil(rect.w), math.ceil(rect.h)) + end +end + +function Kit.pushClip(x, y, w, h) + local prev = clipStack[#clipStack] + local x2, y2 = x + math.max(0, w), y + math.max(0, h) + if prev then + x, y = math.max(x, prev.x), math.max(y, prev.y) + x2 = math.min(x2, prev.x + prev.w) + y2 = math.min(y2, prev.y + prev.h) + end + local rect = { x = x, y = y, w = math.max(0, x2 - x), h = math.max(0, y2 - y) } + clipStack[#clipStack + 1] = rect + applyClip(rect) end function Kit.popClip() - if Kit._clipActive and G and G.setScissor then G.setScissor() end - Kit._clipActive = false + clipStack[#clipStack] = nil + applyClip(clipStack[#clipStack]) +end + +-- A pcall-ed draw that raised mid-clip must not leak the stack into later +-- frames (every hit test would stay fenced to the dead rect), so the frame +-- boundary clears it. +function Kit.resetClip() + for i = #clipStack, 1, -1 do clipStack[i] = nil end + applyClip(nil) end return Kit diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 694418d8..fb367806 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -271,6 +271,45 @@ function Ops.closeSpeciesPicker(S, Kit) if Kit and Kit.blur then Kit.blur() end end +-- The Boxes panel's add flow rides the same picker (#715): instead of +-- silently dropping catalog entry #1 into the box, "+ Add mon here" and the +-- dashed empty cells open the picker in box-add mode, and the committed +-- species goes through Ops.boxAddSpecies below. No selection is required: +-- the target is the box, not a mon. +function Ops.openBoxAddPicker(S, Kit) + local box = Ops.boxes(S)[S.selectedBox] + if #box >= BoxesMod.CAPACITY then + return Ops.say(S, ("Box %d is full (%d/%d)") + :format(S.selectedBox, #box, BoxesMod.CAPACITY)) + end + S.speciesPicker = { query = "", offset = 0, opened = true, mode = "box-add" } + if Kit then Kit.focus = "species-picker" end -- soft keyboard rises (#529) + return true +end + +-- Commit half of the box-add picker. Builds the mon exactly the way +-- Ops.partyAdd does (MonOps.create at Lv5, owned by the save's player), so a +-- box mon and a party mon born in the editor are indistinguishable. +function Ops.boxAddSpecies(S, id) + local box = Ops.boxes(S)[S.selectedBox] + if #box >= BoxesMod.CAPACITY then + return Ops.say(S, ("Box %d is full (%d/%d)") + :format(S.selectedBox, #box, BoxesMod.CAPACITY)) + end + if not Ops.speciesUsable(S, id) then + return Ops.say(S, ("%s has no usable base stats, cannot add it") + :format(tostring(id))) + end + local mon = MonOps.create(S.data, id, 5) + mon.ot = S.save.player.name + mon.otId = S.save.player.id + table.insert(box, mon) + S.selectedBoxSlot = #box + S.editingMon = mon + return Ops.mark(S, ("Added %s Lv5 to box %d slot %d") + :format(id, S.selectedBox, #box)) +end + function Ops.setDv(S, mon, key, value) if not mon then return false end local want = clamp(math.floor(value), 0, 15) @@ -362,6 +401,9 @@ function Ops.selectBoxSlot(S, index) return true end +-- Kept for the keyboard/test path; the Boxes panel itself goes through the +-- species picker (Ops.openBoxAddPicker -> Ops.boxAddSpecies) so the user +-- chooses what lands in the box instead of always getting catalog entry #1. function Ops.boxAdd(S) local box = Ops.boxes(S)[S.selectedBox] if #box >= BoxesMod.CAPACITY then diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index aeba4fb9..61f992db 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -40,15 +40,20 @@ function State.new() -- party / inspector selectedParty = 1, + partyOffset = 0, -- roster scroll position (#715) + inspectorScroll = 0, -- MonEditor body pixel scroll (#715) editingMon = nil, -- reference into party or a box - -- species picker overlay: nil when closed, otherwise { query, offset }. - -- Modal in the literal sense -- App shields every widget under it for the - -- frame -- because Kit hit-tests without a z-order (#541). + -- species picker overlay: nil when closed, otherwise { query, offset } + -- plus mode = "box-add" when it is adding to a box instead of changing a + -- species (Ops.openBoxAddPicker). Modal in the literal sense -- App + -- shields every widget under it for the frame -- because Kit hit-tests + -- without a z-order (#541). speciesPicker = nil, -- boxes selectedBox = 1, selectedBoxSlot = 1, + dockOffset = 0, -- party dock scroll position (#715) -- items itemQuery = "", @@ -58,6 +63,7 @@ function State.new() itemPickOffset = 0, -- scroll position in the ADD ITEM list (#595) bagOffset = 0, pcOffset = 0, + itemsScroll = 0, -- stacked-layout pixel scroll (#715) -- events eventsTab = "flags", diff --git a/tools/save-editor/Theme.lua b/tools/save-editor/Theme.lua index 762d641f..94cdc30e 100644 --- a/tools/save-editor/Theme.lua +++ b/tools/save-editor/Theme.lua @@ -227,7 +227,12 @@ end function Theme.ellipsize(font, text, maxW) text = tostring(text or "") if not font then return text end - if maxW <= 0 or font:getWidth(text) <= maxW then return text end + -- A non-positive budget means "nothing fits", not "everything fits": the + -- old early-out returned the whole string, which is how a phone-width + -- status bar ended up with two lines of text stacked on top of each other + -- (#715). + if maxW <= 0 then return "" end + if font:getWidth(text) <= maxW then return text end local ell = "..." local ew = font:getWidth(ell) while #text > 0 and font:getWidth(text) + ew > maxW do @@ -239,7 +244,8 @@ end function Theme.ellipsizeLeft(font, text, maxW) text = tostring(text or "") if not font then return text end - if maxW <= 0 or font:getWidth(text) <= maxW then return text end + if maxW <= 0 then return "" end -- same rule as Theme.ellipsize (#715) + if font:getWidth(text) <= maxW then return text end local ell = "..." local ew = font:getWidth(ell) while #text > 0 and font:getWidth(text) + ew > maxW do diff --git a/tools/save-editor/panels/Boxes.lua b/tools/save-editor/panels/Boxes.lua index f003d2ab..e3975f9e 100644 --- a/tools/save-editor/panels/Boxes.lua +++ b/tools/save-editor/panels/Boxes.lua @@ -1,11 +1,23 @@ -- Boxes panel: the 12 PC boxes as a real grid rather than the old 20-row --- text list. Three columns: +-- text list. Three columns at full width: -- box strip which boxes have room, so you can see where a deposit lands --- the grid 5 x 4 = Boxes.CAPACITY, empty cells are dashed and clickable +-- the grid empty cells are dashed and clickable -- party dock the deposit source and withdraw target, both in one place -- -- Selecting a slot points S.editingMon at it, so switching to the Party tab -- keeps inspecting the same mon. +-- +-- #715 reflow: the three columns need about 900 real px. Below that the +-- panel stacks the grid over the party dock at full width and drops the box +-- strip (the grid header's < > steppers and Box N counter cover its job). +-- The grid's column count adapts to the width it actually gets, the dock's +-- roster scrolls, and the action labels shorten when the row is tight, so +-- no button ever paints over its neighbour. +-- +-- Adding a mon opens the same searchable species picker the inspector uses +-- (see SpeciesPicker.lua / Ops.openBoxAddPicker): the picked species lands +-- in the selected box as a Lv5 mon built by the same MonOps path partyAdd +-- uses, so its stats, exp and moves are consistent. local BoxesMod = require("src.pokemon.Boxes") local PartyMod = require("src.pokemon.Party") @@ -16,24 +28,10 @@ local PAL = Theme.PAL local M = {} local COLS = 5 -local ROWS = math.ceil(BoxesMod.CAPACITY / COLS) -function M.draw(S, Kit, x, y, w, h) +local function drawStrip(S, Kit, boxes, x, y, stripW, h) local s = Kit.scale - local gap = 20 * s local pad = 16 * s - - S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, BoxesMod.COUNT) - S.save.currentBox = S.selectedBox - local boxes = Ops.boxes(S) - local box = boxes[S.selectedBox] - - local stripW = math.max(150 * s, math.min(200 * s, w * 0.16)) - local dockW = math.max(220 * s, math.min(300 * s, w * 0.22)) - local gridX = x + stripW + gap - local gridW = w - stripW - dockW - 2 * gap - - -- ------------------------------------------------------------ box strip Kit.card(x, y, stripW, h) Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(BoxesMod.COUNT)) local stripTop = y + pad + Kit.textHeight("caption") + 10 * s @@ -56,8 +54,10 @@ function M.draw(S, Kit, x, y, w, h) Kit.meter(mx, ry + (bRowH - 5 * s) / 2, 44 * s, 5 * s, fill / BoxesMod.CAPACITY * 100, fill >= BoxesMod.CAPACITY and PAL.yellow or PAL.blue) end +end - -- ------------------------------------------------------------- the grid +local function drawGrid(S, Kit, box, gridX, y, gridW, h) + local s = Kit.scale Kit.card(gridX, y, gridW, h) local gpad = 18 * s local gx = gridX + gpad @@ -78,17 +78,59 @@ function M.draw(S, Kit, x, y, w, h) Ops.stepBox(S, 1) end + -- Bottom action row, measured before it is drawn (#715): full labels when + -- they fit side by side, short verbs when they do not, so Withdraw / Add / + -- Release can never stack on each other the way the fixed offsets did. local actH = 34 * s local actY = y + h - gpad - actH + local wdLabel, addLabel = "Withdraw to party", "+ Add mon here" + local relLabel = Ops.armLabel(S, "box-release", "Release") + local function widths() + return Kit.textWidth("small", wdLabel) + 22 * s, + Kit.textWidth("small", addLabel) + 22 * s, + Kit.textWidth("small", relLabel) + 22 * s + end + local wdW, addW, relW = widths() + if wdW + addW + relW + 20 * s > ginner then + wdLabel, addLabel = "Withdraw", "+ Add" + wdW, addW, relW = widths() + end + if Kit.button(gx, actY, wdW, actH, wdLabel, + { font = "small", radius = 9 * s, + enabled = #S.save.party < PartyMod.MAX }) then + Ops.withdraw(S) + end + if Kit.button(gx + wdW + 10 * s, actY, addW, actH, addLabel, + { font = "small", radius = 9 * s, + enabled = #box < BoxesMod.CAPACITY }) then + Ops.openBoxAddPicker(S, Kit) + end + if Kit.button(gx + ginner - relW, actY, relW, actH, relLabel, + { kind = "danger", font = "small", radius = 9 * s }) then + Ops.release(S) + end + + -- ------------------------------------------------------------- the grid local gridTop = y + gpad + headH + 14 * s local gridH = actY - 14 * s - gridTop local cellGap = 10 * s - local cellW = (ginner - cellGap * (COLS - 1)) / COLS - local cellH = math.min((gridH - cellGap * (ROWS - 1)) / ROWS, 110 * s) + -- Columns adapt to the real width (#715): a cell needs ~86px before its + -- name reads, so a narrow card gets fewer, taller-stacked columns instead + -- of five slivers. + local cols = math.max(2, math.min(COLS, + math.floor((ginner + cellGap) / (86 * s + cellGap)))) + local rows = math.ceil(BoxesMod.CAPACITY / cols) + local cellW = math.max(0, (ginner - cellGap * (cols - 1)) / cols) + -- floor at Kit's 26px tap target so a short window shrinks the cells but + -- never inverts them (#715); overflow clips inside the grid body rather + -- than running over the action row, and the clip fences the hit tests + local cellH = math.max(26 * s, + math.min((gridH - cellGap * (rows - 1)) / rows, 110 * s)) + Kit.pushClip(gx, gridTop, ginner, gridH) for i = 1, BoxesMod.CAPACITY do - local cc = (i - 1) % COLS - local cr = math.floor((i - 1) / COLS) + local cc = (i - 1) % cols + local cr = math.floor((i - 1) / cols) local bx = gx + cc * (cellW + cellGap) local by = gridTop + cr * (cellH + cellGap) local mon = box[i] @@ -104,7 +146,8 @@ function M.draw(S, Kit, x, y, w, h) Kit.ellipsize("mono", mon.species, cellW - 12 * s), bx, by + cellH / 2 - Kit.textHeight("mono") / 2, cellW, PAL.text) else - -- empty slots are dashed and clickable: clicking one adds a mon there + -- empty slots are dashed and clickable: clicking one opens the species + -- picker to add a mon there Theme.col(PAL.cardBorder, Kit.hover(bx, by, cellW, cellH) and 0.6 or 0.32) Theme.dashed(bx, by, cellW, cellH, 11 * s, 6 * s, 5 * s) Kit.text("micro", tostring(i), bx + 10 * s, by + 8 * s, PAL.faint) @@ -112,31 +155,16 @@ function M.draw(S, Kit, x, y, w, h) cellW, PAL.faint) if Kit.press(bx, by, cellW, cellH) then S.selectedBoxSlot = math.min(i, #box + 1) - Ops.boxAdd(S) + Ops.openBoxAddPicker(S, Kit) end end end + Kit.popClip() +end - local wdW = 170 * s - if Kit.button(gx, actY, wdW, actH, "Withdraw to party", - { font = "small", radius = 9 * s, - enabled = #S.save.party < PartyMod.MAX }) then - Ops.withdraw(S) - end - if Kit.button(gx + wdW + 10 * s, actY, 140 * s, actH, "+ Add mon here", - { font = "small", radius = 9 * s, - enabled = #box < BoxesMod.CAPACITY }) then - Ops.boxAdd(S) - end - local relW = 110 * s - if Kit.button(gx + ginner - relW, actY, relW, actH, - Ops.armLabel(S, "box-release", "Release"), - { kind = "danger", font = "small", radius = 9 * s }) then - Ops.release(S) - end - - -- ----------------------------------------------------------- party dock - local dx = gridX + gridW + gap +local function drawDock(S, Kit, dx, y, dockW, h) + local s = Kit.scale + local pad = 16 * s Kit.card(dx, y, dockW, h) Kit.caption(dx + pad, y + pad, "PARTY DOCK") Kit.textRight("mono", ("%d/%d"):format(#S.save.party, PartyMod.MAX), @@ -144,35 +172,71 @@ function M.draw(S, Kit, x, y, w, h) local dTop = y + pad + Kit.textHeight("caption") + 10 * s local dInner = dockW - 2 * pad local dRowH = 34 * s - for i, mon in ipairs(S.save.party) do - local ry = dTop + (i - 1) * (dRowH + 7 * s) - if Kit.row(dx + pad, ry, dInner, dRowH, S.editingMon == mon, PAL.green, 9 * s) then - Ops.selectParty(S, i) - end - local lv = ("Lv%d"):format(mon.level) - local lvW = Kit.textWidth("tiny", lv) - Kit.textRight("tiny", lv, dx + pad + dInner - 10 * s, - ry + (dRowH - Kit.textHeight("tiny")) / 2, PAL.caption) - Kit.text("mono", Kit.ellipsize("mono", mon.species, dInner - 30 * s - lvW), - dx + pad + 10 * s, ry + (dRowH - Kit.textHeight("mono")) / 2, PAL.text) - end + local dGap = 7 * s + + -- Deposit is pinned to the card bottom and the roster scrolls above it + -- (#715): six party rows used to be laid out unconditionally and the + -- button drawn below them, which on a short card walked both straight out + -- of the card. + local depH = 36 * s + local depY = y + h - pad - depH + local listH = math.max(0, depY - 10 * s - dTop) + if #S.save.party == 0 then - Kit.emptyBox(dx + pad, dTop, dInner, 70 * s, "Party is empty.") + Kit.emptyBox(dx + pad, dTop, dInner, math.min(listH, 70 * s), "Party is empty.") + else + local visible = math.max(1, math.floor((listH + dGap) / (dRowH + dGap))) + S.dockOffset = Kit.scroll(dx + pad, dTop, dInner, listH, + S.dockOffset or 0, #S.save.party, visible) + Kit.pushClip(dx + pad, dTop, dInner, listH) + for i = 1, visible do + local slot = S.dockOffset + i + local mon = S.save.party[slot] + if not mon then break end + local ry = dTop + (i - 1) * (dRowH + dGap) + if Kit.row(dx + pad, ry, dInner, dRowH, S.editingMon == mon, PAL.green, 9 * s) then + Ops.selectParty(S, slot) + end + local lv = ("Lv%d"):format(mon.level) + local lvW = Kit.textWidth("tiny", lv) + Kit.textRight("tiny", lv, dx + pad + dInner - 10 * s, + ry + (dRowH - Kit.textHeight("tiny")) / 2, PAL.caption) + Kit.text("mono", Kit.ellipsize("mono", mon.species, dInner - 30 * s - lvW), + dx + pad + 10 * s, ry + (dRowH - Kit.textHeight("mono")) / 2, PAL.text) + end + Kit.popClip() + Kit.scrollbar(dx + pad, dTop, dInner, listH, + S.dockOffset, #S.save.party, math.max(1, math.floor((listH + dGap) / (dRowH + dGap)))) end - local depY = dTop + math.max(#S.save.party, 2) * (dRowH + 7 * s) + 6 * s - if Kit.button(dx + pad, depY, dInner, 36 * s, "Deposit selected slot", + if Kit.button(dx + pad, depY, dInner, depH, "Deposit selected slot", { kind = "accent", font = "small", radius = 9 * s, enabled = #S.save.party > 0 }) then Ops.deposit(S) end - local noteY = depY + 36 * s + 10 * s - local noteH = y + h - pad - noteY - if noteH > Kit.textHeight("tiny") * 2 then - Kit.textCenter("tiny", - "Deposit fills the current box first, then the next box with room, and " .. - "the status bar says where the mon landed.", - dx + pad, noteY, dInner, PAL.caption) +end + +function M.draw(S, Kit, x, y, w, h) + local s = Kit.scale + local gap = 20 * s + + S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, BoxesMod.COUNT) + S.save.currentBox = S.selectedBox + local boxes = Ops.boxes(S) + local box = boxes[S.selectedBox] + + if w < 900 * s then + -- stacked (#715): grid over dock, strip dropped (see the header comment) + local dockH = Theme.clamp(h * 0.38, 140 * s, 320 * s) + drawGrid(S, Kit, box, x, y, w, h - dockH - gap) + drawDock(S, Kit, x, y + h - dockH, w, dockH) + else + local stripW = math.max(150 * s, math.min(200 * s, w * 0.16)) + local dockW = math.max(220 * s, math.min(300 * s, w * 0.22)) + local gridW = w - stripW - dockW - 2 * gap + drawStrip(S, Kit, boxes, x, y, stripW, h) + drawGrid(S, Kit, box, x + stripW + gap, y, gridW, h) + drawDock(S, Kit, x + w - dockW, y, dockW, h) end end diff --git a/tools/save-editor/panels/Dex.lua b/tools/save-editor/panels/Dex.lua index f9988dc4..f8b1629c 100644 --- a/tools/save-editor/panels/Dex.lua +++ b/tools/save-editor/panels/Dex.lua @@ -12,7 +12,13 @@ local PAL = Theme.PAL local M = {} -local COLS = 4 +-- Grid columns adapt to the card width: four at the design size, fewer on a +-- phone so the name and the two chips stay readable instead of shearing into +-- each other (#715). 180 logical px is the narrowest a row reads at. +local MAX_COLS = 4 +local function colsFor(inner, s) + return math.max(1, math.min(MAX_COLS, math.floor(inner / (180 * s)))) +end function M.draw(S, Kit, x, y, w, h) local s = Kit.scale @@ -33,36 +39,60 @@ function M.draw(S, Kit, x, y, w, h) local headW = math.max(Kit.captionWidth("POKEDEX"), Kit.textWidth("headline", ("%d / %d owned"):format(owned, total))) - -- bulk actions, laid out from the right edge inward + -- bulk actions, measured first (#715): at full width they sit right-aligned + -- on the headline; on a narrower card they take rows of their own below it + -- and FLOW, wrapping to further rows when even one is too narrow, so the + -- cluster can never paint over the headline or over itself. local actH = 34 * s - local actY = y + pad + (headH - actH) / 2 local buttons = { { label = "Own party + boxes", kind = "ghost", fn = Ops.dexStamp }, { label = "See all", kind = "accent", fn = Ops.dexSeeAll }, { label = "Own all", kind = "good", fn = Ops.dexOwnAll }, + { label = Ops.armLabel(S, "dex-clear", "Wipe dex"), kind = "danger", + fn = Ops.dexClear }, } - local rightEdge = cx + inner - local clearLabel = Ops.armLabel(S, "dex-clear", "Wipe dex") - local clearW = Kit.textWidth("small", clearLabel) + 32 * s - rightEdge = rightEdge - clearW - if Kit.button(rightEdge, actY, clearW, actH, clearLabel, - { kind = "danger", font = "small", radius = 9 * s }) then - Ops.dexClear(S) + local clusterW = -10 * s + for _, b in ipairs(buttons) do + clusterW = clusterW + 10 * s + Kit.textWidth("small", b.label) + 32 * s end - for i = #buttons, 1, -1 do - local b = buttons[i] - local bw = Kit.textWidth("small", b.label) + 32 * s - rightEdge = rightEdge - 10 * s - bw - if Kit.button(rightEdge, actY, bw, actH, b.label, - { kind = b.kind, font = "small", radius = 9 * s }) then - b.fn(S) + local ownRow = clusterW > inner - headW - 24 * s + local actRows = 1 + local rightEdge = cx + inner + if not ownRow then + local actY = y + pad + (headH - actH) / 2 + for i = #buttons, 1, -1 do + local b = buttons[i] + local bw = Kit.textWidth("small", b.label) + 32 * s + rightEdge = rightEdge - bw + if Kit.button(rightEdge, actY, bw, actH, b.label, + { kind = b.kind, font = "small", radius = 9 * s }) then + b.fn(S) + end + rightEdge = rightEdge - 10 * s + end + rightEdge = rightEdge + 10 * s + else + local bx = cx + local by = y + pad + headH + 10 * s + for _, b in ipairs(buttons) do + local bw = Kit.textWidth("small", b.label) + 32 * s + if bx > cx and bx + bw > cx + inner then + bx = cx + by = by + actH + 8 * s + actRows = actRows + 1 + end + if Kit.button(bx, by, bw, actH, b.label, + { kind = b.kind, font = "small", radius = 9 * s }) then + b.fn(S) + end + bx = bx + bw + 10 * s end end -- the two completion meters fill whatever the header leaves between the - -- headline and the button cluster + -- headline and the button cluster (the full line, when the cluster wrapped) local meterX = cx + headW + 24 * s - local meterW = rightEdge - 24 * s - meterX + local meterW = (ownRow and cx + inner or rightEdge) - 24 * s - meterX if meterW > 120 * s then local my = y + pad Kit.text("tiny", "SEEN", meterX, my, PAL.caption) @@ -77,23 +107,32 @@ function M.draw(S, Kit, x, y, w, h) end -- --------------------------------------------------------- species grid + local cols = colsFor(inner, s) local pagerH = 30 * s local pagerY = y + h - pad - pagerH local gridTop = y + pad + headH + 18 * s + + (ownRow and actRows * (actH + 8 * s) + 2 * s or 0) local rowH = 38 * s local rowGap = 8 * s local colGap = 16 * s - local colW = (inner - colGap * (COLS - 1)) / COLS - local perCol = math.max(1, math.floor((pagerY - 12 * s - gridTop) / (rowH + rowGap))) - local perPage = perCol * COLS + local colW = (inner - colGap * (cols - 1)) / cols + local gridH = pagerY - 12 * s - gridTop + local perCol = math.max(1, math.floor(gridH / (rowH + rowGap))) + local perPage = perCol * cols S.dexOffset = Ops.clamp(S.dexOffset or 0, 0, math.max(0, #species - perPage)) + -- wheel / touch drag move whole grid rows so the columns never shear (#715) + S.dexOffset = Kit.scroll(cx, gridTop, inner, gridH, S.dexOffset, + #species, perPage, cols) local chipW = 46 * s local chipH = 22 * s + -- clip the grid body so a too-short window clips the last partial row + -- (and fences its hits) instead of drawing it over the pager (#715) + Kit.pushClip(cx, gridTop, inner, gridH) for i = 1, math.min(perPage, #species - S.dexOffset) do local id = species[S.dexOffset + i] - local ci = (i - 1) % COLS - local ri = math.floor((i - 1) / COLS) + local ci = (i - 1) % cols + local ri = math.floor((i - 1) / cols) local rx = cx + ci * (colW + colGap) local ry = gridTop + ri * (rowH + rowGap) local isSeen = dex.seen[id] == true @@ -119,7 +158,9 @@ function M.draw(S, Kit, x, y, w, h) Ops.dexOwned(S, id, not isOwned) end end + Kit.popClip() + Kit.scrollbar(cx, gridTop, inner, gridH, S.dexOffset, #species, perPage) S.dexOffset = Kit.pager(cx, pagerY, inner, S.dexOffset, #species, perPage) end diff --git a/tools/save-editor/panels/Events.lua b/tools/save-editor/panels/Events.lua index a27777ee..70b075f7 100644 --- a/tools/save-editor/panels/Events.lua +++ b/tools/save-editor/panels/Events.lua @@ -102,19 +102,26 @@ function M.draw(S, Kit, x, y, w, h) local inner = w - 2 * pad -- ------------------------------------------------------------ sub-tabs + -- The pills flow left to right and WRAP when the card is too narrow to + -- hold all four on one line (#715): a fixed row used to run the last pill + -- past the card edge. local pillH = 32 * s - local px = cx + local px, py = cx, y + pad for _, t in ipairs(SUB_TABS) do local pw = Kit.textWidth("small", t.label) + 32 * s + if px > cx and px + pw > cx + inner then + px = cx + py = py + pillH + 8 * s + end local active = (S.eventsTab == t.id) Theme.col(PAL.rowBg, 0.6) - love.graphics.rectangle("fill", px, y + pad, pw, pillH, pillH / 2, pillH / 2) - Theme.stroke(px, y + pad, pw, pillH, pillH / 2, + love.graphics.rectangle("fill", px, py, pw, pillH, pillH / 2, pillH / 2) + Theme.stroke(px, py, pw, pillH, pillH / 2, active and PAL.blue or PAL.cardBorder, active and 0.8 or 0.24, active and 1.5 * s or 1) - Kit.textCenter("small", t.label, px, y + pad + (pillH - Kit.textHeight("small")) / 2, + Kit.textCenter("small", t.label, px, py + (pillH - Kit.textHeight("small")) / 2, pw, active and PAL.heading or PAL.muted) - if Kit.press(px, y + pad, pw, pillH) then + if Kit.press(px, py, pw, pillH) then S.eventsTab = t.id S.eventsOffset = 0 Ops.disarm(S) @@ -123,12 +130,21 @@ function M.draw(S, Kit, x, y, w, h) px = px + pw + 10 * s end + -- The filter shares the last pill row when there is room for at least a + -- usable field beside the pills; on a narrow window it wraps onto its own + -- row instead of painting over the last pill (#715). local clearW = 74 * s - local fieldW = math.min(280 * s, math.max(140 * s, cx + inner - clearW - 10 * s - px - 10 * s)) + local filterY = py + local availF = cx + inner - clearW - 10 * s - px - 10 * s + if availF < 120 * s then + filterY = py + pillH + 8 * s + availF = inner - clearW - 10 * s + end + local fieldW = math.min(280 * s, math.max(120 * s, availF)) local fieldX = cx + inner - clearW - 10 * s - fieldW - S.eventFilter = Kit.textfield("event-filter", fieldX, y + pad, fieldW, pillH, + S.eventFilter = Kit.textfield("event-filter", fieldX, filterY, fieldW, pillH, S.eventFilter, "filter keys...") - if Kit.button(cx + inner - clearW, y + pad, clearW, pillH, "Clear", + if Kit.button(cx + inner - clearW, filterY, clearW, pillH, "Clear", { kind = "accent", font = "small", radius = 8 * s, enabled = S.eventFilter ~= "" }) then S.eventFilter = "" @@ -136,8 +152,9 @@ function M.draw(S, Kit, x, y, w, h) Ops.say(S, "Filter cleared") end - local hintY = y + pad + pillH + 10 * s - Kit.text("small", HINTS[S.eventsTab] or "", cx, hintY, PAL.caption) + local hintY = filterY + pillH + 10 * s + Kit.text("small", Kit.ellipsize("small", HINTS[S.eventsTab] or "", inner), + cx, hintY, PAL.caption) -- ---------------------------------------------------------- row grid local rows = buildRows(S) @@ -147,21 +164,31 @@ function M.draw(S, Kit, x, y, w, h) local rowH = 34 * s local rowGap = 8 * s local colGap = 20 * s - local colW = (inner - colGap) / 2 - local perCol = math.max(1, math.floor((pagerY - 12 * s - gridTop) / (rowH + rowGap))) - local perPage = perCol * 2 + -- two columns need ~460 logical px before the checkbox labels read; a + -- phone gets one full-width column instead of two crushed ones (#715) + local cols = (inner >= 460 * s) and 2 or 1 + local colW = (inner - colGap * (cols - 1)) / cols + local gridH = pagerY - 12 * s - gridTop + local perCol = math.max(1, math.floor(gridH / (rowH + rowGap))) + local perPage = perCol * cols S.eventsOffset = Ops.clamp(S.eventsOffset or 0, 0, math.max(0, #rows - perPage)) + -- wheel / touch drag move whole grid rows, same contract as the pager (#715) + S.eventsOffset = Kit.scroll(cx, gridTop, inner, gridH, S.eventsOffset, + #rows, perPage, cols) if #rows == 0 then - Kit.emptyBox(cx, gridTop, inner, 80 * s, + Kit.emptyBox(cx, gridTop, inner, math.min(gridH, 80 * s), S.eventFilter ~= "" and "No key matches that filter." or "Nothing recorded here yet.") end + -- clip the grid body: on a window too short for even one row the partial + -- row clips (and its hit test is fenced) instead of covering the pager (#715) + Kit.pushClip(cx, gridTop, inner, gridH) for i = 1, math.min(perPage, #rows - S.eventsOffset) do local row = rows[S.eventsOffset + i] - local ci = (i - 1) % 2 - local ri = math.floor((i - 1) / 2) + local ci = (i - 1) % cols + local ri = math.floor((i - 1) / cols) local rx = cx + ci * (colW + colGap) local ry = gridTop + ri * (rowH + rowGap) if row.header then @@ -175,23 +202,30 @@ function M.draw(S, Kit, x, y, w, h) if changed then row.set(newChecked) end end end + Kit.popClip() - S.eventsOffset = Kit.pager(cx, pagerY, inner, S.eventsOffset, #rows, perPage) + Kit.scrollbar(cx, gridTop, inner, gridH, S.eventsOffset, #rows, perPage) -- "Clear all" only makes sense for the two key tables the editor owns - -- wholesale; flags and object toggles are cleared one row at a time. + -- wholesale; flags and object toggles are cleared one row at a time. Its + -- width is reserved BEFORE the pager draws, so the pager's counter yields + -- to the button instead of running underneath it (#715). local clearKey = (S.eventsTab == "trainers" and "defeatedTrainers") or (S.eventsTab == "items" and "itemsTaken") or nil + local clearBw = 0 if clearKey then local label = (S.eventsTab == "trainers") and "Clear all trainers" or "Clear all items taken" - local bw = Kit.textWidth("small", label) + 32 * s - if Kit.button(cx + inner - bw, pagerY, bw, pagerH, + clearBw = Kit.textWidth("small", label) + 32 * s + if Kit.button(cx + inner - clearBw, pagerY, clearBw, pagerH, Ops.armLabel(S, "clear-" .. clearKey, label), { kind = "danger", font = "small", radius = 8 * s }) then Ops.clearTable(S, clearKey, label:gsub("^Clear all ", "")) end + clearBw = clearBw + 10 * s end + S.eventsOffset = Kit.pager(cx, pagerY, inner - clearBw, S.eventsOffset, + #rows, perPage) end return M diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index 3a40b68c..b78d45cf 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -8,6 +8,12 @@ -- typing used to be the only way to reach an id past the first screenful. -- Badges sit in the wallet column as toggle chips because they are boolean -- inventory flags, not stackable items, and must not look like quantity rows. +-- +-- #715 reflow: side by side the wallet column plus the two quantity lists +-- need about 900 real px (a quantity row's -/+/x cluster alone is ~110px). +-- Below that the five cards stack in one full-width column that scrolls in +-- pixels (Kit.scrollPixels); the inner lists keep their own wheel/drag +-- regions, which claim the notch first when the pointer is over them. local Bag = require("src.inventory.Bag") local Theme = require("Theme") @@ -50,35 +56,30 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu return clicked end -function M.draw(S, Kit, x, y, w, h) - local s = Kit.scale - local gap = 20 * s - local pad = 16 * s - Ops.pcItems(S) +-- ---------------------------------------------------------------- sections +-- Each card is a function of its own rect so the wide (three column) and the +-- stacked (#715) layouts are the same drawing code with different geometry. - local leftW = math.max(260 * s, math.min(320 * s, w * 0.26)) - local listW = (w - leftW - 2 * gap) / 2 - local bagX = x + leftW + gap - local pcX = bagX + listW + gap - - -- ------------------------------------------------------------- money - -- Money and badges are fixed-height so the picker gets every pixel left - -- over: cycling through ~250 item ids in a two-row list was the thing that - -- made the old panel unusable. - local moneyH = pad * 2 + Kit.textHeight("caption") + 8 * s +local function moneyHeight(Kit, s, pad) + return pad * 2 + Kit.textHeight("caption") + 8 * s + Kit.textHeight("headline") + 10 * s + 30 * s - Kit.card(x, y, leftW, moneyH) +end + +local function drawMoney(S, Kit, x, y, w, h) + local s = Kit.scale + local pad = 16 * s + Kit.card(x, y, w, h) Kit.caption(x + pad, y + pad, "MONEY") local maxW = 74 * s - if Kit.button(x + leftW - pad - maxW, y + pad - 4 * s, maxW, 26 * s, "Max out", + if Kit.button(x + w - pad - maxW, y + pad - 4 * s, maxW, 26 * s, "Max out", { kind = "accent", font = "tiny", radius = 7 * s, enabled = (S.save.money or 0) < Ops.MONEY_MAX }) then Ops.maxMoney(S) end Kit.text("headline", ("$%d"):format(S.save.money or 0), x + pad, y + pad + Kit.textHeight("caption") + 8 * s, PAL.yellow) - local mbY = y + moneyH - pad - 30 * s - local mbW = (leftW - 2 * pad - 3 * 8 * s) / 4 + local mbY = y + h - pad - 30 * s + local mbW = (w - 2 * pad - 3 * 8 * s) / 4 for i, delta in ipairs(MONEY_STEPS) do local label = (delta > 0 and "+" or "") .. tostring(delta) if Kit.button(x + pad + (i - 1) * (mbW + 8 * s), mbY, mbW, 30 * s, label, @@ -86,20 +87,54 @@ function M.draw(S, Kit, x, y, w, h) Ops.addMoney(S, delta) end end +end - -- ------------------------------------------------------------ picker - local badgeIds = Ops.badgeIds(S) - local badgeCols = 4 - local badgeRows = math.ceil(#badgeIds / badgeCols) - local badgeH = pad * 2 + Kit.textHeight("caption") + 10 * s +local BADGE_COLS = 4 + +local function badgeHeight(S, Kit, s, pad) + local badgeRows = math.ceil(#Ops.badgeIds(S) / BADGE_COLS) + return pad * 2 + Kit.textHeight("caption") + 10 * s + badgeRows * (28 * s + 7 * s) - 7 * s - local pickY = y + moneyH + gap - local pickH = h - moneyH - badgeH - 2 * gap - Kit.card(x, pickY, leftW, pickH) - Kit.caption(x + pad, pickY + pad, "ADD ITEM") - local qy = pickY + pad + Kit.textHeight("caption") + 8 * s +end + +local function drawBadges(S, Kit, x, y, w, h) + local s = Kit.scale + local pad = 16 * s + local badgeIds = Ops.badgeIds(S) + Kit.card(x, y, w, h) + local earned = 0 + for _, id in ipairs(badgeIds) do + -- #515: truthy check, not `== true` -- the in-game grant path stores a + -- number (see OverworldController.lua checkVictoryRewards), matching + -- src/inventory/Badges.lua's own truthy read. + if S.save.inventory[id] then earned = earned + 1 end + end + Kit.caption(x + pad, y + pad, "BADGES") + Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + w - pad, + y + pad, PAL.caption) + local bTop = y + pad + Kit.textHeight("caption") + 10 * s + local bW = (w - 2 * pad - (BADGE_COLS - 1) * 7 * s) / BADGE_COLS + for i, id in ipairs(badgeIds) do + local bc = (i - 1) % BADGE_COLS + local br = math.floor((i - 1) / BADGE_COLS) + local on = S.save.inventory[id] + local short = id:gsub("BADGE$", "") + if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s), + bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on, + PAL.green, PAL.steel) then + Ops.toggleBadge(S, id) + end + end +end + +local function drawPicker(S, Kit, x, y, w, h) + local s = Kit.scale + local pad = 16 * s + Kit.card(x, y, w, h) + Kit.caption(x + pad, y + pad, "ADD ITEM") + local qy = y + pad + Kit.textHeight("caption") + 8 * s local prevQuery = S.itemQuery or "" - S.itemQuery = Kit.textfield("item-query", x + pad, qy, leftW - 2 * pad, 32 * s, + S.itemQuery = Kit.textfield("item-query", x + pad, qy, w - 2 * pad, 32 * s, S.itemQuery or "", "search item ids...") -- a new query is a new list: keep the first hit on screen rather than -- leaving the view parked wherever the old result set had scrolled to @@ -116,7 +151,7 @@ function M.draw(S, Kit, x, y, w, h) end local addH = 32 * s - local addY = pickY + pickH - pad - addH + local addY = y + h - pad - addH local listTop = qy + 32 * s + 10 * s local listBottom = addY - 10 * s local cRowH = 28 * s @@ -125,32 +160,36 @@ function M.draw(S, Kit, x, y, w, h) -- #595: the wheel drives the same offset a pager would, so the whole -- catalog is reachable with the mouse alone. Kit.scroll clamps, which is -- also what pulls the view back when a narrower query shortens the list. - S.itemPickOffset = Kit.scroll(x + pad, listTop, leftW - 2 * pad, + S.itemPickOffset = Kit.scroll(x + pad, listTop, w - 2 * pad, listBottom - listTop, S.itemPickOffset or 0, #choices, visible) - Kit.pushClip(x + pad, listTop, leftW - 2 * pad, listBottom - listTop) + Kit.pushClip(x + pad, listTop, w - 2 * pad, listBottom - listTop) for i = 1, math.min(visible, #choices - S.itemPickOffset) do local id = choices[S.itemPickOffset + i] local ry = listTop + (i - 1) * (cRowH + cGap) - if Kit.row(x + pad, ry, leftW - 2 * pad, cRowH, id == S.selectedItemId, + if Kit.row(x + pad, ry, w - 2 * pad, cRowH, id == S.selectedItemId, PAL.green, 8 * s) then S.selectedItemId = id Ops.say(S, "Picked " .. id) end - Kit.text("mono", Kit.ellipsize("mono", id, leftW - 2 * pad - 20 * s), + Kit.text("mono", Kit.ellipsize("mono", id, w - 2 * pad - 20 * s), x + pad + 10 * s, ry + (cRowH - Kit.textHeight("mono")) / 2, PAL.text) end Kit.popClip() + -- the drag/wheel offset is also made visible: on a phone the list looked + -- bottomless-yet-stuck without an indicator (#715) + Kit.scrollbar(x + pad, listTop, w - 2 * pad, listBottom - listTop, + S.itemPickOffset, #choices, visible) -- the position counter rides the caption line, where it can never collide -- with the list body or the two add buttons below it if #choices > visible then Kit.textRight("micro", ("%d-%d of %d"):format(S.itemPickOffset + 1, math.min(S.itemPickOffset + visible, #choices), #choices), - x + leftW - pad, pickY + pad, PAL.faint) + x + w - pad, y + pad, PAL.faint) elseif #choices == 0 then Kit.text("mono", "no item matches", x + pad + 10 * s, listTop + 8 * s, PAL.faint) end - local halfW = (leftW - 2 * pad - 8 * s) / 2 + local halfW = (w - 2 * pad - 8 * s) / 2 if Kit.button(x + pad, addY, halfW, addH, "-> Bag", { font = "small", radius = 8 * s, enabled = S.selectedItemId ~= nil }) then Ops.addToBag(S, S.selectedItemId) @@ -159,102 +198,141 @@ function M.draw(S, Kit, x, y, w, h) { font = "small", radius = 8 * s, enabled = S.selectedItemId ~= nil }) then Ops.addToPc(S, S.selectedItemId) end +end - -- ------------------------------------------------------------ badges - local badgeY = y + h - badgeH - Kit.card(x, badgeY, leftW, badgeH) - local earned = 0 - for _, id in ipairs(badgeIds) do - -- #515: truthy check, not `== true` -- the in-game grant path stores a - -- number (see OverworldController.lua checkVictoryRewards), matching - -- src/inventory/Badges.lua's own truthy read. - if S.save.inventory[id] then earned = earned + 1 end +-- The bag and PC cards share one shape: a caption line, an optional meter, +-- a quantity-row list with wheel/drag + pager. +local function drawQuantityCard(S, Kit, x, y, w, h, cfg) + local s = Kit.scale + local pad = 16 * s + Kit.card(x, y, w, h) + Kit.caption(x + pad, y + pad, cfg.title) + Kit.textRight("mono", cfg.counter, x + w - pad, y + pad, PAL.caption) + local rowsTop = y + pad + Kit.textHeight("caption") + 8 * s + if cfg.meterFrac then + Kit.meter(x + pad, rowsTop, w - 2 * pad, 5 * s, cfg.meterFrac * 100, + cfg.meterFrac >= 1 and PAL.yellow or PAL.blue) + rowsTop = rowsTop + 5 * s + 12 * s + else + rowsTop = rowsTop + 12 * s end - Kit.caption(x + pad, badgeY + pad, "BADGES") - Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + leftW - pad, - badgeY + pad, PAL.caption) - local bTop = badgeY + pad + Kit.textHeight("caption") + 10 * s - local bW = (leftW - 2 * pad - (badgeCols - 1) * 7 * s) / badgeCols - for i, id in ipairs(badgeIds) do - local bc = (i - 1) % badgeCols - local br = math.floor((i - 1) / badgeCols) - local on = S.save.inventory[id] - local short = id:gsub("BADGE$", "") - if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s), - bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on, - PAL.green, PAL.steel) then - Ops.toggleBadge(S, id) - end - end - - -- --------------------------------------------------------------- bag - local order = Bag.order(S.save) - local capacity = Bag.capacity(S.data) - Kit.card(bagX, y, listW, h) - Kit.caption(bagX + pad, y + pad, "BAG") - Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), capacity), - bagX + listW - pad, y + pad, PAL.caption) - local barY = y + pad + Kit.textHeight("caption") + 8 * s - local slotFrac = Bag.slots(S.save) / capacity - Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100, - slotFrac >= 1 and PAL.yellow or PAL.blue) local pagerH = 30 * s local pagerY = y + h - pad - pagerH - local rowsTop = barY + 5 * s + 12 * s local rowH = 36 * s local rowGap = 6 * s - local perPage = math.max(1, math.floor((pagerY - 12 * s - rowsTop) / (rowH + rowGap))) - S.bagOffset = Ops.clamp(S.bagOffset or 0, 0, math.max(0, #order - perPage)) + local listH = pagerY - 12 * s - rowsTop + local perPage = math.max(1, math.floor(listH / (rowH + rowGap))) + local order = cfg.order + local offset = Ops.clamp(cfg.offset or 0, 0, math.max(0, #order - perPage)) -- the wheel moves the same offset the pager below does (#595) - S.bagOffset = Kit.scroll(bagX + pad, rowsTop, listW - 2 * pad, - pagerY - 12 * s - rowsTop, S.bagOffset, #order, perPage) + offset = Kit.scroll(x + pad, rowsTop, w - 2 * pad, listH, offset, #order, perPage) if #order == 0 then - Kit.emptyBox(bagX + pad, rowsTop, listW - 2 * pad, 70 * s, "Bag is empty.") + Kit.emptyBox(x + pad, rowsTop, w - 2 * pad, math.min(listH, 70 * s), cfg.empty) end - for i = 1, math.min(perPage, #order - S.bagOffset) do - local id = order[S.bagOffset + i] + Kit.pushClip(x + pad, rowsTop, w - 2 * pad, listH) + for i = 1, math.min(perPage, #order - offset) do + local id = order[offset + i] local ry = rowsTop + (i - 1) * (rowH + rowGap) - if quantityRow(S, Kit, bagX + pad, ry, listW - 2 * pad, rowH, id, - S.save.inventory[id] or 0, id == S.selectedBagId, - function() Ops.bagAdjust(S, id, -1) end, - function() Ops.bagAdjust(S, id, 1) end, - function() Ops.bagDrop(S, id) end) then + if quantityRow(S, Kit, x + pad, ry, w - 2 * pad, rowH, id, + cfg.qty(id), id == cfg.selected(), + function() cfg.adjust(id, -1) end, + function() cfg.adjust(id, 1) end, + function() cfg.drop(id) end) then + cfg.select(id) + end + end + Kit.popClip() + Kit.scrollbar(x + pad, rowsTop, w - 2 * pad, listH, offset, #order, perPage) + return Kit.pager(x + pad, pagerY, w - 2 * pad, offset, #order, perPage) +end + +local function drawBag(S, Kit, x, y, w, h) + local order = Bag.order(S.save) + local capacity = Bag.capacity(S.data) + S.bagOffset = drawQuantityCard(S, Kit, x, y, w, h, { + title = "BAG", + counter = ("%d/%d slots"):format(Bag.slots(S.save), capacity), + meterFrac = Bag.slots(S.save) / capacity, + order = order, + offset = S.bagOffset, + empty = "Bag is empty.", + qty = function(id) return S.save.inventory[id] or 0 end, + selected = function() return S.selectedBagId end, + select = function(id) S.selectedBagId = id Ops.say(S, ("Selected %s in the bag"):format(id)) - end - end - S.bagOffset = Kit.pager(bagX + pad, pagerY, listW - 2 * pad, S.bagOffset, - #order, perPage) + end, + adjust = function(id, d) Ops.bagAdjust(S, id, d) end, + drop = function(id) Ops.bagDrop(S, id) end, + }) +end - -- -------------------------------------------------------- pc storage +local function drawPc(S, Kit, x, y, w, h) local pcOrder = Ops.pcOrder(S) - Kit.card(pcX, y, listW, h) - Kit.caption(pcX + pad, y + pad, "PC STORAGE") - Kit.textRight("mono", ("%d kinds"):format(#pcOrder), pcX + listW - pad, - y + pad, PAL.caption) - S.pcOffset = Ops.clamp(S.pcOffset or 0, 0, math.max(0, #pcOrder - perPage)) - S.pcOffset = Kit.scroll(pcX + pad, rowsTop, listW - 2 * pad, - pagerY - 12 * s - rowsTop, S.pcOffset, #pcOrder, perPage) - if #pcOrder == 0 then - Kit.emptyBox(pcX + pad, rowsTop, listW - 2 * pad, 70 * s, - "PC storage is empty. Items sent here have no slot cap.") - end - for i = 1, math.min(perPage, #pcOrder - S.pcOffset) do - local id = pcOrder[S.pcOffset + i] - local ry = rowsTop + (i - 1) * (rowH + rowGap) - if quantityRow(S, Kit, pcX + pad, ry, listW - 2 * pad, rowH, id, - S.save.pcItems[id] or 0, id == S.selectedPcId, - function() Ops.pcAdjust(S, id, -1) end, - function() Ops.pcAdjust(S, id, 1) end, - function() Ops.pcDrop(S, id) end) then + S.pcOffset = drawQuantityCard(S, Kit, x, y, w, h, { + title = "PC STORAGE", + counter = ("%d kinds"):format(#pcOrder), + order = pcOrder, + offset = S.pcOffset, + empty = "PC storage is empty. Items sent here have no slot cap.", + qty = function(id) return S.save.pcItems[id] or 0 end, + selected = function() return S.selectedPcId end, + select = function(id) S.selectedPcId = id Ops.say(S, ("Selected %s in PC storage"):format(id)) - end + end, + adjust = function(id, d) Ops.pcAdjust(S, id, d) end, + drop = function(id) Ops.pcDrop(S, id) end, + }) +end + +function M.draw(S, Kit, x, y, w, h) + local s = Kit.scale + local gap = 20 * s + local pad = 16 * s + Ops.pcItems(S) + + if w < 900 * s then + -- stacked (#715): one full-width column, scrolled in pixels. The offset + -- from LAST frame's scrollPixels call positions this frame, and the call + -- itself comes after the cards so their inner lists claim the wheel or a + -- drag over their own bodies first. + local off = Theme.clamp(S.itemsScroll or 0, 0, + math.max(0, (S._itemsContentH or 0) - h)) + local moneyH = moneyHeight(Kit, s, pad) + local badgeH = badgeHeight(S, Kit, s, pad) + local pickH = 280 * s + local listH = 300 * s + Kit.pushClip(x, y, w, h) + local cy = y - off + drawMoney(S, Kit, x, cy, w, moneyH); cy = cy + moneyH + gap + drawPicker(S, Kit, x, cy, w, pickH); cy = cy + pickH + gap + drawBadges(S, Kit, x, cy, w, badgeH); cy = cy + badgeH + gap + drawBag(S, Kit, x, cy, w, listH); cy = cy + listH + gap + drawPc(S, Kit, x, cy, w, listH); cy = cy + listH + Kit.popClip() + S._itemsContentH = (cy + off) - y + S.itemsScroll = Kit.scrollPixels(x, y, w, h, off, S._itemsContentH) + return end - S.pcOffset = Kit.pager(pcX + pad, pagerY, listW - 2 * pad, S.pcOffset, - #pcOrder, perPage) + + local leftW = math.max(260 * s, math.min(320 * s, w * 0.26)) + local listW = (w - leftW - 2 * gap) / 2 + local bagX = x + leftW + gap + local pcX = bagX + listW + gap + + -- Money and badges are fixed-height so the picker gets every pixel left + -- over: cycling through ~250 item ids in a two-row list was the thing that + -- made the old panel unusable. + local moneyH = moneyHeight(Kit, s, pad) + local badgeH = badgeHeight(S, Kit, s, pad) + drawMoney(S, Kit, x, y, leftW, moneyH) + drawPicker(S, Kit, x, y + moneyH + gap, leftW, h - moneyH - badgeH - 2 * gap) + drawBadges(S, Kit, x, y + h - badgeH, leftW, badgeH) + drawBag(S, Kit, bagX, y, listW, h) + drawPc(S, Kit, pcX, y, listW, h) end return M diff --git a/tools/save-editor/panels/MapBrowser.lua b/tools/save-editor/panels/MapBrowser.lua index d901e402..24c02d91 100644 --- a/tools/save-editor/panels/MapBrowser.lua +++ b/tools/save-editor/panels/MapBrowser.lua @@ -156,16 +156,44 @@ function MapBrowser.draw(S, Kit, x, y, w, h) S.mapQuery = S.mapQuery or "" S.mapZoom = clampZoom(S.mapZoom or 2) + -- Column plan (#715). Side by side, the list and spawn cards claim ~470 + -- logical px before the viewport gets anything, and a portrait phone does + -- not have it: the old layout answered by laying the viewport out at a + -- negative width, which the scissor below rejected ("Can't set scissor + -- with negative width and/or height") and took the whole editor down. + -- Portrait now stacks the three cards vertically -- list, viewport, spawn + -- inspector, each full width -- and every viewport dimension is clamped at + -- zero so no window shape can reach the scissor with a negative rect. local listW = math.max(200 * s, math.min(260 * s, w * 0.2)) local sideW = math.max(230 * s, math.min(300 * s, w * 0.22)) - local viewX = x + listW + gap local viewW = w - listW - sideW - 2 * gap + local stacked = h > w or viewW < 260 * s + local lr, vr, sr -- list / viewport / spawn card rects + if stacked then + local capH = Kit.textHeight("caption") + -- list: caption, search field, three rows, pager, the goto button + local listH = math.min(math.max(0, h * 0.32), + 2 * pad + capH + 8 * s + 32 * s + 10 * s + 3 * 30 * s + 10 * s + + 30 * s + 10 * s + 34 * s) + -- spawns: caption, three 62px rows, the hint line + local sideH = math.min(math.max(0, h * 0.34), + 2 * pad + capH + 12 * s + 3 * (62 * s + 8 * s) - 8 * s + 6 * s + 30 * s) + lr = { x = x, y = y, w = w, h = listH } + vr = { x = x, y = y + listH + gap, w = w, + h = math.max(0, h - listH - sideH - 2 * gap) } + sr = { x = x, y = y + h - sideH, w = w, h = sideH } + else + lr = { x = x, y = y, w = listW, h = h } + vr = { x = x + listW + gap, y = y, w = math.max(0, viewW), h = h } + sr = { x = x + w - sideW, y = y, w = sideW, h = h } + end -- --------------------------------------------------------- the map list - Kit.card(x, y, listW, h) - Kit.caption(x + pad, y + pad, "MAPS") - local qy = y + pad + Kit.textHeight("caption") + 8 * s - S.mapQuery = Kit.textfield("map-query", x + pad, qy, listW - 2 * pad, 32 * s, + local listInner = lr.w - 2 * pad + Kit.card(lr.x, lr.y, lr.w, lr.h) + Kit.caption(lr.x + pad, lr.y + pad, "MAPS") + local qy = lr.y + pad + Kit.textHeight("caption") + 8 * s + S.mapQuery = Kit.textfield("map-query", lr.x + pad, qy, listInner, 32 * s, S.mapQuery, "search maps...") local ids = {} @@ -176,31 +204,40 @@ function MapBrowser.draw(S, Kit, x, y, w, h) end local gotoH = 34 * s - local gotoY = y + h - pad - gotoH + local gotoY = lr.y + lr.h - pad - gotoH local pagerH = 30 * s local pagerY = gotoY - 10 * s - pagerH local listTop = qy + 32 * s + 10 * s local mRowH = 26 * s local mGap = 4 * s - local perPage = math.max(1, math.floor((pagerY - 10 * s - listTop) / (mRowH + mGap))) + local listBodyH = pagerY - 10 * s - listTop + local perPage = math.max(1, math.floor(listBodyH / (mRowH + mGap))) S.mapListOffset = Ops.clamp(S.mapListOffset or 0, 0, math.max(0, #ids - perPage)) + -- wheel and touch drag reach the list too (#715): App routes the wheel to + -- zoom on this tab, so the list rides Kit's drag path and the pager alone + -- on desktop -- on a phone the drag is the difference between "stuck" and + -- scrollable. + S.mapListOffset = Kit.scroll(lr.x + pad, listTop, listInner, listBodyH, + S.mapListOffset, #ids, perPage) for i = 1, math.min(perPage, #ids - S.mapListOffset) do local id = ids[S.mapListOffset + i] local ry = listTop + (i - 1) * (mRowH + mGap) - if Kit.row(x + pad, ry, listW - 2 * pad, mRowH, id == S.mapId, PAL.blue, 7 * s) then + if Kit.row(lr.x + pad, ry, listInner, mRowH, id == S.mapId, PAL.blue, 7 * s) then MapBrowser.select(S, id) end - Kit.text("tiny", Kit.ellipsize("tiny", id, listW - 2 * pad - 18 * s), - x + pad + 9 * s, ry + (mRowH - Kit.textHeight("tiny")) / 2, + Kit.text("tiny", Kit.ellipsize("tiny", id, listInner - 18 * s), + lr.x + pad + 9 * s, ry + (mRowH - Kit.textHeight("tiny")) / 2, id == S.mapId and PAL.heading or PAL.muted) end if #ids == 0 then - Kit.text("mono", "no map matches", x + pad + 9 * s, listTop + 8 * s, PAL.faint) + Kit.text("mono", "no map matches", lr.x + pad + 9 * s, listTop + 8 * s, PAL.faint) end - S.mapListOffset = Kit.pager(x + pad, pagerY, listW - 2 * pad, S.mapListOffset, + Kit.scrollbar(lr.x + pad, listTop, listInner, listBodyH, + S.mapListOffset, #ids, perPage) + S.mapListOffset = Kit.pager(lr.x + pad, pagerY, listInner, S.mapListOffset, #ids, perPage) - if Kit.button(x + pad, gotoY, listW - 2 * pad, gotoH, "Go to save location", + if Kit.button(lr.x + pad, gotoY, listInner, gotoH, "Go to save location", { font = "small", radius = 9 * s }) then MapBrowser.select(S, S.save.player.map) Ops.say(S, ("Jumped to %s (%d,%d)"):format(S.save.player.map, @@ -208,18 +245,18 @@ function MapBrowser.draw(S, Kit, x, y, w, h) end -- ---------------------------------------------------------- the viewport - Kit.card(viewX, y, viewW, h) + Kit.card(vr.x, vr.y, vr.w, vr.h) local vpad = 18 * s - local vx0 = viewX + vpad - local vinner = viewW - 2 * vpad + local vx0 = vr.x + vpad + local vinner = math.max(0, vr.w - 2 * vpad) local headH = 28 * s Kit.text("monoBig", tostring(S.mapId), vx0, - y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading) + vr.y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading) local ok, map = pcall(MapLoader.load, S.data, S.mapId) if not ok then Kit.text("mono", "Failed to load map: " .. tostring(map), vx0, - y + vpad + headH + 20 * s, PAL.red) + vr.y + vpad + headH + 20 * s, PAL.red) return end @@ -227,38 +264,46 @@ function MapBrowser.draw(S, Kit, x, y, w, h) local oLabel = outdoor and "OUTDOOR" or "INDOOR" local oW = Kit.textWidth("tiny", oLabel) + 16 * s local oX = vx0 + Kit.textWidth("monoBig", tostring(S.mapId)) + 14 * s - Theme.stroke(oX, y + vpad + (headH - 20 * s) / 2, oW, 20 * s, 6 * s, + Theme.stroke(oX, vr.y + vpad + (headH - 20 * s) / 2, oW, 20 * s, 6 * s, PAL.cardBorder, 0.3, 1) Kit.textCenter("tiny", oLabel, oX, - y + vpad + (headH - 20 * s) / 2 + (20 * s - Kit.textHeight("tiny")) / 2, oW, + vr.y + vpad + (headH - 20 * s) / 2 + (20 * s - Kit.textHeight("tiny")) / 2, oW, outdoor and PAL.green or PAL.muted) - -- zoom cluster, right-aligned in the viewport header + -- zoom cluster, right-aligned in the viewport header. The centre button + -- is the one part with a long label; on a header too narrow to hold it + -- beside the title it is dropped (its job is covered by the list's "Go to + -- save location" plus the first-draw centering) rather than painted over + -- the map name (#715). local centerW = 130 * s local zBtn = 32 * s local rightEdge = vx0 + vinner - if Kit.button(rightEdge - centerW, y + vpad, centerW, headH, "Center on player", - { kind = "accent", font = "small", radius = 7 * s }) then - if S.save.player.map == S.mapId then - centerOn(S, S.save.player.x, S.save.player.y) - Ops.say(S, "Centred on the player") - else - Ops.say(S, "Player isn't on this map") + local zoomW = 2 * zBtn + 56 * s + 12 * s + local showCenter = vinner >= zoomW + 10 * s + centerW + 160 * s + if showCenter then + if Kit.button(rightEdge - centerW, vr.y + vpad, centerW, headH, "Center on player", + { kind = "accent", font = "small", radius = 7 * s }) then + if S.save.player.map == S.mapId then + centerOn(S, S.save.player.x, S.save.player.y) + Ops.say(S, "Centred on the player") + else + Ops.say(S, "Player isn't on this map") + end end end - local zx = rightEdge - centerW - 10 * s - (2 * zBtn + 56 * s + 12 * s) - if Kit.stepper(zx, y + vpad, zBtn, headH, "-", { radius = 7 * s }) then + local zx = rightEdge - (showCenter and (centerW + 10 * s) or 0) - zoomW + if Kit.stepper(zx, vr.y + vpad, zBtn, headH, "-", { radius = 7 * s }) then S.mapZoom = clampZoom(S.mapZoom - 0.5) end Kit.textCenter("mono", ("%.2fx"):format(S.mapZoom), zx + zBtn + 6 * s, - y + vpad + (headH - Kit.textHeight("mono")) / 2, 56 * s, PAL.muted) - if Kit.stepper(zx + zBtn + 62 * s, y + vpad, zBtn, headH, "+", { radius = 7 * s }) then + vr.y + vpad + (headH - Kit.textHeight("mono")) / 2, 56 * s, PAL.muted) + if Kit.stepper(zx + zBtn + 62 * s, vr.y + vpad, zBtn, headH, "+", { radius = 7 * s }) then S.mapZoom = clampZoom(S.mapZoom + 0.5) end local legendH = 22 * s - local vy0 = y + vpad + headH + 12 * s - local vh0 = (y + h - vpad - legendH - 10 * s) - vy0 + local vy0 = vr.y + vpad + headH + 12 * s + local vh0 = math.max(0, (vr.y + vr.h - vpad - legendH - 10 * s) - vy0) S._mapViewW, S._mapViewH = vinner, vh0 -- First draw of a map: park the camera somewhere meaningful rather than at @@ -279,8 +324,11 @@ function MapBrowser.draw(S, Kit, x, y, w, h) Theme.stroke(vx0, vy0, vinner, vh0, 12 * s, PAL.cardBorder, 0.28, 1) -- love_stub (headless tests) lacks push/pop/scale/scissor; skip the actual - -- render there but keep all click/button logic below running. - if love.graphics.push then + -- render there but keep all click/button logic below running. The size + -- guard is the #715 crash fix proper: an exhausted viewport (a window + -- shorter or narrower than the chrome) renders nothing instead of handing + -- LOVE a negative scissor rect. + if love.graphics.push and vinner > 0 and vh0 > 0 then love.graphics.setScissor(math.floor(vx0), math.floor(vy0), math.ceil(vinner), math.ceil(vh0)) love.graphics.push() @@ -292,6 +340,23 @@ function MapBrowser.draw(S, Kit, x, y, w, h) love.graphics.setScissor() end + -- Touch pan (#715): arrows/WASD and the wheel are desktop-only inputs, so + -- a held pointer drags the camera directly. A plain tap still selects a + -- cell via the click handling below; only movement while held pans. + if Kit.mouseDown and not Kit.blockClicks + and (S._mapDrag or Kit.hit(vx0, vy0, vinner, vh0)) then + local d = S._mapDrag + if not d then + S._mapDrag = { mx = Kit.mouseX, my = Kit.mouseY, + camX = S.mapCamX, camY = S.mapCamY } + else + S.mapCamX = d.camX - (Kit.mouseX - d.mx) / S.mapZoom + S.mapCamY = d.camY - (Kit.mouseY - d.my) / S.mapZoom + end + elseif not Kit.mouseDown then + S._mapDrag = nil + end + -- click handling: warp cells jump the view, everything else selects if Kit.mouseClicked then local cx, cy = cellAtScreen(S, map, Kit, vx0, vy0, vinner, vh0) @@ -307,7 +372,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h) end -- legend + the current selection readout - local ly = y + h - vpad - legendH + 4 * s + local ly = vr.y + vr.h - vpad - legendH + 4 * s local lx = vx0 local legend = { { PAL.blue, "warp", false }, @@ -332,11 +397,11 @@ function MapBrowser.draw(S, Kit, x, y, w, h) vx0 + vinner, ly, PAL.caption) -- ------------------------------------------------------ spawn inspector - local sx0 = viewX + viewW + gap - Kit.card(sx0, y, sideW, h) - Kit.caption(sx0 + pad, y + pad, "SPAWN POINTS") - local sTop = y + pad + Kit.textHeight("caption") + 12 * s - local sInner = sideW - 2 * pad + local sx0 = sr.x + Kit.card(sx0, sr.y, sr.w, sr.h) + Kit.caption(sx0 + pad, sr.y + pad, "SPAWN POINTS") + local sTop = sr.y + pad + Kit.textHeight("caption") + 12 * s + local sInner = sr.w - 2 * pad local player = S.save.player local out = S.save.lastOutdoor local heal = S.save.lastHeal diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 0e51f1b1..0a2230ee 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -7,6 +7,14 @@ -- the Party and Boxes panels dock into (rule 1 of the design spec): the list -- stays visible while you edit, and Escape clears the selection rather than -- "closing a window". +-- +-- #715 reflow: the inspector used to shrink its stat tiles and DV/move rows +-- against a vertical budget, and past a point the rows still ran over the +-- action buttons. Sizes are fixed at readable values now; when the card is +-- too short for them the whole body scrolls (Kit.scrollPixels), and when it +-- is too narrow for the DV | moves split the two columns stack. The clip +-- over the card doubles as the hit fence, so a control scrolled out of view +-- cannot take a stray tap. local Theme = require("Theme") local Ops = require("Ops") @@ -66,63 +74,12 @@ end -- and the differences between mons stay legible. local STAT_SCALE = 400 -function MonEditor.draw(S, Kit, x, y, w, h) +-- The -5 -1 [Lv] +1 +5 stepper row plus the EXP readout, at (lx0, ly). +local function drawLevelRow(S, Kit, mon, lx0, ly) local s = Kit.scale - Kit.card(x, y, w, h) - local mon = S.editingMon - local pad = 18 * s - if not mon then - -- The inspector column is always drawn, so it explains itself rather - -- than collapsing and reflowing the panel underneath it. - local tw = math.min(w - 40 * s, 340 * s) - Kit.textCenter("button", - "Pick a slot on the left to inspect it. Every change here re-runs the " .. - "Gen1 stat formulas, so HP and stats stay legal.", - x + (w - tw) / 2, y + h / 2 - Kit.textHeight("button"), tw, PAL.muted) - return - end - - local def = S.data.pokemon[mon.species] - local cx, cy = x + pad, y + pad - local inner = w - 2 * pad - -- Backstop for a window too short for even the compacted rhythm below: - -- nothing this panel draws may land outside its own card (#497). Party - -- draws the inspector last, so no outer clip is lost by the pop at the end. - Kit.pushClip(x, y, w, h) - - -- ---------------------------------------------------------- header row - local sprite = 96 * s - MonEditor.drawSprite(S, Kit, mon.species, cx, cy, sprite) - local hx = cx + sprite + 18 * s - local hw = inner - sprite - 18 * s - - Kit.text("title", mon.species, hx, cy, PAL.heading) - local nameW = Kit.textWidth("title", mon.species) - Kit.text("tiny", ("#%03d"):format(def and def.dex or 0), hx + nameW + 12 * s, - cy + Kit.textHeight("title") - Kit.textHeight("tiny") - 2 * s, PAL.caption) - - -- One control instead of a pair of arrows: cycling walked the catalog an - -- entry at a time (151 taps to cross the dex) and ran a full MonOps - -- recalculation on every step, including on records the Gen1 formulas - -- cannot use, which is what crashed the editor (#541). This opens the - -- searchable picker; the species name itself is a second, larger target. - local pickH = 30 * s - local pickW = math.min(150 * s, math.max(90 * s, hw * 0.6)) - local px = hx + hw - pickW - local py = cy + (Kit.textHeight("title") - pickH) / 2 - local openPicker = Kit.button(px, py, pickW, pickH, "Change species", - { kind = "accent", font = "small", radius = 8 * s }) - if not openPicker then - openPicker = Kit.press(hx, cy, math.max(0, px - hx - 10 * s), - Kit.textHeight("title")) - end - if openPicker then Ops.openSpeciesPicker(S, Kit) end - - -- level stepper: -5 -1 [Lv] +1 +5, matching MonOps.setLevel's 1..100 clamp - local ly = cy + Kit.textHeight("title") + 14 * s local lh = 28 * s - Kit.caption(hx, ly + (lh - Kit.textHeight("caption")) / 2, "LEVEL") - local lx = hx + 52 * s + Kit.caption(lx0, ly + (lh - Kit.textHeight("caption")) / 2, "LEVEL") + local lx = lx0 + 52 * s local bw = 40 * s for _, d in ipairs({ { "-5", -5 }, { "-1", -1 } }) do if Kit.stepper(lx, ly, bw, lh, d[1], { font = "small", radius = 7 * s }) then @@ -141,52 +98,19 @@ function MonEditor.draw(S, Kit, x, y, w, h) end Kit.text("mono", ("EXP %d"):format(mon.exp or 0), lx + 6 * s, ly + (lh - Kit.textHeight("mono")) / 2, PAL.muted) + return lh +end - -- ------------------------------------------------------- derived stats - local statsY = cy + sprite + 18 * s - Kit.caption(cx, statsY, "STATS . recalculated from level + DVs") - statsY = statsY + Kit.textHeight("caption") + 10 * s - local gap = 12 * s - local cellW = (inner - gap * 4) / 5 - -- Everything below the header competes for one vertical budget. At the - -- design size it is generous; in a 720px-tall window (a phone held - -- sideways) it is not, and the DV / move rows used to run past the card and - -- paint over the status bar (#497). Shrink the two flexible blocks -- the - -- stat tiles and the DV / move rows -- instead of overflowing, with floors - -- that keep every row the 26px target Kit's rule 6 promises. statsY is - -- already past the STATS caption here, so only the DVs / MOVES caption is - -- subtracted. - local actH = 34 * s - local rowGap = 8 * s - local budget = (y + h - pad) - statsY - (Kit.textHeight("caption") + 10 * s) - - 18 * s - actH - 4 * s - local cellH = Theme.clamp(budget * 0.3, 46 * s, 68 * s) - local rowH = Theme.clamp((budget - cellH) / 4 - rowGap, 26 * s, 34 * s) - for i, st in ipairs(STAT_KEYS) do - local bx = cx + (i - 1) * (cellW + gap) - Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6) - local value = (mon.stats and mon.stats[st.field]) or 0 - Kit.text("micro", st.key, bx + 12 * s, statsY + 10 * s, PAL.caption) - Kit.text("stat", tostring(value), bx + 12 * s, - statsY + 10 * s + Kit.textHeight("micro") + 4 * s, PAL.heading) - Kit.meter(bx + 12 * s, statsY + cellH - 14 * s, cellW - 24 * s, 5 * s, - value / STAT_SCALE * 100, PAL.blue) - end - - -- --------------------------------------------------- DVs | moves split - local colY = statsY + cellH + 18 * s - local colGap = 18 * s - local colW = (inner - colGap) / 2 - local rightX = cx + colW + colGap - - Kit.caption(cx, colY, "DVs") - Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0), - cx + colW, colY, PAL.caption) - Kit.caption(rightX, colY, "MOVES") - Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption) - - local rowY = colY + Kit.textHeight("caption") + 10 * s +-- Everything the level row needs in width, for the "does it fit beside the +-- sprite" decision. +local function levelRowWidth(Kit, mon) + local s = Kit.scale + return 52 * s + 2 * (40 * s + 8 * s) + 58 * s + 8 * s + 2 * (40 * s + 8 * s) + + 6 * s + Kit.textWidth("mono", ("EXP %d"):format(mon.exp or 0)) +end +local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) + local s = Kit.scale for i, key in ipairs(DV_KEYS) do local ry = rowY + (i - 1) * (rowH + rowGap) Theme.row(cx, ry, colW, rowH, 10 * s, 0.6) @@ -212,7 +136,10 @@ function MonEditor.draw(S, Kit, x, y, w, h) Ops.setDv(S, mon, key, 15) end end +end +local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) + local s = Kit.scale for slot = 1, 4 do local ry = rowY + (slot - 1) * (rowH + rowGap) Theme.row(rightX, ry, colW, rowH, 10 * s, 0.6) @@ -239,16 +166,175 @@ function MonEditor.draw(S, Kit, x, y, w, h) Ops.clearMove(S, mon, slot) end end +end - local actY = rowY + 4 * (rowH + rowGap) + 4 * s - local actW = (colW - 10 * s) / 2 - if Kit.button(rightX, actY, actW, actH, "Reset to learnset", - { font = "small", radius = 9 * s }) then - Ops.resetMoves(S, mon) +function MonEditor.draw(S, Kit, x, y, w, h) + local s = Kit.scale + Kit.card(x, y, w, h) + local mon = S.editingMon + local pad = 18 * s + if not mon then + -- The inspector column is always drawn, so it explains itself rather + -- than collapsing and reflowing the panel underneath it. + local tw = math.min(w - 40 * s, 340 * s) + Kit.textCenter("button", + "Pick a slot on the left to inspect it. Every change here re-runs the " .. + "Gen1 stat formulas, so HP and stats stay legal.", + x + (w - tw) / 2, y + h / 2 - Kit.textHeight("button"), tw, PAL.muted) + return end - if Kit.button(rightX + actW + 10 * s, actY, actW, actH, "Full heal", - { kind = "good", font = "small", radius = 9 * s }) then - Ops.healMon(S, mon) + + local def = S.data.pokemon[mon.species] + local inner = w - 2 * pad + local capH = Kit.textHeight("caption") + local titleH = Kit.textHeight("title") + + -- Reflow decisions, all against real pixels (#715): the DV | moves split + -- needs ~470px of card interior; below that the two stacks go one above + -- the other. A narrow card also drops the sprite to 64px so the header + -- text keeps room. + local narrow = inner < 470 * s + local sprite = (narrow and 64 or 96) * s + local rowH = 30 * s + local rowGap = 8 * s + local cellH = 52 * s + local actH = 34 * s + + local hw = inner - sprite - 18 * s + local levelInHeader = hw >= levelRowWidth(Kit, mon) + local headerH + if levelInHeader then + headerH = math.max(sprite, titleH + 14 * s + 28 * s) + else + -- the level row does not fit beside the sprite: it drops below the + -- header block at full card width instead of painting over the sprite + headerH = math.max(sprite, titleH) + 12 * s + 28 * s + end + + local colRowsH = 4 * (rowH + rowGap) - rowGap + local colsH + if narrow then + colsH = (capH + 10 * s + colRowsH) * 2 + 14 * s + 10 * s + actH + else + colsH = capH + 10 * s + colRowsH + 12 * s + actH + end + local contentH = pad + headerH + 18 * s + + capH + 10 * s + cellH + 18 * s + + colsH + pad + + -- Called before the widgets so this frame already draws at the updated + -- offset; any list-free card body is fair game for the drag (#715). + S.inspectorScroll = Kit.scrollPixels(x, y, w, h, S.inspectorScroll, contentH) + Kit.pushClip(x, y, w, h) + local cx = x + pad + local cy = y + pad - S.inspectorScroll + + -- ---------------------------------------------------------- header row + MonEditor.drawSprite(S, Kit, mon.species, cx, cy, sprite) + local hx = cx + sprite + 18 * s + + -- One control instead of a pair of arrows: cycling walked the catalog an + -- entry at a time (151 taps to cross the dex) and ran a full MonOps + -- recalculation on every step, including on records the Gen1 formulas + -- cannot use, which is what crashed the editor (#541). This opens the + -- searchable picker; the species name itself is a second, larger target. + local pickH = 30 * s + local pickW = math.min(150 * s, math.max(90 * s, hw * 0.6)) + local px = hx + hw - pickW + local py = cy + (titleH - pickH) / 2 + + -- the species name yields to the button instead of running under it (#715) + local name = Kit.ellipsize("title", mon.species, math.max(40 * s, px - hx - 12 * s)) + Kit.text("title", name, hx, cy, PAL.heading) + local nameW = Kit.textWidth("title", name) + if nameW + Kit.textWidth("tiny", "#000") + 12 * s < px - hx - 12 * s then + Kit.text("tiny", ("#%03d"):format(def and def.dex or 0), hx + nameW + 12 * s, + cy + titleH - Kit.textHeight("tiny") - 2 * s, PAL.caption) + end + + local openPicker = Kit.button(px, py, pickW, pickH, "Change species", + { kind = "accent", font = "small", radius = 8 * s }) + if not openPicker then + openPicker = Kit.press(hx, cy, math.max(0, px - hx - 10 * s), titleH) + end + if openPicker then Ops.openSpeciesPicker(S, Kit) end + + -- level stepper: -5 -1 [Lv] +1 +5, matching MonOps.setLevel's 1..100 clamp + if levelInHeader then + drawLevelRow(S, Kit, mon, hx, cy + titleH + 14 * s) + else + drawLevelRow(S, Kit, mon, cx, cy + math.max(sprite, titleH) + 12 * s) + end + + -- ------------------------------------------------------- derived stats + local statsY = cy + headerH + 18 * s + Kit.caption(cx, statsY, "STATS . recalculated from level + DVs") + statsY = statsY + capH + 10 * s + local gap = 12 * s + local cellW = (inner - gap * 4) / 5 + for i, st in ipairs(STAT_KEYS) do + local bx = cx + (i - 1) * (cellW + gap) + Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6) + local value = (mon.stats and mon.stats[st.field]) or 0 + Kit.text("micro", st.key, bx + 12 * s, statsY + 8 * s, PAL.caption) + Kit.text("stat", tostring(value), bx + 12 * s, + statsY + 8 * s + Kit.textHeight("micro") + 2 * s, PAL.heading) + Kit.meter(bx + 12 * s, statsY + cellH - 12 * s, cellW - 24 * s, 5 * s, + value / STAT_SCALE * 100, PAL.blue) + end + + -- --------------------------------------------------- DVs | moves split + local colY = statsY + cellH + 18 * s + if narrow then + -- stacked: DVs first, then moves, then the two actions side by side at + -- full width (#715) + Kit.caption(cx, colY, "DVs") + Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0), + cx + inner, colY, PAL.caption) + local rowY = colY + capH + 10 * s + drawDvRows(S, Kit, mon, cx, rowY, inner, rowH, rowGap) + + local movesY = rowY + colRowsH + 14 * s + Kit.caption(cx, movesY, "MOVES") + Kit.textRight("tiny", "click a slot to cycle", cx + inner, movesY, PAL.caption) + local mRowY = movesY + capH + 10 * s + drawMoveRows(S, Kit, mon, cx, mRowY, inner, rowH, rowGap) + + local actY = mRowY + colRowsH + 10 * s + local actW = (inner - 10 * s) / 2 + if Kit.button(cx, actY, actW, actH, "Reset to learnset", + { font = "small", radius = 9 * s }) then + Ops.resetMoves(S, mon) + end + if Kit.button(cx + actW + 10 * s, actY, actW, actH, "Full heal", + { kind = "good", font = "small", radius = 9 * s }) then + Ops.healMon(S, mon) + end + else + local colGap = 18 * s + local colW = (inner - colGap) / 2 + local rightX = cx + colW + colGap + + Kit.caption(cx, colY, "DVs") + Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0), + cx + colW, colY, PAL.caption) + Kit.caption(rightX, colY, "MOVES") + Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption) + + local rowY = colY + capH + 10 * s + drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) + drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) + + local actY = rowY + colRowsH + 12 * s + local actW = (colW - 10 * s) / 2 + if Kit.button(rightX, actY, actW, actH, "Reset to learnset", + { font = "small", radius = 9 * s }) then + Ops.resetMoves(S, mon) + end + if Kit.button(rightX + actW + 10 * s, actY, actW, actH, "Full heal", + { kind = "good", font = "small", radius = 9 * s }) then + Ops.healMon(S, mon) + end end Kit.popClip() end diff --git a/tools/save-editor/panels/Party.lua b/tools/save-editor/panels/Party.lua index ae232c85..0a2e44c4 100644 --- a/tools/save-editor/panels/Party.lua +++ b/tools/save-editor/panels/Party.lua @@ -4,6 +4,12 @@ -- Reorder lives on the row itself (the up/down pair appears on the selected -- row) rather than in a bottom button strip, which leaves Add / Remove as the -- only two panel-level verbs. +-- +-- #715 reflow: side by side, the roster and the inspector need about 640 +-- real px between them. Anything narrower stacks the two cards (roster +-- above, inspector below) at full width instead of shrinking both into +-- unreadable slivers, and the roster body scrolls (wheel / touch drag / +-- Kit.scrollbar) rather than silently truncating past the fold. local PartyMod = require("src.pokemon.Party") local Theme = require("Theme") @@ -29,11 +35,8 @@ local function hpColor(frac) return PAL.green end -function Party.draw(S, Kit, x, y, w, h) +local function drawRoster(S, Kit, x, y, listW, h) local s = Kit.scale - local gap = 20 * s - local listW = rosterWidth(w, s) - Kit.card(x, y, listW, h) local pad = 18 * s local cx = x + pad @@ -56,12 +59,21 @@ function Party.draw(S, Kit, x, y, w, h) local rowH = 64 * s local rowGap = 8 * s S.selectedParty = Ops.clamp(S.selectedParty or 1, 1, #S.save.party) - for i, mon in ipairs(S.save.party) do + -- The list used to `break` past the fold, silently hiding party slots on + -- a short window; it scrolls instead now (#715), same offset contract as + -- every other list in the editor. + local visible = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) + S.partyOffset = Kit.scroll(cx, listTop, innerW, listH, + S.partyOffset or 0, #S.save.party, visible) + Kit.pushClip(cx, listTop, innerW, listH) + for i = 1, visible do + local slot = S.partyOffset + i + local mon = S.save.party[slot] + if not mon then break end local ry = listTop + (i - 1) * (rowH + rowGap) - if ry + rowH > listTop + listH then break end local selected = (S.editingMon == mon) if Kit.row(cx, ry, innerW, rowH, selected, PAL.green) then - Ops.selectParty(S, i) + Ops.selectParty(S, slot) end local rpad = 12 * s @@ -96,7 +108,7 @@ function Party.draw(S, Kit, x, y, w, h) local tw = math.max(40 * s, (cx + innerW - rightW - 10 * s) - tx) local name = Kit.ellipsize("monoRow", mon.species, tw - 34 * s) Kit.text("monoRow", name, tx, ry + 10 * s, PAL.heading) - Kit.text("tiny", ("#%d"):format(i), + Kit.text("tiny", ("#%d"):format(slot), tx + Kit.textWidth("monoRow", name) + 8 * s, ry + 12 * s, PAL.caption) local maxHp = (mon.stats and mon.stats.hp) or 1 @@ -105,6 +117,9 @@ function Party.draw(S, Kit, x, y, w, h) Kit.text("tiny", ("HP %d/%d"):format(mon.hp or 0, maxHp), tx, ry + rowH - 10 * s - Kit.textHeight("tiny"), PAL.muted) end + Kit.popClip() + Kit.scrollbar(cx, listTop, innerW, listH, + S.partyOffset, #S.save.party, visible) end local halfW = (innerW - 10 * s) / 2 @@ -118,8 +133,22 @@ function Party.draw(S, Kit, x, y, w, h) { kind = "danger", font = "small", radius = 9 * s }) then Ops.partyRemove(S) end +end - MonEditor.draw(S, Kit, x + listW + gap, y, w - listW - gap, h) +function Party.draw(S, Kit, x, y, w, h) + local s = Kit.scale + local gap = 20 * s + if w < 640 * s then + -- stacked (#715): roster on top with enough height for a few rows, the + -- inspector takes the rest and scrolls internally (see MonEditor) + local rosterH = Theme.clamp(h * 0.42, 150 * s, 300 * s) + drawRoster(S, Kit, x, y, w, rosterH) + MonEditor.draw(S, Kit, x, y + rosterH + gap, w, h - rosterH - gap) + else + local listW = rosterWidth(w, s) + drawRoster(S, Kit, x, y, listW, h) + MonEditor.draw(S, Kit, x + listW + gap, y, w - listW - gap, h) + end end return Party diff --git a/tools/save-editor/panels/SpeciesPicker.lua b/tools/save-editor/panels/SpeciesPicker.lua index 5647b141..e7c2710d 100644 --- a/tools/save-editor/panels/SpeciesPicker.lua +++ b/tools/save-editor/panels/SpeciesPicker.lua @@ -23,11 +23,23 @@ function Picker.results(S) return Ops.speciesSearch(S, p and p.query or "") end +-- One commit funnel for both of the picker's jobs: changing the inspected +-- mon's species, and the Boxes panel's add flow (mode "box-add"), which +-- creates a fresh Lv5 mon in the selected box instead (#715). Either way an +-- unusable record refuses in the status bar rather than crashing (#541). +local function commit(S, id) + local p = S.speciesPicker + if p and p.mode == "box-add" then + return Ops.boxAddSpecies(S, id) + end + return Ops.setSpecies(S, S.editingMon, id) +end + -- Enter commits the top match, which is the whole point of a search field. function Picker.commitFirst(S, Kit) local hits = Picker.results(S) if not hits[1] then return Ops.say(S, "No species matches that") end - local ok = Ops.setSpecies(S, S.editingMon, hits[1]) + local ok = commit(S, hits[1]) if ok then Ops.closeSpeciesPicker(S, Kit) end return ok end @@ -65,7 +77,8 @@ function Picker.draw(S, Kit, width, height) local cx, cy = x + pad, y + pad local inner = w - 2 * pad - Kit.caption(cx, cy, "CHOOSE A SPECIES") + Kit.caption(cx, cy, p.mode == "box-add" + and ("ADD TO BOX %d"):format(S.selectedBox or 1) or "CHOOSE A SPECIES") local closeW = 30 * s if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x", { font = "small", radius = 7 * s }) then @@ -86,6 +99,9 @@ function Picker.draw(S, Kit, width, height) local listH = (y + h - pad - pagerH - 10 * s) - cy local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) + -- wheel / touch drag scroll the modal list too; the shield is already + -- lowered for this layer, so Kit.scroll works here and only here (#715) + p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage) if #hits == 0 then Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.") @@ -99,9 +115,11 @@ function Picker.draw(S, Kit, width, height) -- A record the formulas cannot use still lists, greyed: hiding it would -- make a modded species look like it never registered (#541). local usable = Ops.speciesUsable(S, id) - local current = (S.editingMon and S.editingMon.species == id) + -- box-add has no "current" species: nothing is being replaced + local current = p.mode ~= "box-add" + and (S.editingMon and S.editingMon.species == id) or false if Kit.row(cx, ry, inner, rowH, current, PAL.green, 9 * s) then - if Ops.setSpecies(S, S.editingMon, id) then + if commit(S, id) then Ops.closeSpeciesPicker(S, Kit) Kit.popClip() return @@ -121,6 +139,7 @@ function Picker.draw(S, Kit, width, height) ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption) end Kit.popClip() + Kit.scrollbar(cx, cy, inner, listH, p.offset, #hits, perPage) end p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage)