From 3a7fd6d026478afccb805406e25c64fcfc60bf66 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 9 Apr 2026 14:20:16 -0400 Subject: [PATCH 1/4] fix(masters-tournament): wide-short layouts, cache rehydration, flag cleanup, LIVE alert rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the PR review fixes, covering a wave of on-device issues found while running the plugin on a 192x48 panel during the 2026 Masters. Cache: the LEDMatrix core CacheManager.get() signature is `max_age: int = 300` and raises on None; it also JSON-serializes datetimes to ISO strings. Four sites passed `max_age=None` to mean "return stale" and a new fetch_tournament_meta consumer expected datetime objects back. Added a `_NEVER_EXPIRE = 2**31 - 1` sentinel for the cache read sites and a `_rehydrate_meta()` helper that parses start_date/end_date back into tz-aware datetimes at the read boundary, so _get_cache_ttl, fetch_tournament_meta, and manager's countdown/phase logic all work against the disk-backed cache. Display tiers: "large" (>64 wide) was hardcoded for 128x64. A 192x48 panel inherited the same layout and overflowed every vertical stack. Introduced `is_wide_short` (aspect >= 2.5 on large tier) and made `_configure_tier()` compute max_players from the actual pixel budget rather than a constant, so wide-short and standard panels both get a sensible count. Wide-short layouts: base-class `render_leaderboard` now has a two-column layout (4 players per page vs 2 on 192x48), `render_schedule` gets the same treatment (2 pairings per row), `render_field_overview` puts par stats on the left and the leader highlight on the right, and `render_player_card` puts the headshot/name/country on the left with a big centered score on the right. The enhanced renderer overrides delegate to super() when is_wide_short so the new layouts take effect everywhere — trading the textured background for fitting content on short panels. Each method now clips against actual bounds. Render_live_alert had a 3-row vertical stack that overflowed 48-tall panels. Added a wide-short path: LIVE header across the top, player name + hole info stacked on the left, big "LEADER"/"EAGLE!"/etc text hugging the right edge. Hole card on small tier (64x32) was pushing par/yardage off the bottom because the layout computed par_y from a wrapped name block instead of anchoring to the canvas. Rewrote the common layout to pin hole # to the top and par/yardage to the bottom, with the name clipped to whatever's left in between. Added a new `_render_hole_card_compact` specifically for small tier that drops the map image (unreadable at 32px) and uses a two-column text layout so hole #, name, par, yardage, and zone all fit without clipping. 192x48 and 128x64 layouts unchanged. Player card rotation was advancing every display frame (~1 FPS). Added a dwell timer (`player_card_duration`, default 8s) so cards are actually readable, matching how course-tour mode works. Tee time text in the thru column was gray; changed to white for legibility. Flag size is now tier-aware (14x10 on large tier instead of the hardcoded 10x7). Country flags: all 16 existing masters flag PNGs had a baked-in 1px gray border (corner pixel (80,80,80,255) on every one), which was what the user saw as a "1 pixel highlight around the flags". Cropped 1px off all sides of every flag (16x10 -> 14x8), regenerated USA.png programmatically as a 14x9 flag with proper red/white stripes + navy canton + dotted pixel stars, and filled in 7 countries missing from the masters set (AUT, CHN, DEN, FIN, KOR, MEX, NZL) by copying from the olympics plugin's country_flags. Verified live on devpi (Raspberry Pi running at 192x48) against the live 2026 Masters data: plugin initializes with phase=tournament, refreshes every 30s, no cache errors, no 404s, all modes rendering correctly (leaderboard shows 4 players, schedule shows 2 tee time pairings per page, player cards rotate on an 8s timer, LIVE alert fits in 48px). 64x32 hole card now shows #12, Golden, Par 3, 15.., and zone without clipping. Bumps manifest 2.1.2 -> 2.2.1. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins.json | 2 +- .../assets/masters/flags/ARG.png | Bin 122 -> 99 bytes .../assets/masters/flags/AUS.png | Bin 164 -> 139 bytes .../assets/masters/flags/AUT.png | Bin 0 -> 127 bytes .../assets/masters/flags/CAN.png | Bin 115 -> 92 bytes .../assets/masters/flags/CHN.png | Bin 0 -> 225 bytes .../assets/masters/flags/DEN.png | Bin 0 -> 218 bytes .../assets/masters/flags/ENG.png | Bin 134 -> 104 bytes .../assets/masters/flags/ESP.png | Bin 121 -> 91 bytes .../assets/masters/flags/FIJ.png | Bin 105 -> 81 bytes .../assets/masters/flags/FIN.png | Bin 0 -> 297 bytes .../assets/masters/flags/GER.png | Bin 117 -> 98 bytes .../assets/masters/flags/IRL.png | Bin 122 -> 99 bytes .../assets/masters/flags/JPN.png | Bin 135 -> 115 bytes .../assets/masters/flags/KOR.png | Bin 0 -> 456 bytes .../assets/masters/flags/MEX.png | Bin 0 -> 287 bytes .../assets/masters/flags/NIR.png | Bin 103 -> 81 bytes .../assets/masters/flags/NOR.png | Bin 168 -> 129 bytes .../assets/masters/flags/NZL.png | Bin 0 -> 451 bytes .../assets/masters/flags/RSA.png | Bin 138 -> 104 bytes .../assets/masters/flags/SCO.png | Bin 153 -> 133 bytes .../assets/masters/flags/SWE.png | Bin 138 -> 109 bytes .../assets/masters/flags/USA.png | Bin 126 -> 145 bytes .../assets/masters/flags/WAL.png | Bin 118 -> 86 bytes plugins/masters-tournament/manager.py | 15 +- plugins/masters-tournament/manifest.json | 12 +- plugins/masters-tournament/masters_data.py | 62 ++- .../masters-tournament/masters_renderer.py | 401 +++++++++++++----- .../masters_renderer_enhanced.py | 216 ++++++++-- 29 files changed, 557 insertions(+), 151 deletions(-) create mode 100644 plugins/masters-tournament/assets/masters/flags/AUT.png create mode 100644 plugins/masters-tournament/assets/masters/flags/CHN.png create mode 100644 plugins/masters-tournament/assets/masters/flags/DEN.png create mode 100644 plugins/masters-tournament/assets/masters/flags/FIN.png create mode 100644 plugins/masters-tournament/assets/masters/flags/KOR.png create mode 100644 plugins/masters-tournament/assets/masters/flags/MEX.png create mode 100644 plugins/masters-tournament/assets/masters/flags/NZL.png diff --git a/plugins.json b/plugins.json index 5194b97..6c921b2 100644 --- a/plugins.json +++ b/plugins.json @@ -702,7 +702,7 @@ "last_updated": "2026-04-09", "verified": true, "screenshot": "", - "latest_version": "2.1.2" + "latest_version": "2.2.1" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/assets/masters/flags/ARG.png b/plugins/masters-tournament/assets/masters/flags/ARG.png index 8168b07aa947f37e450527abd7146175ba079a14..089c7306f1cf20ed1f5f7138dd8464c3ba7157e8 100644 GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq_jL;978JRB%itY>5VyHay-6vVd~*&v?BrhYxb@E3^X%tXuY5c-*)lRknt|aD XE7zlf>sHT!hB0`$`njxgN@xNAs=X?k diff --git a/plugins/masters-tournament/assets/masters/flags/AUS.png b/plugins/masters-tournament/assets/masters/flags/AUS.png index 3c85eae0f6eb95a87721e92ac67d992ba3013f9b..484e21c7ab2788a1aa187d787f73b2cb202ff357 100644 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq=GzM978JR%$?vZ)L_8lBp-LD zqUcUYbgpoEimROpkCB7}i};-%H>#6`8#}6Ru8r$_vzJ@G`4MwNNy)$Ks}$z&&W)J2 n=3IDpf!4z(3f#}RlP%Pr`bhTfcz4AXXgY(ZtDnm{r-UW|9Dy($ literal 164 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!NM(DvIEGZrSvujSV1ojWOS}ZP zk9(GD5SQ#x`7;{r6?^oVW~)jEhCXH&J|L#daOe^LwDZq*3te@wa5?nIciQW)%{OaS zI;-yZDd>>0R(Z>@TA_oyQA(PRzN}FC(qkt*g=f0OoE2MLu70_8{3c^vgKT|c*P=L} OwG5uFelF{r5}E*x#XJ)L diff --git a/plugins/masters-tournament/assets/masters/flags/AUT.png b/plugins/masters-tournament/assets/masters/flags/AUT.png new file mode 100644 index 0000000000000000000000000000000000000000..0a7a573cfc75fab02d135501e83a0265cd034bc4 GIT binary patch literal 127 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!2~3Y?s%RCQf{6ujv*Cul5G?h9CzSKNK8po z^F93D{JrO;13$_R>s>PW^Z);R6!3HEx3Z6Ryq?A2f978JRB>y>o;KTezo&$$I q%-5;q@kx0p6(uEkk~efyE(=57EH-Wd`|>?N3WL~#mVm?byuQ>M=+e?g&)0IA7$ja*j Pn!@1e>gTe~DWM4fS~DeL diff --git a/plugins/masters-tournament/assets/masters/flags/CHN.png b/plugins/masters-tournament/assets/masters/flags/CHN.png new file mode 100644 index 0000000000000000000000000000000000000000..a76a00004ad06128db1da0847308a7efc9f8d5bd GIT binary patch literal 225 zcmV<703QE|P)$XYZLjm6c_{S`Y-qBF5bvn^*`*#NP}I@9~F`Yf<7v zJp|0$o7Kx5GnoXG-WWh;KHc1H)uLdq>t=`sj5ZQg&wPJQwzqJxzD}`97!<5ne4Rda z%Wz7FRdO%WfD8(9ElNNiMq-tPi^|p=GK8V4I)MlRC`5{rkp|mM|5=RE-Iu;A$LK!c bSN;~>ZM-&F_qEd@00000NkvXXu0mjf#ba2z literal 0 HcmV?d00001 diff --git a/plugins/masters-tournament/assets/masters/flags/DEN.png b/plugins/masters-tournament/assets/masters/flags/DEN.png new file mode 100644 index 0000000000000000000000000000000000000000..a99894a5d342e17cdaef3470f0b58ccbc3502645 GIT binary patch literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!2~3Y?s%RCQVTs@978JRtPQm0Iusx<=jI-h z#)kdU9bsBc;v2pknzk)z;Sz)1=M^7#oXT`fI`@k(w(GXFCZ5_IHm@W-kfdSG@RO=AOLnCn~59g0?XvF}-@A7G SX*2-2oWax8&t;ucLK6Vou2<^- literal 0 HcmV?d00001 diff --git a/plugins/masters-tournament/assets/masters/flags/ENG.png b/plugins/masters-tournament/assets/masters/flags/ENG.png index acf6e6444d777d5a65ac39cdcbb6dc56e05004d9..ccb87c1fc78d5d1a416d704ccd34ce3d7bac9aa0 100644 GIT binary patch literal 104 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryr1U*q978JRB>(yU|9?H3%mWo= zWo95~wiHwK*@4O B9xDI< literal 134 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!NcnoYIEGZrIXh{m-~j^;7V+My zB5C)fS01i<5~7@_uJPfhwa$D`DUnNzN{>W$oMqT?>|J4b)KR`a3mnpCeb&}gmVKpg j`$LxBOyTeiuas>M*a)$Re{|gpG?>BD)z4*}Q$iB}(ta+w diff --git a/plugins/masters-tournament/assets/masters/flags/ESP.png b/plugins/masters-tournament/assets/masters/flags/ESP.png index a804eadc23dafe63a59f4dd5bf0d048d193edf30..10d4f468ac73bedc9ede7e794c8f2b8567f7a2b1 100644 GIT binary patch literal 91 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq?9~e978JRB-U-n9ul6$gE{&W)_|q^)Nv~;=rK;2M!cmYVx_u7*fEfyp)rZ;b|P_ V`FlU2X9LY*@O1TaS?83{1OU0;CGr3O diff --git a/plugins/masters-tournament/assets/masters/flags/FIJ.png b/plugins/masters-tournament/assets/masters/flags/FIJ.png index 6472ecddd2b442f80e5046ee1d705371d5967d9f..44c94a23202809d450b2ae858101449881918fa8 100644 GIT binary patch literal 81 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq$E6D978JRB&Q@~{5bExmZ0Q* fd4*e4CJV!sM~wZyXFmu4s$uYS^>bP0l+XkK>B$z4 literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!NEvv#IEGZrN#^0;_;KEWF(EM_ zA>qb`gAF=o85zYTW@v1j+t`?RrNJ?gft%UH?j$S2#X`37?z3epfqEG{UHx3vIVCg! E0Cd70nE(I) diff --git a/plugins/masters-tournament/assets/masters/flags/FIN.png b/plugins/masters-tournament/assets/masters/flags/FIN.png new file mode 100644 index 0000000000000000000000000000000000000000..fb763ba396eec9a00f297a1cabb87ab959c09289 GIT binary patch literal 297 zcmV+^0oMMBP)>_h5s}Jpj0e03-nb@Cf&aASoCn zGgAPATI2~NfbLYsOwG_F+dsV?oa{`byN74wDaeEKn}xZVx(<=Q9|X(mKV32;1A)&9 z!It}gwod;+_D-&bk#>(S&n|BB?&Q|S%D71Qt!#7W2#c#luYBsiG=+|6tvxc+kFN?M v0AAnT=VzxQs5(X~0W%#2FjX@ZX<7INcr%_41L+5%00000NkvXXu0mjf*CBg6 literal 0 HcmV?d00001 diff --git a/plugins/masters-tournament/assets/masters/flags/GER.png b/plugins/masters-tournament/assets/masters/flags/GER.png index 04522369abbb239c2221d75a6b65168a0e7cb87f..5ab3419474ad9706a38f67e7716c9c11ffeb136c 100644 GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq%=KU978JRB&Q@K{5bExmZ0P= v%nSslWsOWHqb`MCQm@%uGxYGc-2JzH72h$%ss%li%V>q QH_#jgPgg&ebxsLQ00FNe$^ZZW diff --git a/plugins/masters-tournament/assets/masters/flags/IRL.png b/plugins/masters-tournament/assets/masters/flags/IRL.png index 1e3cadced7e35041b63321c86b515348668f1352..d155553bdd376507ba92251a01e28f4eda5206d9 100644 GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq_jL;978JRB&W=H`*D6F&jFV{ uH8vnP_W%F?%h%mDnQk&LF}?(1Ffp*aVk>_3KT;E@i^0>?&t;ucLK6Tm&>xBb literal 122 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!NI81CIEGZrN#^0;_;KEWF(EM_ zA>qb@zs)*l8KW&FW@v1beb@9~^Y+P0GCNEs6p05M=jJ)vlaj?Rp%cepBkr(`ks&9H WYk%{v=fOb37(8A5T-G@yGywoeASVI< diff --git a/plugins/masters-tournament/assets/masters/flags/JPN.png b/plugins/masters-tournament/assets/masters/flags/JPN.png index ebfd15a46d69a6aed012ecbd610cf69c06023311..c5c77ace71c25335656631edba39de6bba74139c 100644 GIT binary patch literal 115 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq^vw$978JRB>(yU|9?H35d*hg z=j5KAf^C*!n`d%l9y)e>mEdfpoJSG4Dr^D%%G(+asP$cSD{;NS!_07Hi!k4=uE*DUUzz3H9jsdn8NwS9g^#~v jDml7?Cv}O5A|u1Mb)sLhGG&T^CNp@t`njxgN@xNA>^Lf$ diff --git a/plugins/masters-tournament/assets/masters/flags/KOR.png b/plugins/masters-tournament/assets/masters/flags/KOR.png new file mode 100644 index 0000000000000000000000000000000000000000..e82b46d9be7c31ec39576c065890db4b79596ff1 GIT binary patch literal 456 zcmV;(0XP1MP)xu78tiC1VL7D_;DMFhd(Y%H#jyA{&d3D@{R!5Ac! zMQm(R1hr5UVxbTSeh7)MwgXR#oDrWhFBzzlCtL;%2LL2w+qnHd07W+tLsE|*HBydF<|X6EUx z`ahGkYxS1V;RnaLzPifi^MODh6bdo3>IUh$-XD$r7#wUvIvby~uWx)$wa2YQU?(*@ zH<#Jl({+UH`P!huTcn%5mzxJZHMedzc7t8( zl&|ye9Ly{trfC|+!r^}Qw#wfob|IR8zBPSp`^KuDo*e!DW05#U~{a$U8(Z@W2scqG)+WQB4XxnI2?<`ip3&*_BQz%rboKm ye9OAmG=DG{j6@>tKk?&mBH}Or01?oK;hq6hUBJNm6CHQ}0000rrK;a zG50ELv1^7n<-O;-=j4j^0RY4his9hm!3HEx3Z6Ryq$E6D978JRB>(yU|9?H35reSh fEH~3jUdjye(JVZQ*JGQ2Y8X6S{an^LB{Ts5-Ix^0 literal 103 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!Na=aHIEGZrN#^0;_;KEWF(EM_ zAt7Raoo(bSW@cu|85$esHZ~?+X>d$rP+rQ($-qB@?Q`DJ4{1Q144$rjF6*2Ung9ua B9O?i7 diff --git a/plugins/masters-tournament/assets/masters/flags/NOR.png b/plugins/masters-tournament/assets/masters/flags/NOR.png index b37eac344c81672694e93e58fc5ee208017f2e06..0d3f07c6a8880c5745b7ee856c818db8d617093d 100644 GIT binary patch literal 129 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq&z%b978JRB-&*T8 z=?Nh{i9hP=IS+d}y0X^i_E1CcR literal 168 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!NacIFIEGZrN#^0;_;KEWF(EM_ zAz_6@0i#~Rzkhbji<5W`|NreR;m4)*=<1z^2?q|ibiU=`;h7?R;1ENpnOTFk3?CD- zBAbEMv4j(e!p`iBYyskb_U~tLHr^#s#_kZ?Aam=)IW-Y(o+}-!ZeGGd3{y|bF>8i< ROaofY;OXk;vd$@?2>^~qG}Hh9 diff --git a/plugins/masters-tournament/assets/masters/flags/NZL.png b/plugins/masters-tournament/assets/masters/flags/NZL.png new file mode 100644 index 0000000000000000000000000000000000000000..d02320fa36ed956f94ce198630427f0b9cc4f36e GIT binary patch literal 451 zcmV;!0X+VRP)pp!cBZcbK zE0uTe@4oplc>ZBLwo19s`m&A6{E@v6KkaM}a&1=h#nS6Dcb=`TCvQJ*o?UqSs+-Rg zvoskTn=D)oEl4g{ZrcyFkP;M)D5 zPJkpgK#MUUv^za!3HEx3Z6Ryr1U*q978JRB&V#{@bkO_TY{3i zFf$N5{{R2~$xRbKvM@7ON}b#`v2kJJzDDP5ZVV@5c>YEN${ho0W$<+Mb6Mw<&;$Uu CwjoFW literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!NCkSjIEGZrN#^0;_;KEWF(EM_ zA>qb?k8L_<8O>cJW@v1beb@B=ZTrbfG7=ITh7VMfl`F5YvpDb={P$0@I23c>#0`&i n%o_YIuZ}o59AMqV%fN8pF~7o_mD9fi4QKFl^>bP0l+XkK5DzYp diff --git a/plugins/masters-tournament/assets/masters/flags/SCO.png b/plugins/masters-tournament/assets/masters/flags/SCO.png index 865d1746c1aa64ee164b41df448beebd37fa81ee..0cb98d56f8d2a0991dcad8a0e2172d5ac8880890 100644 GIT binary patch literal 133 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq`vm!S@-fPI8}lZ^j;Rc7-?4Tn0~9KbLh*2~7Z-g)eUa literal 153 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!NF{i>IEGZrSvo0@%R!NY<#;>$ z7GYQQR~_F(UG$YN+%HWR4?J_mvxn)+Jw}1d^KRe0G+XnfT&3Q9y=il}L^SNg%CnxA zYcsoEf8zbcJjY@C?Usg+X4|jV+{%-uh-{tsMwz#&(nK)k{mw3+T@0SCelF{r5}E+l C3pqmo diff --git a/plugins/masters-tournament/assets/masters/flags/SWE.png b/plugins/masters-tournament/assets/masters/flags/SWE.png index 1aa6729fc518ed8da12d6e7cc3143989ac7f76ac..bc0e72813a8d8be1c7c727a3dc2e1c748a58ad0e 100644 GIT binary patch literal 109 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq)a?r978JRB&VE+`Zu4E^N)8| zS^@~n+CGI*KH-qI^$HWCf7XY;%;zY*vUwt_?UxL>RSeHx@EHCG_ACYJXYh3Ob6Mw< G&;$T^ZzHn+ literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!NCkSjIEGZrN#^0;_;KEWF(EM_ zA>oFCXrIy#ea%^oAdq=4XwNL>d23S=79A;LV{3CvVBlt+H2)%_UZDg>*2l$@xGy-* kei4zdz)8m-Xx0Pqab#}=KljEwmbGc-2NZES2TVvdji0zZQ@ir&TS&Ib}cHvOzON;Wc>P$X^;G;0Eb YP%sbI%HtB;Km!>(UHx3vIVCg!05-`ar2qf` diff --git a/plugins/masters-tournament/assets/masters/flags/WAL.png b/plugins/masters-tournament/assets/masters/flags/WAL.png index 3bbea2d484e6c05db203d3669abc56fe702c2722..512f3ba497e4fa3df56435cfcd06a84a294680b6 100644 GIT binary patch literal 86 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^>!3HEx3Z6Ryq+~r^978JRB>(yU|9?H35reSh kEH*b~WoPEY1>cn!@|Ll7sdcqC0TnWMy85}Sb4q9e09wr#H2?qr literal 118 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~g!3HGv?z=Y!NZES2IEGZrIXl5okimfC@FBiN z*GnRaseNYr8`%G<@4coHD^=KVM(3d5!B< StDJxaF?hQAxvX bool: def _display_player_cards(self, force_clear: bool) -> bool: if not self._leaderboard_data: return False - # Rotate through top players + # Rotate through top players on a dwell timer (not every frame) so + # viewers actually get to read each card. + now = time.time() + if self._last_player_card_advance == 0.0: + self._last_player_card_advance = now + elif now - self._last_player_card_advance >= self._player_card_interval: + self._player_card_index += 1 + self._last_player_card_advance = now idx = self._player_card_index % min(5, len(self._leaderboard_data)) player = self._leaderboard_data[idx] - self._player_card_index += 1 return self._show_image(self.renderer.render_player_card(player)) def _display_course_tour(self, force_clear: bool) -> bool: @@ -579,6 +587,7 @@ def on_config_change(self, new_config): self.display_duration = new_config.get("display_duration", 20) self._hole_switch_interval = new_config.get("hole_display_duration", 15) self._page_interval = new_config.get("page_display_duration", 15) + self._player_card_interval = new_config.get("player_card_duration", 8) self._last_hole_advance.clear() self._last_page_advance.clear() self.modes = self._build_enabled_modes() diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 4ed4c88..41bd753 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.1.2", + "version": "2.2.1", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,16 @@ "height": 64 }, "versions": [ + { + "version": "2.2.1", + "released": "2026-04-09", + "ledmatrix_min_version": "2.0.0" + }, + { + "version": "2.2.0", + "released": "2026-04-09", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.1.2", "released": "2026-04-09", diff --git a/plugins/masters-tournament/masters_data.py b/plugins/masters-tournament/masters_data.py index faf6ac9..263bb04 100644 --- a/plugins/masters-tournament/masters_data.py +++ b/plugins/masters-tournament/masters_data.py @@ -22,6 +22,13 @@ CACHE_KEY_META = "masters_tournament_meta" CACHE_KEY_SCHEDULE = "masters_schedule" +# Sentinel passed to cache_manager.get(max_age=...) when we want "return +# whatever exists, even if stale". The LEDMatrix core CacheManager.get() +# signature is `max_age: int = 300` — it doesn't accept None, so we use a +# very large finite value (~68 years) to effectively disable expiry at the +# read site. +_NEVER_EXPIRE = 2**31 - 1 + class MastersDataSource: """Fetches and caches Masters Tournament data from ESPN Golf API.""" @@ -119,7 +126,7 @@ def fetch_tournament_meta(self) -> Optional[Dict]: """ cached = self.cache_manager.get(CACHE_KEY_META, max_age=self._get_cache_ttl()) if cached: - return cached + return self._rehydrate_meta(cached) # Meta lives alongside the leaderboard payload; a leaderboard fetch # will populate it as a side effect. @@ -128,14 +135,32 @@ def fetch_tournament_meta(self) -> Optional[Dict]: except Exception as e: self.logger.warning(f"fetch_tournament_meta: leaderboard fetch failed: {e}") - cached = self.cache_manager.get(CACHE_KEY_META, max_age=None) + cached = self.cache_manager.get(CACHE_KEY_META, max_age=_NEVER_EXPIRE) if cached: - return cached + return self._rehydrate_meta(cached) # Final fallback: compute the Masters as the second Thursday of April # so off-season countdowns still work. return self._computed_fallback_meta() + @classmethod + def _rehydrate_meta(cls, cached: Dict) -> Dict: + """Convert cached meta date fields back to tz-aware datetimes. + + The core CacheManager serializes to JSON on disk, which turns our + datetime objects into ISO strings. Consumers (countdown, phase + detection, TTL computation) all expect datetime instances, so we + rehydrate here at the single read boundary. + """ + if not isinstance(cached, dict): + return cached + meta = dict(cached) + for key in ("start_date", "end_date"): + value = meta.get(key) + if isinstance(value, str): + meta[key] = cls._parse_iso_utc(value) + return meta + def _parse_tournament_meta(self, data: Dict) -> Optional[Dict]: """Extract tournament meta from an ESPN leaderboard response.""" try: @@ -277,7 +302,7 @@ def fetch_schedule(self) -> List[Dict]: self.logger.error(f"fetch_schedule: leaderboard refresh failed: {e}") return self._get_fallback_data(cache_key) - cached = self.cache_manager.get(cache_key, max_age=None) + cached = self.cache_manager.get(cache_key, max_age=_NEVER_EXPIRE) if cached is not None: return cached return [] @@ -624,24 +649,27 @@ def _get_cache_ttl(self) -> int: Avoids calling fetch_tournament_meta() (which could recurse into fetch_leaderboard) — only reads whatever is already in cache. """ - meta = self.cache_manager.get(CACHE_KEY_META, max_age=None) - if meta: - status = meta.get("status") - if status == "in": - return 30 - start = meta.get("start_date") - end = meta.get("end_date") - now = datetime.now(timezone.utc) - if start and end and start <= now <= end: - return 30 - if start and timedelta(0) <= start - now <= timedelta(days=3): - return 300 + raw = self.cache_manager.get(CACHE_KEY_META, max_age=_NEVER_EXPIRE) + if not raw: return 3600 + meta = self._rehydrate_meta(raw) + status = meta.get("status") + if status == "in": + return 30 + start = meta.get("start_date") + end = meta.get("end_date") + if not isinstance(start, datetime): + return 3600 + now = datetime.now(timezone.utc) + if isinstance(end, datetime) and start <= now <= end: + return 30 + if timedelta(0) <= start - now <= timedelta(days=3): + return 300 return 3600 def _get_fallback_data(self, cache_key: str) -> List[Dict]: """Get stale cached data or mock data as fallback.""" - cached = self.cache_manager.get(cache_key, max_age=None) + cached = self.cache_manager.get(cache_key, max_age=_NEVER_EXPIRE) if cached: self.logger.warning("Using stale cached data for %s", cache_key) return cached diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index 8600522..f5ddda9 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -140,15 +140,24 @@ def __init__( else: self.tier = "large" + # Wide-short panels (e.g. 192x48) have lots of horizontal room but + # too little vertical room for the default "large" 128x64 layouts. + # Track aspect so render methods can opt into horizontal variants. + self.aspect = self.width / max(1, self.height) + self.is_wide_short = self.tier == "large" and self.aspect >= 2.5 + self._configure_tier() self._load_fonts() self._flag_cache: Dict[str, Image.Image] = {} def _configure_tier(self): - """Configure display parameters by size tier with generous spacing.""" + """Configure display parameters by size tier with generous spacing. + + max_players is computed from the actual pixel budget (not hardcoded) + so wide-short panels like 192x48 don't overflow the canvas. + """ if self.tier == "tiny": # 32x16 - self.max_players = 2 self.name_len = 8 self.row_height = 7 self.header_height = 7 @@ -160,8 +169,8 @@ def _configure_tier(self): self.headshot_size = 0 self.row_gap = 0 self.footer_height = 0 + self.flag_size = (0, 0) elif self.tier == "small": # 64x32 - self.max_players = 3 # Was 4 - breathe self.name_len = 10 self.row_height = 7 self.header_height = 8 @@ -171,21 +180,35 @@ def _configure_tier(self): self.show_country = False self.show_headshot = False self.headshot_size = 0 - self.row_gap = 1 # 1px gap between rows - self.footer_height = 5 # Page dots - else: # 128x64 - self.max_players = 5 # Was 7 - much more readable - self.name_len = 14 - self.row_height = 9 # Was 7 - more vertical space + self.row_gap = 1 + self.footer_height = 5 + self.flag_size = (10, 7) + else: # large (>64 wide) + # Horizontal budget — wide-short panels can show a longer name + # but less headshot detail. + self.name_len = 14 if not self.is_wide_short else 16 + self.row_height = 9 self.header_height = 11 self.logo_size = 18 self.show_pos_badge = True self.show_thru = True self.show_country = True self.show_headshot = True - self.headshot_size = 28 # Larger to fill the border box - self.row_gap = 1 # 1px gap between rows - self.footer_height = 6 # Page dots + # Headshot fills available vertical space minus padding + border + # + space for the name badge (~14px). On 128x64 this is ~28px; + # on 192x48 it shrinks to ~20px. + self.headshot_size = max(16, min(self.height - 20, 32)) + self.row_gap = 1 + self.footer_height = 6 + # Bigger flags on large tier — scale roughly to row height. + # 14x10 on 64-tall, 12x9 on 48-tall. + flag_h = max(8, min(self.row_height + 1, 10)) + self.flag_size = (int(flag_h * 1.4), flag_h) + + # Compute max_players from actual available vertical space. + available_h = self.height - self.header_height - self.footer_height - 2 + slot_h = self.row_height + self.row_gap + self.max_players = max(1, available_h // slot_h) def _load_fonts(self): if self.tier == "tiny": @@ -272,15 +295,16 @@ def _get_flag(self, country_code: str) -> Optional[Image.Image]: if country_code in self._flag_cache: return self._flag_cache[country_code] flag_path = self.flags_dir / f"{country_code}.png" - if flag_path.exists(): - try: - flag = Image.open(flag_path).convert("RGBA") - flag.thumbnail((10, 7), Image.Resampling.NEAREST) - self._flag_cache[country_code] = flag - return flag - except Exception: - pass - return None + fw, fh = self.flag_size + if fw == 0 or fh == 0 or not flag_path.exists(): + return None + try: + flag = Image.open(flag_path).convert("RGBA") + flag.thumbnail((fw, fh), Image.Resampling.NEAREST) + self._flag_cache[country_code] = flag + return flag + except Exception: + return None def _score_color(self, score, position=None) -> Tuple[int, int, int]: if position == 1: @@ -299,11 +323,18 @@ def render_leaderboard( self, leaderboard_data: List[Dict], show_favorites: bool = True, page: int = 0, ) -> Optional[Image.Image]: - """Render paginated broadcast-style leaderboard.""" + """Render paginated broadcast-style leaderboard. + + Wide-short panels (aspect >= 2.5, e.g. 192x48) render a two-column + layout so we can show 2*max_players entries per page instead of + wasting the horizontal real estate. + """ if not leaderboard_data: return None - total_pages = max(1, (len(leaderboard_data) + self.max_players - 1) // self.max_players) + two_column = self.is_wide_short + per_page = self.max_players * (2 if two_column else 1) + total_pages = max(1, (len(leaderboard_data) + per_page - 1) // per_page) page = page % total_pages img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) @@ -311,26 +342,58 @@ def render_leaderboard( self._draw_header_bar(img, draw, "LEADERBOARD") - y = self.header_height + 2 - start = page * self.max_players - players = leaderboard_data[start : start + self.max_players] - - for i, player in enumerate(players): - if i % 2 == 0: - draw.rectangle([(0, y), (self.width - 1, y + self.row_height - 1)], - fill=COLORS["row_alt"]) - - self._draw_leaderboard_row(img, draw, player, y, i, show_favorites) - y += self.row_height + self.row_gap + start = page * per_page + players = leaderboard_data[start : start + per_page] + + if two_column: + col_w = self.width // 2 + # Faint divider between columns + draw.line([(col_w, self.header_height + 2), + (col_w, self.height - self.footer_height - 2)], + fill=COLORS["masters_dark"]) + for i, player in enumerate(players): + col = i // self.max_players + row = i % self.max_players + y = self.header_height + 2 + row * (self.row_height + self.row_gap) + x0 = col * col_w + x1 = x0 + col_w - 1 + if row % 2 == 0: + draw.rectangle([(x0, y), (x1, y + self.row_height - 1)], + fill=COLORS["row_alt"]) + self._draw_leaderboard_row( + img, draw, player, y, row, show_favorites, + x0=x0 + 1, x1=x1 - 1, + ) + else: + y = self.header_height + 2 + for i, player in enumerate(players): + if i % 2 == 0: + draw.rectangle([(0, y), (self.width - 1, y + self.row_height - 1)], + fill=COLORS["row_alt"]) + self._draw_leaderboard_row(img, draw, player, y, i, show_favorites) + y += self.row_height + self.row_gap - # Page indicator self._draw_page_dots(draw, page, total_pages) - return img - def _draw_leaderboard_row(self, img, draw, player, y, index, show_favorites): + def _draw_leaderboard_row( + self, img, draw, player, y, index, show_favorites, + x0: Optional[int] = None, x1: Optional[int] = None, + ): + """Draw a single leaderboard row within [x0, x1] horizontally. + + When x0/x1 are None, the row spans the full canvas width. + """ + if x0 is None: + x0 = 1 + if x1 is None: + x1 = self.width - 2 + col_width = x1 - x0 + pos_text = str(player.get("position", "")) - name = format_player_name(player.get("player", "?"), self.name_len) + # Narrower columns need shorter names. + name_budget = self.name_len if col_width >= self.width - 4 else max(6, self.name_len - 4) + name = format_player_name(player.get("player", "?"), name_budget) score = player.get("score", 0) score_text = format_score_to_par(score) position = player.get("position", 99) @@ -338,7 +401,7 @@ def _draw_leaderboard_row(self, img, draw, player, y, index, show_favorites): # Vertically center text in row text_y = y + (self.row_height - self._text_height(draw, "A", self.font_body)) // 2 - x = 1 + x = x0 # Position badge if self.show_pos_badge and self.tier != "tiny": @@ -363,26 +426,15 @@ def _draw_leaderboard_row(self, img, draw, player, y, index, show_favorites): img.paste(flag, (x, flag_y), flag) x += flag.width + 2 - # Player name - is_fav = show_favorites and self._is_favorite(player) - if is_fav: - name_color = COLORS["azalea_pink"] - elif is_leader: - name_color = COLORS["masters_yellow"] - else: - name_color = COLORS["white"] + # Right-aligned score (and optional thru) + right_x = x1 - draw.text((x, text_y), name, fill=name_color, font=self.font_body) - - # Score and thru (right-aligned, non-overlapping) - right_x = self.width - 2 - - if self.show_thru: + if self.show_thru and col_width >= 60: thru = str(player.get("thru", "")) if thru: thru_w = self._text_width(draw, thru, self.font_detail) draw.text((right_x - thru_w, text_y + 1), thru, - fill=COLORS["gray"], font=self.font_detail) + fill=COLORS["white"], font=self.font_detail) right_x -= thru_w + 4 score_w = self._text_width(draw, score_text, self.font_body) @@ -390,6 +442,22 @@ def _draw_leaderboard_row(self, img, draw, player, y, index, show_favorites): fill=self._score_color(score, position if isinstance(position, int) else 99), font=self.font_body) + # Player name — clip to whatever's left between x and (score start - pad) + name_right = right_x - score_w - 3 + is_fav = show_favorites and self._is_favorite(player) + if is_fav: + name_color = COLORS["azalea_pink"] + elif is_leader: + name_color = COLORS["masters_yellow"] + else: + name_color = COLORS["white"] + + if x < name_right: + # Clip the name text to fit the remaining width + while name and self._text_width(draw, name, self.font_body) > name_right - x: + name = name[:-1] + draw.text((x, text_y), name, fill=name_color, font=self.font_body) + # ═══════════════════════════════════════════════════════════ # PLAYER CARD - Spacious layout # ═══════════════════════════════════════════════════════════ @@ -409,31 +477,98 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: x = 4 y = 4 - # Headshot on left + # Headshot on left (sized to available vertical space) + headshot_size = self.headshot_size if self.show_headshot: + # Clamp headshot so it + border fits inside the canvas with room + # above and below for padding. + max_headshot = self.height - (2 * y) - 2 + headshot_size = min(headshot_size, max(16, max_headshot)) headshot = self.logo_loader.get_player_headshot( player.get("player_id", ""), player.get("headshot_url"), - max_size=self.headshot_size, + max_size=headshot_size, ) if headshot: draw.rectangle( - [x - 1, y - 1, x + self.headshot_size, y + self.headshot_size], + [x - 1, y - 1, x + headshot_size, y + headshot_size], outline=COLORS["masters_yellow"], ) img.paste(headshot, (x, y), headshot if headshot.mode == "RGBA" else None) # Text area to the right of headshot - tx = x + self.headshot_size + 6 if self.show_headshot else x + tx = x + headshot_size + 6 if self.show_headshot else x + right_bound = self.width - 3 + bottom_bound = self.height - 3 # Player name - larger, with room to breathe - name = player.get("player", "Unknown") + raw_name = player.get("player", "Unknown") if self.tier == "tiny": - name = format_player_name(name, 10) + name = format_player_name(raw_name, 10) elif self.tier == "small": - name = format_player_name(name, 12) + name = format_player_name(raw_name, 12) + else: + # On wide-short panels we have horizontal room for a longer name. + name = format_player_name(raw_name, 18 if self.is_wide_short else 14) + # Wide-short layout: two text columns to the right of the headshot + # so we don't stack 5 text rows in 48px. + if self.is_wide_short: + score = player.get("score", 0) + score_text = format_score_to_par(score) + pos = player.get("position", "") + thru = player.get("thru", "") + country = player.get("country", "") + + # Column 1: name on top, then country flag + code + self._text_shadow(draw, (tx, y), name, self.font_header, COLORS["white"]) + yt1 = y + self._text_height(draw, name, self.font_header) + 3 + if country: + flag = self._get_flag(country) + fx = tx + if flag: + img.paste(flag, (fx, yt1), flag) + fx += flag.width + 3 + draw.text((fx, yt1), country, + fill=COLORS["light_gray"], font=self.font_detail) + yt1 += max(flag.height if flag else 0, + self._text_height(draw, country, self.font_detail)) + 2 + + # Position + thru underneath + if pos and yt1 + 9 <= bottom_bound: + pos_txt = f"Pos {pos}" + draw.text((tx, yt1), pos_txt, + fill=COLORS["masters_yellow"], font=self.font_detail) + if thru: + pos_w = self._text_width(draw, pos_txt, self.font_detail) + draw.text((tx + pos_w + 6, yt1), f"Thru {thru}", + fill=COLORS["white"], font=self.font_detail) + + # Column 2: big score block, right-aligned + score_w = self._text_width(draw, score_text, self.font_score) + score_h = self._text_height(draw, score_text, self.font_score) + sx = right_bound - score_w - 2 + sy = y + (self.height - score_h) // 2 - y + self._text_shadow(draw, (sx, sy), score_text, + self.font_score, self._score_color(score)) + + # Green jacket strip along the bottom (only if there's room) + jacket_count = MULTIPLE_WINNERS.get(raw_name, 0) + if jacket_count > 0: + jy = bottom_bound - 8 + if jy > yt1 + 2: + jacket_icon = self.logo_loader.get_green_jacket_icon(size=7) + jx = tx + if jacket_icon: + img.paste(jacket_icon, (jx, jy), + jacket_icon if jacket_icon.mode == "RGBA" else None) + jx += 9 + draw.text((jx, jy), f"x{jacket_count}", + fill=COLORS["masters_yellow"], font=self.font_detail) + return img + + # Standard (tall) vertical-stack layout self._text_shadow(draw, (tx, y), name, self.font_header, COLORS["white"]) y_text = y + self._text_height(draw, name, self.font_header) + 3 @@ -446,16 +581,18 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: img.paste(flag, (fx, y_text), flag) fx += flag.width + 3 draw.text((fx, y_text), country, fill=COLORS["light_gray"], font=self.font_detail) - y_text += 10 + y_text += max(flag.height if flag else 0, + self._text_height(draw, country, self.font_detail)) + 2 # Score - big and prominent with spacing score = player.get("score", 0) score_text = format_score_to_par(score) if self.tier == "large": - self._text_shadow(draw, (tx, y_text), score_text, - self.font_score, self._score_color(score)) - y_text += self._text_height(draw, score_text, self.font_score) + 4 + if y_text + self._text_height(draw, score_text, self.font_score) <= bottom_bound: + self._text_shadow(draw, (tx, y_text), score_text, + self.font_score, self._score_color(score)) + y_text += self._text_height(draw, score_text, self.font_score) + 4 else: draw.text((tx, y_text), score_text, fill=self._score_color(score), font=self.font_body) @@ -464,7 +601,7 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: # Position and thru - spread across with spacing pos = player.get("position", "") thru = player.get("thru", "") - if pos: + if pos and y_text + 9 <= bottom_bound: draw.text((tx, y_text), f"Pos: {pos}", fill=COLORS["masters_yellow"], font=self.font_detail) if thru and self.tier != "tiny": @@ -473,18 +610,19 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: fill=COLORS["white"], font=self.font_detail) y_text += 9 - # Green jacket count at bottom - jacket_count = MULTIPLE_WINNERS.get(player.get("player", ""), 0) + # Green jacket count at bottom (only if there's still vertical room) + jacket_count = MULTIPLE_WINNERS.get(raw_name, 0) if jacket_count > 0 and self.tier != "tiny": jy = self.height - 10 - jacket_icon = self.logo_loader.get_green_jacket_icon(size=8) - jx = 4 - if jacket_icon: - img.paste(jacket_icon, (jx, jy), - jacket_icon if jacket_icon.mode == "RGBA" else None) - jx += 10 - draw.text((jx, jy), f"x{jacket_count} Green Jackets", - fill=COLORS["masters_yellow"], font=self.font_detail) + if jy > y_text + 2: + jacket_icon = self.logo_loader.get_green_jacket_icon(size=8) + jx = 4 + if jacket_icon: + img.paste(jacket_icon, (jx, jy), + jacket_icon if jacket_icon.mode == "RGBA" else None) + jx += 10 + draw.text((jx, jy), f"x{jacket_count} Green Jackets", + fill=COLORS["masters_yellow"], font=self.font_detail) return img @@ -777,28 +915,50 @@ def render_schedule(self, schedule_data: List[Dict], page: int = 0) -> Optional[ return img content_top = self.header_height + 2 - # Each tee time gets 2 lines: time + players + content_bottom = self.height - self.footer_height - 2 entry_h = (self.row_height + self.row_gap) * 2 + 2 - visible = max(1, (self.height - content_top - self.footer_height - 2) // entry_h) + rows = max(1, (content_bottom - content_top) // entry_h) + + two_column = self.is_wide_short + cols = 2 if two_column else 1 + per_page = rows * cols - total_pages = max(1, (len(schedule_data) + visible - 1) // visible) + total_pages = max(1, (len(schedule_data) + per_page - 1) // per_page) page = page % total_pages + start = page * per_page + entries = schedule_data[start : start + per_page] - start = page * visible - entries = schedule_data[start : start + visible] + col_w = self.width // cols + if two_column: + draw.line([(col_w, content_top), (col_w, content_bottom)], + fill=COLORS["masters_dark"]) + + # Fit ~3 player names in a single column, 2 per side in two-col + name_budget = 10 if not two_column else 9 + names_per_entry = 3 if not two_column else 2 - y = content_top for i, entry in enumerate(entries): + col = i // rows + row = i % rows + cx = col * col_w + 3 + cx_right = (col + 1) * col_w - 3 + y = content_top + row * entry_h + # Time in yellow time_text = entry.get("time", "") - draw.text((3, y), time_text, fill=COLORS["masters_yellow"], font=self.font_body) + draw.text((cx, y), time_text, + fill=COLORS["masters_yellow"], font=self.font_body) y += self.row_height + 1 - # Players indented - players = entry.get("players", []) - players_text = ", ".join(format_player_name(p, 10) for p in players[:3]) - draw.text((6, y), players_text, fill=COLORS["white"], font=self.font_detail) - y += self.row_height + 3 + # Players — clip to column width + players = entry.get("players", []) or [] + players_text = ", ".join( + format_player_name(p, name_budget) for p in players[:names_per_entry] + ) + while players_text and self._text_width(draw, players_text, self.font_detail) > (cx_right - cx - 3): + players_text = players_text[:-1] + draw.text((cx + 3, y), players_text, + fill=COLORS["white"], font=self.font_detail) self._draw_page_dots(draw, page, total_pages) return img @@ -923,21 +1083,72 @@ def render_field_overview(self, leaderboard_data: List[Dict]) -> Optional[Image. over = sum(1 for p in leaderboard_data if p.get("score", 0) > 0) even = total - under - over - y = self.header_height + 4 line_h = 10 if self.tier == "large" else 8 + content_top = self.header_height + 3 + content_bottom = self.height - 2 + available = content_bottom - content_top + + leader_block_h = line_h * 2 + 6 # divider + "Leader" label + leader row + + # Wide-short layout: put par stats in two columns, leader on the right + # column, so everything fits in a 48-tall canvas. + if self.is_wide_short: + col_w = self.width // 2 + # Tighter vertical rhythm for detail rows so all 4 stat rows fit. + detail_h = self._text_height(draw, "A", self.font_detail) + detail_step = detail_h + 1 + y_l = content_top + draw.text((4, y_l), f"Players: {total}", + fill=COLORS["white"], font=self.font_body) + y_l += self._text_height(draw, "A", self.font_body) + 2 + draw.text((4, y_l), f"Under: {under}", + fill=COLORS["under_par"], font=self.font_detail) + y_l += detail_step + draw.text((4, y_l), f"Even: {even}", + fill=COLORS["even_par"], font=self.font_detail) + y_l += detail_step + if y_l + detail_h <= content_bottom: + draw.text((4, y_l), f"Over: {over}", + fill=COLORS["over_par"], font=self.font_detail) + + if leaderboard_data: + draw.line([(col_w, content_top), + (col_w, content_bottom)], + fill=COLORS["masters_yellow"]) + y_r = content_top + draw.text((col_w + 4, y_r), "LEADER", + fill=COLORS["masters_yellow"], font=self.font_detail) + y_r += line_h + 1 + leader = leaderboard_data[0] + leader_name = format_player_name(leader.get("player", ""), self.name_len) + leader_score = format_score_to_par(leader.get("score", 0)) + self._text_shadow(draw, (col_w + 4, y_r), leader_name, + self.font_body, COLORS["white"]) + y_r += line_h + 1 + draw.text((col_w + 4, y_r), leader_score, + fill=self._score_color(leader.get("score", 0)), + font=self.font_body) + return img + + # Single-column layout — decide whether the leader block fits + show_leader = leaderboard_data and ( + available >= line_h * 4 + 6 + leader_block_h + ) + y = content_top draw.text((4, y), f"Players: {total}", fill=COLORS["white"], font=self.font_body) y += line_h + 2 draw.text((4, y), f"Under par: {under}", fill=COLORS["under_par"], font=self.font_detail) y += line_h - draw.text((4, y), f"Even par: {even}", fill=COLORS["even_par"], font=self.font_detail) - y += line_h - draw.text((4, y), f"Over par: {over}", fill=COLORS["over_par"], font=self.font_detail) - y += line_h + 3 + if y + self._text_height(draw, "A", self.font_detail) <= content_bottom: + draw.text((4, y), f"Even par: {even}", fill=COLORS["even_par"], font=self.font_detail) + y += line_h + if y + self._text_height(draw, "A", self.font_detail) <= content_bottom: + draw.text((4, y), f"Over par: {over}", fill=COLORS["over_par"], font=self.font_detail) + y += line_h + 3 - # Leader highlight - if leaderboard_data: + if show_leader: draw.line([(3, y), (self.width - 3, y)], fill=COLORS["masters_yellow"]) y += 4 diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index b83f245..e1a838a 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -57,6 +57,15 @@ def render_leaderboard( if not leaderboard_data: return None + # Wide-short panels use the base class two-column layout, which + # adapts to the horizontal space. We lose the texture background + # in that case — an acceptable trade for fitting twice as many + # players on screen. + if self.is_wide_short: + return super().render_leaderboard( + leaderboard_data, show_favorites=show_favorites, page=page + ) + total_pages = max(1, (len(leaderboard_data) + self.max_players - 1) // self.max_players) page = page % total_pages @@ -86,6 +95,12 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: if not player: return None + # Wide-short panels (192x48, 256x64, etc.): delegate to the base + # class's two-column layout. We drop the round-scores block — there's + # no room for it on a 48-tall canvas — but the core card stays legible. + if self.is_wide_short: + return super().render_player_card(player) + img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) draw = ImageDraw.Draw(img) @@ -190,32 +205,67 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: return img def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: - """Enhanced hole card — left info panel, right hole image using full height.""" + """Enhanced hole card — left info panel, right hole image using full height. + + Layout is anchored to the TOP and BOTTOM of the canvas so hole number + is pinned to the top, par/yardage are pinned to the bottom, and the + hole name fills whatever's left in the middle (wrapped on tall + displays, truncated on short ones). + + Small tier (64x32 and similar) uses a compact text-only layout — + the hole map is too small to be useful at that size and eating it + lets us actually show par and yardage without clipping. + """ hole_info = get_hole_info(hole_number) img = self._draw_gradient_bg((10, 70, 25), COLORS["augusta_green"]) draw = ImageDraw.Draw(img) - # Left panel width for text info - left_w = 38 if self.tier == "large" else 28 + # Compact text-only layout for small/short displays. + if self.tier == "small": + return self._render_hole_card_compact(img, draw, hole_number, hole_info) + + # Left panel width for text info — wider on large tier, and wider + # still when we have lots of horizontal room to spare (e.g. 192x48). + if self.tier == "large": + left_w = 48 if self.is_wide_short else 38 + else: + left_w = 28 # ── Left panel: hole info ── draw.rectangle([(0, 0), (left_w - 1, self.height - 1)], fill=COLORS["masters_dark"]) draw.line([(left_w - 1, 0), (left_w - 1, self.height)], fill=COLORS["masters_yellow"]) - # Hole number + line_h = self._text_height(draw, "A", self.font_detail) + 1 + max_text_w = left_w - 4 + + # Top: hole number hole_text = f"#{hole_number}" + hole_h = self._text_height(draw, hole_text, self.font_header) hw = self._text_width(draw, hole_text, self.font_header) self._text_shadow(draw, ((left_w - hw) // 2, 2), hole_text, self.font_header, COLORS["white"]) + top_bound = 2 + hole_h + 2 + + # Bottom: par + yardage pinned to actual canvas bottom + par_text = f"Par {hole_info['par']}" + yard_text = f"{hole_info['yardage']}y" + par_block_h = line_h * 2 + par_y = self.height - par_block_h - 2 + pw = self._text_width(draw, par_text, self.font_detail) + draw.text(((left_w - pw) // 2, par_y), par_text, + fill=COLORS["white"], font=self.font_detail) + yw = self._text_width(draw, yard_text, self.font_detail) + draw.text(((left_w - yw) // 2, par_y + line_h), yard_text, + fill=COLORS["light_gray"], font=self.font_detail) + bottom_bound = par_y - 2 - # Hole name — width-aware wrapping + # Middle: hole name — fit in whatever space is left name_text = hole_info["name"] - name_y = 12 if self.tier == "tiny" else 14 - line_h = self._text_height(draw, "A", self.font_detail) + 1 - max_text_w = left_w - 4 + name_slot = bottom_bound - top_bound + max_lines = max(1, name_slot // line_h) - name_lines = [] + name_lines: List[str] = [] nw = self._text_width(draw, name_text, self.font_detail) if nw <= max_text_w: name_lines = [name_text] @@ -231,33 +281,28 @@ def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: name_lines.append(current) current = word if current: - # Truncate last line with ellipsis if too wide - if self._text_width(draw, current, self.font_detail) > max_text_w: - while len(current) > 1 and self._text_width(draw, current + "..", self.font_detail) > max_text_w: - current = current[:-1] - current = current + ".." name_lines.append(current) - + # Clamp to available lines; ellipsize the last surviving line if clipped. + if len(name_lines) > max_lines: + name_lines = name_lines[:max_lines] + last = name_lines[-1] + while last and self._text_width(draw, last + "..", self.font_detail) > max_text_w: + last = last[:-1] + name_lines[-1] = (last + "..") if last else ".." + # Also shrink any single line that doesn't fit horizontally. + for idx, line in enumerate(name_lines): + while line and self._text_width(draw, line, self.font_detail) > max_text_w: + line = line[:-1] + name_lines[idx] = line + + # Vertically center the name block in its slot. + block_h = len(name_lines) * line_h + name_y = top_bound + max(0, (name_slot - block_h) // 2) for i, line in enumerate(name_lines): lw = self._text_width(draw, line, self.font_detail) draw.text(((left_w - lw) // 2, name_y + i * line_h), line, fill=COLORS["masters_yellow"], font=self.font_detail) - # Par and yardage — anchored to bottom, above name block - name_block_bottom = name_y + len(name_lines) * line_h - par_yard_h = line_h * 2 + 2 # two lines plus padding - par_y = max(name_block_bottom + 2, self.height - par_yard_h - 2) - - par_text = f"Par {hole_info['par']}" - pw = self._text_width(draw, par_text, self.font_detail) - draw.text(((left_w - pw) // 2, par_y), par_text, - fill=COLORS["white"], font=self.font_detail) - - yard_text = f"{hole_info['yardage']}y" - yw = self._text_width(draw, yard_text, self.font_detail) - draw.text(((left_w - yw) // 2, par_y + line_h), yard_text, - fill=COLORS["light_gray"], font=self.font_detail) - # ── Right side: hole layout image using full height ── img_x = left_w + 2 img_w = self.width - img_x - 2 @@ -286,10 +331,78 @@ def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: return img + def _render_hole_card_compact(self, img, draw, hole_number: int, + hole_info: Dict) -> Image.Image: + """Two-column compact hole card for short/small displays (e.g. 64x32). + + Drops the hole map image entirely — it's too small to read at this + size, and dedicating the canvas to text lets us show hole #, name, + par, yardage, and zone all without clipping. + + Layout: + ┌─────────────┬─────────────┐ + │ #12 │ Par 3 │ + │ Golden Bell │ 155y │ + │ │ AMEN CORNER │ + └─────────────┴─────────────┘ + """ + col_w = self.width // 2 + # Divider + draw.line([(col_w, 1), (col_w, self.height - 2)], + fill=COLORS["masters_yellow"]) + + line_h = self._text_height(draw, "A", self.font_detail) + 1 + + # Left column: hole number (top) + name (centered) + hole_text = f"#{hole_number}" + hw = self._text_width(draw, hole_text, self.font_body) + hole_h = self._text_height(draw, hole_text, self.font_body) + draw.text(((col_w - hw) // 2, 1), hole_text, + fill=COLORS["white"], font=self.font_body) + + name_text = hole_info["name"] + # Truncate name to fit left column + max_name_w = col_w - 4 + while name_text and self._text_width(draw, name_text, self.font_detail) > max_name_w: + name_text = name_text[:-1] + name_y = 1 + hole_h + 2 + nw = self._text_width(draw, name_text, self.font_detail) + draw.text(((col_w - nw) // 2, name_y), name_text, + fill=COLORS["masters_yellow"], font=self.font_detail) + + # Right column: Par / yardage / zone stacked + rx = col_w + 3 + right_w = self.width - rx - 2 + y = 1 + par_text = f"Par {hole_info['par']}" + draw.text((rx, y), par_text, + fill=COLORS["white"], font=self.font_detail) + y += line_h + + yard_text = f"{hole_info['yardage']}y" + draw.text((rx, y), yard_text, + fill=COLORS["light_gray"], font=self.font_detail) + y += line_h + + zone = hole_info.get("zone") + if zone: + zone_text = zone.upper() + while zone_text and self._text_width(draw, zone_text, self.font_detail) > right_w: + zone_text = zone_text[:-1] + draw.text((rx, y), zone_text, + fill=COLORS["masters_yellow"], font=self.font_detail) + + return img + def render_live_alert( self, player_name: str, hole: int, score_desc: str ) -> Optional[Image.Image]: - """Render a live scoring alert with generous spacing.""" + """Render a live scoring alert. + + Wide-short panels use a horizontal layout: LIVE badge on the left, + then player name on top / hole info beneath, with the big score + description hugging the right edge. + """ img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) draw = ImageDraw.Draw(img) @@ -302,14 +415,50 @@ def render_live_alert( self._text_shadow(draw, (3, 1), "LIVE", self.font_header, COLORS["white"] if not is_great else COLORS["bg"]) + # Wide-short horizontal layout: everything lives below the header bar + # in two columns so we don't stack 3 rows of large text on 48px. + if self.is_wide_short: + desc_upper = score_desc.upper() + desc_color = COLORS["masters_yellow"] if is_great else COLORS["under_par"] + desc_w = self._text_width(draw, desc_upper, self.font_score) + desc_h = self._text_height(draw, desc_upper, self.font_score) + + # Right-hand big score block, vertically centered in the body. + body_top = self.header_height + 2 + body_bottom = self.height - 3 + body_mid = (body_top + body_bottom) // 2 + desc_x = self.width - desc_w - 4 + desc_y = body_mid - desc_h // 2 + self._text_shadow(draw, (desc_x, desc_y), + desc_upper, self.font_score, desc_color) + + # Left-hand stack: name on top, hole info underneath. + name = format_player_name(player_name, 18) + name_h = self._text_height(draw, name, self.font_body) + text_left = 4 + text_top = body_top + 2 + self._text_shadow(draw, (text_left, text_top), + name, self.font_body, COLORS["white"]) + + if 1 <= hole <= 18: + hole_info = get_hole_info(hole) + hole_text = f"Hole {hole}: {hole_info['name']}" + # Clip to the space before the score block. + max_w = desc_x - text_left - 6 + while hole_text and self._text_width(draw, hole_text, self.font_detail) > max_w: + hole_text = hole_text[:-1] + draw.text((text_left, text_top + name_h + 3), + hole_text, fill=COLORS["light_gray"], + font=self.font_detail) + return img + + # Standard (taller) vertical stack layout y = self.header_height + 6 - # Player name with room name = format_player_name(player_name, self.name_len) self._text_shadow(draw, (4, y), name, self.font_body, COLORS["white"]) y += self._text_height(draw, name, self.font_body) + 6 - # Score type - big and centered desc_upper = score_desc.upper() + "!" desc_color = COLORS["masters_yellow"] if is_great else COLORS["under_par"] dw = self._text_width(draw, desc_upper, self.font_score) @@ -317,7 +466,6 @@ def render_live_alert( desc_upper, self.font_score, desc_color) y += self._text_height(draw, desc_upper, self.font_score) + 6 - # Hole info if 1 <= hole <= 18: hole_info = get_hole_info(hole) hole_text = f"Hole {hole} - {hole_info['name']}" From c89f195b5bc271b4c70911699f48695960a25eb9 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 9 Apr 2026 15:22:40 -0400 Subject: [PATCH 2/4] fix(masters-tournament): expose display timing config, don't drop 3rd golfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two PR-review follow-ups verified against the current code. 1) config_schema.json was missing player_card_duration, hole_display_duration, and page_display_duration. Manager reads all three (manager.py:125,129,134 at init, and 588-590 on config hot-reload) but since they weren't in the schema, the LEDMatrix web UI couldn't surface them — users had to edit JSON by hand to override the defaults. Added all three as root-level properties with type: integer, minimum: 1, maximum: 300, and descriptions that match the manager's defaults (8 / 15 / 15). 2) render_schedule's wide-short two-column branch was slicing players[:names_per_entry] with names_per_entry=2, dropping the third golfer of every threesome. Verified against live 2026 Masters data: 29 of 31 tee- time groups are threesomes, so the old code was hiding the third player in 94% of groups on 192x48. Build players_text from the full players list instead and let the existing width-clipping loop shorten the string — the user would rather see "Kataoka, C. Ortiz, Max Ho.." (truncated but unambiguous) than "Kataoka, C. Ortiz" (looks like a twosome). Dropped names_per_entry entirely since it's no longer referenced. Bumps manifest 2.2.1 -> 2.2.2. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins.json | 2 +- plugins/masters-tournament/config_schema.json | 21 +++++++++++++++++++ plugins/masters-tournament/manifest.json | 7 ++++++- .../masters-tournament/masters_renderer.py | 10 +++++---- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/plugins.json b/plugins.json index 6c921b2..43fe355 100644 --- a/plugins.json +++ b/plugins.json @@ -702,7 +702,7 @@ "last_updated": "2026-04-09", "verified": true, "screenshot": "", - "latest_version": "2.2.1" + "latest_version": "2.2.2" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/config_schema.json b/plugins/masters-tournament/config_schema.json index 03cb78b..749783f 100644 --- a/plugins/masters-tournament/config_schema.json +++ b/plugins/masters-tournament/config_schema.json @@ -23,6 +23,27 @@ "maximum": 3600, "description": "How often to fetch new data in seconds (30s during tournament, 3600s off-season)" }, + "player_card_duration": { + "type": "integer", + "default": 8, + "minimum": 1, + "maximum": 300, + "description": "Seconds each player card is shown before rotating to the next player in the player card display mode" + }, + "hole_display_duration": { + "type": "integer", + "default": 15, + "minimum": 1, + "maximum": 300, + "description": "Seconds between hole advances in course tour and hole-by-hole display modes" + }, + "page_display_duration": { + "type": "integer", + "default": 15, + "minimum": 1, + "maximum": 300, + "description": "Seconds between page advances in paginated modes (leaderboard, champions, tournament stats, schedule, course overview)" + }, "mock_data": { "type": "boolean", "default": false, diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 41bd753..e69010a 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.2.1", + "version": "2.2.2", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,11 @@ "height": 64 }, "versions": [ + { + "version": "2.2.2", + "released": "2026-04-09", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.2.1", "released": "2026-04-09", diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index f5ddda9..a0a81e1 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -933,9 +933,11 @@ def render_schedule(self, schedule_data: List[Dict], page: int = 0) -> Optional[ draw.line([(col_w, content_top), (col_w, content_bottom)], fill=COLORS["masters_dark"]) - # Fit ~3 player names in a single column, 2 per side in two-col + # Masters pairings are almost always threesomes; always build text + # from the full list and let the width-clipping loop shorten it. + # Dropping the third golfer up front would hide half of who's in the + # group on wide-short layouts. name_budget = 10 if not two_column else 9 - names_per_entry = 3 if not two_column else 2 for i, entry in enumerate(entries): col = i // rows @@ -950,10 +952,10 @@ def render_schedule(self, schedule_data: List[Dict], page: int = 0) -> Optional[ fill=COLORS["masters_yellow"], font=self.font_body) y += self.row_height + 1 - # Players — clip to column width + # Players — build from full list, clip to column width players = entry.get("players", []) or [] players_text = ", ".join( - format_player_name(p, name_budget) for p in players[:names_per_entry] + format_player_name(p, name_budget) for p in players ) while players_text and self._text_width(draw, players_text, self.font_detail) > (cx_right - cx - 3): players_text = players_text[:-1] From 6a2a479eaaf389539b8580fa34ce07d4520f1bbc Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 9 Apr 2026 15:45:24 -0400 Subject: [PATCH 3/4] fix(masters-tournament): enlarge wide-short player card to fill canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a 192x48 display the player card was wasting most of the canvas — a 28px headshot floating in the top-left, small fonts, lots of dead space on the right. User request: scale everything up to fill the available space, and scale down gracefully on smaller/larger panels. Rewrote the wide-short branch of render_player_card into a dedicated _render_player_card_wide_short() with proportional sizing: - Headshot fills the vertical (height - 2*padding), capped by width/4 so narrow wide-short panels (e.g. 128x48) still leave room for the name and score columns. On 192x48 this gives a 42px headshot; on 192x64 a 48px headshot; on 256x64 a 58px headshot. - Score font scales with min(height // 2.4, width // 8, 24). On 192x48 that's ~20px; on 128x48 it drops to 16px so the score block doesn't eat the name column. - Name uses a new _fit_name() helper that tries the biggest font (PressStart2P down to 4x6-font) and the biggest display form (full name → "F. LastName" → last name only) where the full text actually fits. Truncation is a last resort, not the first choice. - POS/THRU row now clips against the text column so it doesn't bleed into the score block. When "THRU F" would collide, falls back to just "F". - Green-jacket strip at the bottom when there's vertical room, with a shorter "xN" label if the full "xN GREEN JACKETS" won't fit. - Uses _load_font_sized() helper (new) that loads a TTF at any pixel size. Verified at 128x48, 192x48, 192x64, 256x64: name shows as "DeChambeau" (full last name, sometimes full name depending on width), headshot fills most of the height, big right-edge score with a faint separator. Non-wide-short sizes (32x16, 64x32, 128x64) unchanged — the new layout is gated on is_wide_short. test_plugin_standalone.py: 45/45 still passing. Deployed to devpi (192x48) and verified live. Bumps manifest 2.2.2 -> 2.2.3. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins.json | 2 +- plugins/masters-tournament/manifest.json | 7 +- .../masters-tournament/masters_renderer.py | 284 ++++++++++++++---- 3 files changed, 227 insertions(+), 66 deletions(-) diff --git a/plugins.json b/plugins.json index 43fe355..5650984 100644 --- a/plugins.json +++ b/plugins.json @@ -702,7 +702,7 @@ "last_updated": "2026-04-09", "verified": true, "screenshot": "", - "latest_version": "2.2.2" + "latest_version": "2.2.3" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index e69010a..67d27aa 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.2.2", + "version": "2.2.3", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,11 @@ "height": 64 }, "versions": [ + { + "version": "2.2.3", + "released": "2026-04-09", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.2.2", "released": "2026-04-09", diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index a0a81e1..e4d3acc 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -113,6 +113,18 @@ def _load_font(name: str) -> ImageFont.ImageFont: return ImageFont.load_default() +def _load_font_sized(filename: str, size: int) -> Optional[ImageFont.ImageFont]: + """Load a specific TTF at an arbitrary point size, returning None on failure.""" + path = _find_font_path(filename) + if not path: + return None + try: + return ImageFont.truetype(path, size) + except Exception as e: + logger.warning(f"Failed to load font {path}@{size}: {e}") + return None + + class MastersRenderer: """Broadcast-quality Masters Tournament renderer with pagination & scrolling.""" @@ -474,14 +486,21 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: draw.rectangle([(0, 0), (self.width - 1, self.height - 1)], outline=COLORS["masters_yellow"]) + raw_name = player.get("player", "Unknown") + + # Wide-short layout: maximize use of the canvas. Headshot fills the + # full vertical minus padding; name/country/pos use fonts scaled to + # height; big score block hugs the right edge. Works for 192x48, + # 192x64, 256x64 and anything else aspect >= 2.5. + if self.is_wide_short: + return self._render_player_card_wide_short(img, draw, player, raw_name) + x = 4 y = 4 # Headshot on left (sized to available vertical space) headshot_size = self.headshot_size if self.show_headshot: - # Clamp headshot so it + border fits inside the canvas with room - # above and below for padding. max_headshot = self.height - (2 * y) - 2 headshot_size = min(headshot_size, max(16, max_headshot)) headshot = self.logo_loader.get_player_headshot( @@ -497,76 +516,15 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: img.paste(headshot, (x, y), headshot if headshot.mode == "RGBA" else None) - # Text area to the right of headshot tx = x + headshot_size + 6 if self.show_headshot else x - right_bound = self.width - 3 bottom_bound = self.height - 3 - # Player name - larger, with room to breathe - raw_name = player.get("player", "Unknown") if self.tier == "tiny": name = format_player_name(raw_name, 10) elif self.tier == "small": name = format_player_name(raw_name, 12) else: - # On wide-short panels we have horizontal room for a longer name. - name = format_player_name(raw_name, 18 if self.is_wide_short else 14) - - # Wide-short layout: two text columns to the right of the headshot - # so we don't stack 5 text rows in 48px. - if self.is_wide_short: - score = player.get("score", 0) - score_text = format_score_to_par(score) - pos = player.get("position", "") - thru = player.get("thru", "") - country = player.get("country", "") - - # Column 1: name on top, then country flag + code - self._text_shadow(draw, (tx, y), name, self.font_header, COLORS["white"]) - yt1 = y + self._text_height(draw, name, self.font_header) + 3 - if country: - flag = self._get_flag(country) - fx = tx - if flag: - img.paste(flag, (fx, yt1), flag) - fx += flag.width + 3 - draw.text((fx, yt1), country, - fill=COLORS["light_gray"], font=self.font_detail) - yt1 += max(flag.height if flag else 0, - self._text_height(draw, country, self.font_detail)) + 2 - - # Position + thru underneath - if pos and yt1 + 9 <= bottom_bound: - pos_txt = f"Pos {pos}" - draw.text((tx, yt1), pos_txt, - fill=COLORS["masters_yellow"], font=self.font_detail) - if thru: - pos_w = self._text_width(draw, pos_txt, self.font_detail) - draw.text((tx + pos_w + 6, yt1), f"Thru {thru}", - fill=COLORS["white"], font=self.font_detail) - - # Column 2: big score block, right-aligned - score_w = self._text_width(draw, score_text, self.font_score) - score_h = self._text_height(draw, score_text, self.font_score) - sx = right_bound - score_w - 2 - sy = y + (self.height - score_h) // 2 - y - self._text_shadow(draw, (sx, sy), score_text, - self.font_score, self._score_color(score)) - - # Green jacket strip along the bottom (only if there's room) - jacket_count = MULTIPLE_WINNERS.get(raw_name, 0) - if jacket_count > 0: - jy = bottom_bound - 8 - if jy > yt1 + 2: - jacket_icon = self.logo_loader.get_green_jacket_icon(size=7) - jx = tx - if jacket_icon: - img.paste(jacket_icon, (jx, jy), - jacket_icon if jacket_icon.mode == "RGBA" else None) - jx += 9 - draw.text((jx, jy), f"x{jacket_count}", - fill=COLORS["masters_yellow"], font=self.font_detail) - return img + name = format_player_name(raw_name, 14) # Standard (tall) vertical-stack layout self._text_shadow(draw, (tx, y), name, self.font_header, COLORS["white"]) @@ -626,6 +584,204 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: return img + def _fit_name(self, draw, raw_name: str, max_width: int, + max_height: int) -> Tuple[ImageFont.ImageFont, str, int]: + """Pick the biggest font + display form where the full name fits. + + Tries PressStart2P (blockier, bigger) first at descending sizes, then + the narrower 4x6-font, for each candidate display string: + 1. Full name ("Scottie Scheffler") + 2. First initial + last ("S. Scheffler") + 3. Last name only ("Scheffler") + Only falls back to mid-word truncation if literally nothing fits. + Returns (font, display_string, rendered_height). + """ + parts = raw_name.split() + full = raw_name.strip() or "?" + last = parts[-1] if parts else full + initial_last = f"{parts[0][0]}. {last}" if len(parts) > 1 else full + candidates = [full, initial_last, last] + + # Try big fonts first, shrinking as needed. Cap each candidate font + # at max_height so we don't overflow vertically. + sizes_press = [16, 14, 12, 10, 8] + sizes_4x6 = [14, 12, 10, 8, 7, 6] + + font_trials: List[Tuple[str, int]] = [] + for s in sizes_press: + font_trials.append(("PressStart2P-Regular.ttf", s)) + for s in sizes_4x6: + font_trials.append(("4x6-font.ttf", s)) + + best_fallback = None + for filename, size in font_trials: + font = _load_font_sized(filename, size) + if font is None: + continue + line_h = self._text_height(draw, "A", font) + if line_h > max_height: + continue + for candidate in candidates: + if self._text_width(draw, candidate, font) <= max_width: + return font, candidate, line_h + # Remember the biggest font where even the last-name form was + # the only option we could still truncate from. + if best_fallback is None: + best_fallback = (font, last, line_h) + + # Nothing fits cleanly — truncate the last name in the smallest + # surviving font (or 4x6 size 6 as an ultimate fallback). + if best_fallback is None: + font = _load_font_sized("4x6-font.ttf", 6) or self.font_detail + return font, last, self._text_height(draw, "A", font) + + font, text, h = best_fallback + while text and self._text_width(draw, text, font) > max_width: + text = text[:-1] + return font, text, h + + def _render_player_card_wide_short(self, img, draw, player, raw_name): + """Maximize canvas usage for wide-short player cards. + + Sizes scale from actual width/height: + - headshot fills height minus padding (e.g. 40px tall on 48-tall, + 56px on 64-tall, 22px on 32-tall) + - name font is ~1/5 of height, loaded dynamically + - score font is ~1/3 of height, loaded dynamically + - flag height matches the country label font + + Layout columns (left → right): + [ headshot ] [ name / flag+country / pos+thru ] [ big score ] + """ + padding = max(3, self.height // 16) + bottom_bound = self.height - padding + + # Headshot — fill the vertical budget, but also cap horizontally so + # narrow wide-short panels (e.g. 128x48) leave enough room for the + # name + score columns. The /4 cap ties the headshot to available + # width, so on 128x48 it shrinks to 32px while 192x48 keeps 42px. + headshot_size = max(16, min(self.height - 2 * padding, self.width // 4)) + hx = padding + hy = padding + if self.show_headshot: + headshot = self.logo_loader.get_player_headshot( + player.get("player_id", ""), + player.get("headshot_url"), + max_size=headshot_size, + ) + if headshot: + # Center the actual image in the reserved slot in case the + # loader returned something smaller than max_size. + hpx = hx + (headshot_size - headshot.width) // 2 + hpy = hy + (headshot_size - headshot.height) // 2 + draw.rectangle( + [hx - 1, hy - 1, hx + headshot_size, hy + headshot_size], + outline=COLORS["masters_yellow"], + ) + img.paste(headshot, (hpx, hpy), + headshot if headshot.mode == "RGBA" else None) + + # Proportional font sizes. Score scales with BOTH height and width so + # narrow wide-short displays (e.g. 128x48) don't let the score eat + # the entire text column. + score_px = max(10, min(24, int(self.height // 2.4), self.width // 8)) + detail_px = max(6, min(10, self.height // 7)) + + score_font = _load_font_sized("PressStart2P-Regular.ttf", score_px) or self.font_score + detail_font = _load_font_sized("4x6-font.ttf", detail_px) or self.font_detail + + # Reserve the right-hand score block width based on the actual score text. + score = player.get("score", 0) + score_text = format_score_to_par(score) + score_w = self._text_width(draw, score_text, score_font) + score_h = self._text_height(draw, score_text, score_font) + score_block_w = score_w + padding * 2 + score_x = self.width - score_w - padding - 1 + score_y = (self.height - score_h) // 2 + self._text_shadow(draw, (score_x, score_y), score_text, + score_font, self._score_color(score)) + + # Faint separator before the score column + sep_x = self.width - score_block_w - 1 + draw.line([(sep_x, padding), (sep_x, bottom_bound)], + fill=COLORS["masters_dark"]) + + # Text column between the headshot and the score separator + tx = hx + headshot_size + padding + 3 + tx_right = sep_x - 3 + text_w = tx_right - tx + + # Name font: pick the biggest candidate where the full name fits. + # PressStart2P is nearly monospace so it shows ~7 chars per 96px at + # size 12; 4x6-font is much narrower. We try several sizes of each + # and fall back to truncation only if nothing fits. + name_font, name_display, name_h = self._fit_name( + draw, raw_name, text_w, max_height=self.height // 3, + ) + + ty = padding + self._text_shadow(draw, (tx, ty), name_display, name_font, COLORS["white"]) + ty += name_h + max(3, self.height // 16) + + # Country flag + code + country = player.get("country", "") + detail_h = self._text_height(draw, "A", detail_font) + if country and ty + detail_h <= bottom_bound: + flag = self._get_flag(country) + fx = tx + if flag: + # Vertically center flag against the country label line + flag_y = ty + max(0, (detail_h - flag.height) // 2) + img.paste(flag, (fx, flag_y), flag) + fx += flag.width + 3 + draw.text((fx, ty), country, + fill=COLORS["light_gray"], font=detail_font) + row_h = max(flag.height if flag else 0, detail_h) + ty += row_h + 2 + + # Pos + Thru row — clip to text column so we don't bleed into score. + pos = player.get("position", "") + thru = player.get("thru", "") + if pos and ty + detail_h <= bottom_bound: + pos_text = f"POS {pos}" + pos_w = self._text_width(draw, pos_text, detail_font) + if tx + pos_w <= tx_right: + draw.text((tx, ty), pos_text, + fill=COLORS["masters_yellow"], font=detail_font) + if thru: + thru_text = f"THRU {thru}" + thru_x = tx + pos_w + 8 + if thru_x + self._text_width(draw, thru_text, detail_font) > tx_right: + # Try the shorter form on the next line instead of clobbering the score + thru_text = str(thru) + thru_x = tx + pos_w + 6 + if thru_x + self._text_width(draw, thru_text, detail_font) <= tx_right: + draw.text((thru_x, ty), thru_text, + fill=COLORS["white"], font=detail_font) + ty += detail_h + 1 + + # Green jacket strip along the bottom if there's room + jacket_count = MULTIPLE_WINNERS.get(raw_name, 0) + if jacket_count > 0: + jy = bottom_bound - detail_h + if jy > ty + 1: + jacket_icon = self.logo_loader.get_green_jacket_icon( + size=max(7, detail_h) + ) + jx = tx + if jacket_icon: + img.paste(jacket_icon, (jx, jy), + jacket_icon if jacket_icon.mode == "RGBA" else None) + jx += jacket_icon.width + 2 + jacket_text = f"x{jacket_count} GREEN JACKETS" + # Shorter label if the long one won't fit + if self._text_width(draw, jacket_text, detail_font) > tx_right - jx: + jacket_text = f"x{jacket_count}" + draw.text((jx, jy), jacket_text, + fill=COLORS["masters_yellow"], font=detail_font) + + return img + # ═══════════════════════════════════════════════════════════ # HOLE CARD - Clean layout # ═══════════════════════════════════════════════════════════ From 1c72705522ecb0087d22d3352cfe80c1820e1d01 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 9 Apr 2026 16:05:02 -0400 Subject: [PATCH 4/4] fix(masters-tournament): log flag failures, memoize font loads, transliterate non-ASCII names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three PR-review follow-ups verified against the current code before fixing. 1) _get_flag silently swallowed exceptions masters_renderer.py:318 had a bare `except Exception: return None` with no logging, so a corrupt or unreadable flag PNG was invisible in the logs. Now logs the failure with exc_info (including country_code and flag_path) via self.logger.warning(..., exc_info=True), and caches the failure in _flag_cache so a broken file doesn't get re-opened on every frame. Updated the _flag_cache annotation to Dict[str, Optional[Image.Image]]. 2) _load_font_sized was called ~13 times per player card render _fit_name loops over ~11 (filename, size) trials and _render_player_card_wide_short calls twice directly, each one reading the TTF from disk via ImageFont.truetype(). With player cards rotating every 8s and a ~1 FPS display loop, that's ~100 TTF reads per dwell cycle just for one player card. Added a module-level _FONT_SIZE_CACHE dict keyed by (filename, size) storing the loaded ImageFont (or None for failures so repeated misses don't re-hit disk either). Verified with a counting wrapper around ImageFont.truetype: 100 hot-cache iterations of the full font trial list = 0 disk reads after the first pass. 3) Non-ASCII player names rendered as missing-glyph boxes Live 2026 Masters has 7 non-ASCII players: Højgaard (x2), García, Olazábal, Åberg, Välimäki, Cabrera. PressStart2P handles most of these correctly, but the 4x6-font (which _fit_name falls back to on narrow wide-short displays) has no Latin Extended glyphs — "Højgaard" rendered as "H[box]jgaard". Added ascii_safe() in masters_helpers.py that applies an explicit single-codepoint map for characters that don't decompose via NFKD (ø→o, æ→ae, ß→ss, ł→l, ð→d, þ→th, Ø/Æ/... etc), then NFKD-normalizes the rest and strips combining marks. "Højgaard" → "Hojgaard", "José María Olazábal" → "Jose Maria Olazabal". All 10 transliteration test cases pass. format_player_name now routes through ascii_safe so every renderer that goes through it is automatically covered. The two bypass sites that took raw ESPN names — _fit_name (for the biggest-font-that-fits candidates) and the three MULTIPLE_WINNERS.get() lookups — now explicitly call ascii_safe. Bonus bug fixed: MULTIPLE_WINNERS dict key is "Jose Maria Olazabal" (ASCII) but ESPN returns "José María Olazábal" so the lookup missed and his 2 green jackets never showed. Now renders "x2 GREEN JACKETS" on his player card correctly. Verified live against the 2026 Masters on devpi (192x48): all 7 non-ASCII players render cleanly with transliterated names, Olazábal's green jacket count shows up, schedule pairings like "Hojgaard, V. Singh, McCa..." display correctly. test_plugin_standalone.py: 45/45 still passing. Bumps manifest 2.2.3 -> 2.2.4. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins.json | 2 +- plugins/masters-tournament/manifest.json | 7 ++- plugins/masters-tournament/masters_helpers.py | 55 ++++++++++++++++++- .../masters-tournament/masters_renderer.py | 39 +++++++++++-- .../masters_renderer_enhanced.py | 3 +- 5 files changed, 96 insertions(+), 10 deletions(-) diff --git a/plugins.json b/plugins.json index 5650984..0743846 100644 --- a/plugins.json +++ b/plugins.json @@ -702,7 +702,7 @@ "last_updated": "2026-04-09", "verified": true, "screenshot": "", - "latest_version": "2.2.3" + "latest_version": "2.2.4" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 67d27aa..c7beb40 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.2.3", + "version": "2.2.4", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,11 @@ "height": 64 }, "versions": [ + { + "version": "2.2.4", + "released": "2026-04-09", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.2.3", "released": "2026-04-09", diff --git a/plugins/masters-tournament/masters_helpers.py b/plugins/masters-tournament/masters_helpers.py index fc06ab9..95e5382 100644 --- a/plugins/masters-tournament/masters_helpers.py +++ b/plugins/masters-tournament/masters_helpers.py @@ -253,8 +253,61 @@ # HELPER FUNCTIONS # ═══════════════════════════════════════════════════════════════ +import unicodedata + +# Characters that don't decompose via NFKD (single-codepoint letters with no +# base+combining form). Extend here if new player nationalities show up. +_ASCII_FALLBACK = { + "ø": "o", "Ø": "O", + "æ": "ae", "Æ": "AE", + "œ": "oe", "Œ": "OE", + "ß": "ss", + "ð": "d", "Ð": "D", + "þ": "th", "Þ": "Th", + "ł": "l", "Ł": "L", + "đ": "d", "Đ": "D", + "ħ": "h", "Ħ": "H", + "ı": "i", "İ": "I", + "ŋ": "n", "Ŋ": "N", + "\u2013": "-", "\u2014": "-", # en-dash, em-dash + "\u2018": "'", "\u2019": "'", # smart single quotes + "\u201C": '"', "\u201D": '"', # smart double quotes +} + + +def ascii_safe(text: str) -> str: + """Transliterate a string to plain ASCII for our bitmap fonts. + + Our rendering fonts (PressStart2P and especially 4x6-font) don't ship + with Latin Extended glyphs, so player names like "Højgaard", "Åberg", + "José María", "Välimäki" either render missing-glyph boxes or drop + characters entirely. Normalize NFKD to split combining accents, strip + the combiners, then apply an explicit map for single-codepoint letters + that don't decompose (ø, æ, ß, ł, ...). Everything else is passed + through if it's already ASCII, and non-ASCII leftovers are dropped. + """ + if not text or text.isascii(): + return text + # Explicit multi-codepoint-safe replacements first (ø -> o, æ -> ae, etc). + # str.maketrans requires single-char keys, but our map has "ae"/"AE" + # values that are multi-char, so iterate explicitly. + out_chars: List[str] = [] + for ch in text: + if ch in _ASCII_FALLBACK: + out_chars.append(_ASCII_FALLBACK[ch]) + else: + out_chars.append(ch) + text = "".join(out_chars) + # Decompose combining accents (é -> e + ́) then strip the combiners. + normalized = unicodedata.normalize("NFKD", text) + result = "".join(ch for ch in normalized if not unicodedata.combining(ch)) + # Drop any remaining non-ASCII. + return result.encode("ascii", "ignore").decode("ascii") + + def format_player_name(name: str, max_length: int = 15) -> str: - """Format player name to fit within character limit.""" + """Format player name to fit within character limit (ASCII-safe).""" + name = ascii_safe(name) if len(name) <= max_length: return name diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index e4d3acc..d9bc572 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -27,6 +27,7 @@ MULTIPLE_WINNERS, PAST_CHAMPIONS, TOURNAMENT_RECORDS, + ascii_safe, format_player_name, format_score_to_par, get_fun_fact_by_index, @@ -113,16 +114,32 @@ def _load_font(name: str) -> ImageFont.ImageFont: return ImageFont.load_default() +# Cache for _load_font_sized. Key: (filename, size). Value: font or None. +# Keeping None in the cache too so repeated failures don't re-hit the disk. +_FONT_SIZE_CACHE: Dict[Tuple[str, int], Optional[ImageFont.ImageFont]] = {} + + def _load_font_sized(filename: str, size: int) -> Optional[ImageFont.ImageFont]: - """Load a specific TTF at an arbitrary point size, returning None on failure.""" + """Load a specific TTF at an arbitrary point size, with memoization. + + Callers like _fit_name() try ~11 (filename, size) combinations per player + card render; without caching each call re-opens and re-parses the TTF. + """ + cache_key = (filename, size) + if cache_key in _FONT_SIZE_CACHE: + return _FONT_SIZE_CACHE[cache_key] path = _find_font_path(filename) if not path: + _FONT_SIZE_CACHE[cache_key] = None return None try: - return ImageFont.truetype(path, size) + font = ImageFont.truetype(path, size) except Exception as e: logger.warning(f"Failed to load font {path}@{size}: {e}") + _FONT_SIZE_CACHE[cache_key] = None return None + _FONT_SIZE_CACHE[cache_key] = font + return font class MastersRenderer: @@ -161,7 +178,7 @@ def __init__( self._configure_tier() self._load_fonts() - self._flag_cache: Dict[str, Image.Image] = {} + self._flag_cache: Dict[str, Optional[Image.Image]] = {} def _configure_tier(self): """Configure display parameters by size tier with generous spacing. @@ -315,7 +332,13 @@ def _get_flag(self, country_code: str) -> Optional[Image.Image]: flag.thumbnail((fw, fh), Image.Resampling.NEAREST) self._flag_cache[country_code] = flag return flag - except Exception: + except Exception as e: + self.logger.warning( + f"Failed to load flag {country_code} from {flag_path}: {e}", + exc_info=True, + ) + # Cache the failure too so we don't re-hit the broken file on every render. + self._flag_cache[country_code] = None return None def _score_color(self, score, position=None) -> Tuple[int, int, int]: @@ -569,7 +592,7 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: y_text += 9 # Green jacket count at bottom (only if there's still vertical room) - jacket_count = MULTIPLE_WINNERS.get(raw_name, 0) + jacket_count = MULTIPLE_WINNERS.get(ascii_safe(raw_name), 0) if jacket_count > 0 and self.tier != "tiny": jy = self.height - 10 if jy > y_text + 2: @@ -595,7 +618,11 @@ def _fit_name(self, draw, raw_name: str, max_width: int, 3. Last name only ("Scheffler") Only falls back to mid-word truncation if literally nothing fits. Returns (font, display_string, rendered_height). + + Input is transliterated to ASCII so accented characters (Åberg, + Højgaard, José María) don't render as missing-glyph boxes. """ + raw_name = ascii_safe(raw_name) parts = raw_name.split() full = raw_name.strip() or "?" last = parts[-1] if parts else full @@ -761,7 +788,7 @@ def _render_player_card_wide_short(self, img, draw, player, raw_name): ty += detail_h + 1 # Green jacket strip along the bottom if there's room - jacket_count = MULTIPLE_WINNERS.get(raw_name, 0) + jacket_count = MULTIPLE_WINNERS.get(ascii_safe(raw_name), 0) if jacket_count > 0: jy = bottom_bound - detail_h if jy > ty + 1: diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index e1a838a..9f40488 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -20,6 +20,7 @@ AUGUSTA_PAR, MULTIPLE_WINNERS, PAST_CHAMPIONS, + ascii_safe, format_player_name, format_score_to_par, get_hole_info, @@ -191,7 +192,7 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: rx += lw + self._text_width(draw, str(r), self.font_detail) + 6 # Green jacket count at bottom - jacket_count = MULTIPLE_WINNERS.get(player.get("player", ""), 0) + jacket_count = MULTIPLE_WINNERS.get(ascii_safe(player.get("player", "")), 0) if jacket_count > 0 and self.tier != "tiny": jy = self.height - 10 jacket = self.logo_loader.get_green_jacket_icon(size=8)