From 269d898c82fd828ef2839c9a3d343eed5615eeff Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:06:19 -0500 Subject: [PATCH] car_cluster: an electric car's cluster at 800x480, drawn to the neon dial look The old cluster was a 1024x512 bank of four analog gauges with menu rails and eight screens. This one is the two-dial EV layout: speed and motor power as neon rings, odometer and miles per kWh under them, the clock, date and outside temperature along the top, turn indicators, a lit lane to the horizon with the car's marker, and P R N D S along the bottom. The car drives itself round a 90 second loop; the arrow keys hold a turn signal and Enter pauses the drive. Each ring is drawn once on a canvas (a colour sweep of short arc segments, a tick ring, a hairline) with three cheap moving parts over it: a cover arc the colour of the empty track, a blurred glow arc, and a bright cap on the leading edge. The hood lines are line pieces on strip canvases, since an arc several screens wide crashes the renderer. No image assets, so it runs in the wasm gallery, on CPython and on MicroPython unchanged. Manifests, the gallery card and thumbnail, the screenshots page and the resolution test follow the new files. --- .site/gallery/index.html | 2 +- .site/gallery/python-files.json | 21 +- .site/pyscript/index.html | 2 +- .site/pyscript/thumbnails/car_cluster.png | Bin 8608 -> 12704 bytes docs/screenshots/README.md | 1 + docs/screenshots/car_cluster.png | Bin 0 -> 41559 bytes lib/examples/car_cluster/car_cluster.py | 129 +-- lib/examples/car_cluster/chrome.py | 231 ------ lib/examples/car_cluster/cluster.py | 303 +++++++ lib/examples/car_cluster/focus_nav.py | 171 ---- lib/examples/car_cluster/gauges.py | 743 ------------------ lib/examples/car_cluster/input_map.py | 169 ---- lib/examples/car_cluster/key_probe.py | 88 --- lib/examples/car_cluster/layout.py | 188 ----- lib/examples/car_cluster/lv_util.py | 36 - lib/examples/car_cluster/rails.py | 166 ---- lib/examples/car_cluster/ring.py | 312 ++++++++ lib/examples/car_cluster/screens/__init__.py | 33 - lib/examples/car_cluster/screens/_common.py | 283 ------- lib/examples/car_cluster/screens/assist.py | 127 --- lib/examples/car_cluster/screens/engine.py | 86 -- lib/examples/car_cluster/screens/lights.py | 100 --- lib/examples/car_cluster/screens/speed.py | 164 ---- .../car_cluster/screens/theme_screen.py | 150 ---- lib/examples/car_cluster/screens/tires.py | 161 ---- lib/examples/car_cluster/screens/trip_a.py | 84 -- lib/examples/car_cluster/screens/trip_b.py | 13 - lib/examples/car_cluster/theme.py | 320 -------- lib/examples/car_cluster/vehicle.py | 320 +++----- packages/car_cluster.json | 76 +- packages/examples.json | 76 +- pydevices-examples.toml | 21 +- tests/test_gallery_frame.py | 4 +- 33 files changed, 814 insertions(+), 3766 deletions(-) create mode 100644 docs/screenshots/car_cluster.png delete mode 100644 lib/examples/car_cluster/chrome.py create mode 100644 lib/examples/car_cluster/cluster.py delete mode 100644 lib/examples/car_cluster/focus_nav.py delete mode 100644 lib/examples/car_cluster/gauges.py delete mode 100644 lib/examples/car_cluster/input_map.py delete mode 100644 lib/examples/car_cluster/key_probe.py delete mode 100644 lib/examples/car_cluster/layout.py delete mode 100644 lib/examples/car_cluster/lv_util.py delete mode 100644 lib/examples/car_cluster/rails.py create mode 100644 lib/examples/car_cluster/ring.py delete mode 100644 lib/examples/car_cluster/screens/__init__.py delete mode 100644 lib/examples/car_cluster/screens/_common.py delete mode 100644 lib/examples/car_cluster/screens/assist.py delete mode 100644 lib/examples/car_cluster/screens/engine.py delete mode 100644 lib/examples/car_cluster/screens/lights.py delete mode 100644 lib/examples/car_cluster/screens/speed.py delete mode 100644 lib/examples/car_cluster/screens/theme_screen.py delete mode 100644 lib/examples/car_cluster/screens/tires.py delete mode 100644 lib/examples/car_cluster/screens/trip_a.py delete mode 100644 lib/examples/car_cluster/screens/trip_b.py delete mode 100644 lib/examples/car_cluster/theme.py diff --git a/.site/gallery/index.html b/.site/gallery/index.html index 3de879518..611f6cda1 100644 --- a/.site/gallery/index.html +++ b/.site/gallery/index.html @@ -130,7 +130,7 @@

Widgets Clinic Queue

Car Cluster

-

Flagship LVGL automobile instrument cluster.

+

An electric car's instrument cluster, 800x480, in LVGL.

diff --git a/.site/gallery/python-files.json b/.site/gallery/python-files.json index cab149052..d5817faeb 100644 --- a/.site/gallery/python-files.json +++ b/.site/gallery/python-files.json @@ -16,25 +16,8 @@ "calc_widgets.py", "car_cluster/__init__.py", "car_cluster/car_cluster.py", - "car_cluster/chrome.py", - "car_cluster/focus_nav.py", - "car_cluster/gauges.py", - "car_cluster/input_map.py", - "car_cluster/key_probe.py", - "car_cluster/layout.py", - "car_cluster/lv_util.py", - "car_cluster/rails.py", - "car_cluster/screens/__init__.py", - "car_cluster/screens/_common.py", - "car_cluster/screens/assist.py", - "car_cluster/screens/engine.py", - "car_cluster/screens/lights.py", - "car_cluster/screens/speed.py", - "car_cluster/screens/theme_screen.py", - "car_cluster/screens/tires.py", - "car_cluster/screens/trip_a.py", - "car_cluster/screens/trip_b.py", - "car_cluster/theme.py", + "car_cluster/cluster.py", + "car_cluster/ring.py", "car_cluster/vehicle.py", "chango/__init__.py", "chango/chango.py", diff --git a/.site/pyscript/index.html b/.site/pyscript/index.html index cf5b8c2c5..c848d547e 100644 --- a/.site/pyscript/index.html +++ b/.site/pyscript/index.html @@ -130,7 +130,7 @@

Widgets Clinic Queue

Car Cluster

-

Flagship LVGL automobile instrument cluster.

+

An electric car's instrument cluster, 800x480, in LVGL.

diff --git a/.site/pyscript/thumbnails/car_cluster.png b/.site/pyscript/thumbnails/car_cluster.png index 3577441f84e9a41c9305b5619bebd29204a82f9a..4192d7085f1bdacc6707e987de7787abe187f3a8 100644 GIT binary patch literal 12704 zcmbWeWmua}^eqY$cQ5Xg;$9$lai>VIBEbvAt+;z}F9eDeFTvfdXmAguxV!7g?|(j= z`|aF&zr0VL$?SRe%>43kfqtA$G;(?htU1_38F{WBAZ6C~pbBun zzkWkO8bX(bh=V1aI1&^QPN!%k)%Ovy+RrLW~b$M;LG?D>^U@OGB_}oyUEKO zb(to1{~|ab38s3O`tNFPeR|lsRY3fpQwa;V%^k67^StXZtCT; zYNW`$snEZpzT!K-m%g;WvRr|khqr#%oFp{v!Mu>LJ*GmJbX8|x1mO(wDE*Q4#6i>pc` zgf4vRe%QzbIS!NbLRVlT@MJJ}mctyp(YNJ%jI|1(>kA3ICwA1$A|tKG5FqGQQ>f3h zWp{orOcaFZ!a}%TZtD!vUw7YlLL^$c8bsh>PG&);0D6NILOO0(0}~Q7&dJibMHSJ6 z!_RQDc=`yXB&YJ&UAq7DkF5t?h^<2I9x$j8o~pCR(x*SK3$I#bhPRj$3dak}1jU|H zDU+q!sN}0Oi*Glk<)PxE2vpWGJ}F+I$!W-@*5C2=DRUa+9QV86rt9WL~?i436=)d%O0^EY}wTBSH+6&=ua?MBsNduXECPSj^iPsI< zE#A)uMd%VvLdtt~^bkDGSftHc&nmk?IA)d?TCV=emBA0Vr&4{%!;LCYVo3kbNao={ zlg2M1{J+HP{|WI@gMMi^4v_y};Xs`EAl^z8SUCFgO@#==kg-C%7Qk2`^iHZP^=c4k zGZqr2Nx%M!QpBSvMErUY#MTM5%#Ukl-5b2>zb3HM?d=@ZO{(QSop1XW!&u zME8XeLP`b^6kx0U7llj#%qkTfEv#X-5)Y1~YN8YliW~YY@{Lg_BaZ6G9<0kK1W6i2 zp#XleEs`56GOXA{)_6hvw?q}UY?@g~j)-G#!NciOYgP*sw$-O_{uIGRIK#k11-8Ud z#bH9;6$;IKkvsvHQ@&}|wx=t+y&_HK%~nBS}+BR!Hlov?^>xlAHPneD5b(#7TXK$Vrh zOH~y6Coo9K^EE>GjnULaD;bk*g2ha^oxWoe7oAi2s<&1G2abwZ`&9ZM=%OO0SRQjO z9ku!J=)A;QKOi$)JbAMiGh$oPqMjB?&WKp>YR>J8Y#AtzyEA`d=`Dsgw6(M(-duH{a@d|E&wXS68T_Q(xO>9H) zB~ILPtWHI&9;bRJes+}=p%);5;t^5m1J zXZ{;6Caim2J#-oWben(%{+8&c#9lSk7CGe$rO_Gtlg!TnvU|Z+ajAEJDx*`!nZ7M1 zdJatz{h<$8bk&!SAaQ0Ebj1JVQh{kGvXt%d+Me=iDVgo`Vsz7K!<^{sA-T5Q{6%qM zv14g3&IWJ!8QcBicG_%KVO$r+v=U;;nHug`D*)M5Wbl1#_cTTNLjIyuLJ@4awc7Z? z{^9Id`fu4jAsOku#sy}?YyXhw?sJ1!c!1FlS5(5V%g-=rGyS5pYW2bD6Vyl=Fm`U4 zLALVmp-;wuuPI!(r1QhwUEPpUQk)^`5#9|5a!j$G&nW6<ZFPv>8hoDfQcJ|BE{NEHjez7xBYni%j2 z2OXo#B9>V5r@D~&F=Y3E`JHhrZ4PeGI)abA`)u8hH~BA*?{)H_6RR|RJ~84t9!Me? zE9}@NeLH{}QEeU$K}fC;cH7~R>_HJtpk^x4fL-sK-(@L3U>+334!+ikHIxUl5{CU_ zGZgM!5*$Wo&U5mfHkQ7GV)`6Nib0^Bf3l@S^GnayjgLeA?$|fdpA!`)CSlFSlVr^v zUN;7N{U=aGmKt?M-?6u;9(+C?qAwrhStfYW)Rj+6C$sFiMcvM%hg0l0e3puA4b<4? zqC<|<(pVj**zA&itgwfP{W(Y=64j{`@Up%-Cc&o;HJZLqbs4?+<<+x^^7JulgKFT= z@@!|~M6Jh>Zy_3%?Sc2ZHIf%VepDe1c&-Pq+AvsGvws5GAeaCxXy)IWnsOKJFivW$ zW$le&*nG%Uvf}DA`|+k$+{#9$1E&TL@+Xb4NTgO&$$gqznZ+H!U&bFS+;`-z^#QA* zZcSs{4_~Ay)}N{Y%bU+MM*{`$_KJLN6lqOOs%eBa(7@QbETz|vZL(#V7ce|s^^{sj z1!O-NI&cgba+L9iS^fG*C#)2qZS|v8$5G0O_ei!peJhCy($wcM&7P$Gvx)2%(T`Ti zOr!u|p_~xBUBmd586J>^0+wf*fKT?Jymq%XQhWi@}4|7V@ zG6qo33>g?7Fe0>j!N|eUM1a!p71)0qjb;b<^1T`N&qTT}bJ|a|+h;)j+xDrdTDk%} zbU;m{?qu|1L=6v^_!t<>L6kAZQ3*^It{n(;!S}Q)0|Xh?D+roiCOcgFkS0^S4oLq> z;qyT72>Lo&O|;Bftp4F9&Jfg=YP*@gVt~17&%8!zuz!$N7HaKd z>V#C`FtA_b&XvBZYsXt*!TrLKA#-1opwTN9n^wV=p^ri+A%W`?RYc_Vo}t~5WWzv} zMOUTrBgT^79{uvT>!GYpdhc#oS;}2_`*e0HlD`)LPe)cUE_zo`A`_c_m}SOl(!be2 z%;IXUsj4fN_|S+@>mNas3mBA5+cU#vueC>x#dKq%ZJdHPfvZKTZ%6sVbQwNy{|^1m zN3E3|{r-6llSxo8S&p(`0`wP(bWDa+MIHa`}9sohiPBqBR3sQ z4nQ(uE$MM~ud|(^{h8`!L7|%H!=yl^Rij1;H#;0U=jd7pj&sjGHeKkgc5PD_>foauKL9Ne@RG3)dUmhD6&-g?io>fRV-DQ zdn6MGZW0u|SuKBDs5&s%chs8~(+yD-eN)Td7;&PuHEXQtjm?;c9!MR(m_uFlD4;!3 zBcALuyY7hcO4`b}T2jPp$n=uz_-pccW-3y2piWU#*IztXUEb37&n0Zlc?Q1sUY6DKl9ptzmV}wC8yy^!O^iRD-#(tna7`-1M;`S zG(Mc5wuI53YfYAoCdG2?_lFB#l^k`C;u)WQg~-NsgFb&vRDmu_8Xs6@hN>?z6D;I@ zm>Z6&Dj~Rb%ifdqV0UpQIOSFJi`+^-O%#wFKY||i4I16oPlz2JQu_xc4OTG(;QC=+ zFuq2+xY;fkyOp{X3&)0#f=y|WU@_P>_a~{uCvRx<68KJVKzQkP6`y#a5C7J@UDE!u zjK3ad@!k)^8yePOsdJ)f?Lz=hdUgVa6X9EPRg*ya!ic;&65yY13r#vBdN&$sD6$9D zs2B}k|7t;GmqfDsJl+tvA?aSn90zLA5FXKE@kOM>b=(%~`lHGsb6d8BvKJ;k#@L=j zbYS_wAjJZEiB^kM_tnzES7=`a66VfsQmU6f9Q>NCe!dFMib3kRj?9oQyU>-GtG&`f< zl}yFhYpOOwL?&)CPRjN(J|IIA=3F#=g_c8wD_}SkKCS7Ly&q6vHaP)_7*Iinmk75$o%-!bf|Fc&=|Ngr3~kt_%g|Gn;9CuWAVf>tjb zWKzb5mbSUAJ%P~ctI(p9r?XLeOBvt7DGZToOouNPLbK72zgAq|(=XTgNONY1l~s=X ziGWWrxp#~mj9KP;F>D}ZFp}yJ4kf+{%k%m|i36Tz2eu z(|O@tn^f4fTRekw{K+hdf&GJEWxXDm#}tPP>K@Z5at?Vq4D9cQngM8mXNt0Y{*KSqAV_2s>fDajVZVQ^z9;Smro1OJKI&WS1tn|p zc}&tkSMcI70(dcwz#*+BMIRokbe6v6w46sax$i8hW#Ykr}tJr8789phWM;oXWG9eQt>8hfm%2{W0`kZgF7>rU$nDAme0=mUh%gh+JGf z+q(HAVR3Hp%sa{(SjhV-Q1P77Qe+F$LbO+jeZ7*|Zfn?VC1=~yUb|v%N8v}qm>;BS z>X5^TY3l=~r^377FfUO^<1w4jH!skRi*xCRbmrDHyUT zjge%V`=JBtLkwAu*~h3gPUdq%_lncbU)BkQN)6eyd_P*VkxC5HX;6_9085%I^+Z2Y zdU&eTFY2_&j|{(bUK` zEbh8|_a?#w8skuKYyk8vB92IlR#y((RYSe%$R)|MwEC6O&O+ z;8PbR-(yl3Q^3bviuc_hxkj(aoLFCa#LDmLewBE>_r77s00pyQh(=_W@XxO?tr+JF zV=76WZ2T-p`3MMQo>uCO2LZo0ab+fShj&V{47pv5ZJtDHWZ!qSKRj&4Rkl37(ww|_ zc|D$TJ{@KS1mgLOKUXPuXxz}4>ZZjT@#Q3Sg!{WE*$R;haXeh~1~k%%=4xRbFj;^V zId|%9U~A^>*{Uoe<`N-1N6l@R?ImhA%@atv-(LTU=g?ONbV@uryIdTzKKVYcjAvAv zT8iX$ijRsiEgm38I@-@LG&N*5fE+q|ivYu~15sA9!#vW-+qiy0{&$fei4c7A(c|3v zXz(qU*sVNWmn|NcqmNZKgGw!cXsy1V1wMb#@an5zUE zBeEBI45qJqVnI1%GKH@mx)M;Hldc0vw@T+*;KPa7*AFH9%@5m#wgeNuz0r1*YBvVa z9JPF)VjUx94N4x#fj+t>;6F;h9Q7kl1C`SqMs#AA98AuY6;7zPjp>W~KsX9!+tWFl zkF{`Nz>6>V5^9>eMQu{>OQlP@mdnhc({NS^!y}n{ka6$DSH*94QTAEdUigo%M(oTE zeE&niN=HvD)zE$hp)S}KHBEhY>;8svxpJe^^}C5SqsKeGHR|sa^XnOCd^#GB(GQ^~ z13e{GMEkBFE%4;L<;#<2J7sl>AMewpnZ8#OtV_~u@A1;Gf-<|hT(2x!{dXI=4zqgp zLYG|`CgBV8)sKKAW;J~A%16`v@cNzLpZf$Z@U53hXIp|$W#ypiJ;cY*q7Ai60z066 zb5buu^^KATQsf@dLi99UQWYAm)snGu>HQS7&G`lmj(wHkLmx_ARiG2NOOizq_@&bk z<@2-OIJ3v&DeNiS(dFtOC&bY$*8=t6$ zj~qW&U+$}+qhhal+;5J{kM~#Hc=vXWWKJv`wx&h%dG#K&R|Ar?1D#XShB~23qHZV8 zJ$8GR?{6{TdG!Pi0q(zhdVq>A4ktG1pRDelZr`?*tyE=Fk2}3~Y-5?c5NijH2g!$+ zfMOH`Q>P$4r76?lse&3BTxD0$!e_r9KbcU+IO|Lkm0hGpbeS*PdR=f`o~# zFnuv_Ldv1_!`~A6Mkt5Hbxc|A+jP_13t;R;Ra8(dR^G=;M6a!W@eh9)m!7Kee8Ms< zA!BB~Pgc4bYDMc;iVK()j$M0IlsT}%I!d>Mc$8;!QRrH*S`|o#Jht-!mXxB%a&Jmo znXF!9A3g=hpSG>s76*v_I-P=tfy9P-wh4XXWyi@IRa~2mX^jFbsJasq7eX*YEPjE2 zCDjGiv-S7Ctll?hGtaVo;Cffd8eJt8&i(%MGWD2lKV*~SxNZhHvD-Dbommq*p45pP zB~n$5z>m45NP+B@)*YN@PGk?D8*25w@~Nm|u&84UO28A>DeDx#JXnx*98Hci{Jru|3$ z$8Wf=zgd!znWe6ek6ZHN&E`KV)AT%D6lH2IIdE-A#kC{IMjWy`-dtkU`Xare8iju8 zEBO0)5sl@{2>>p2a#8>{JGIWwMn4(GoKMKXHS@cV`NMy2h;9@85;~1gJTT^b`vpi{ zmfyHAO0+3dCJK{t?q?NDMa(VuN-Be=@lY^Q1DahbCjz~YtJW}5cZcPc)k~@~BXID^ zna-@zCqPY&3YpyMrlT6hC;y30TVn@q@%6rFa+sek8*MK9Oo|ZxJvltW$VGDd zy3+6&oSzRHCO4h~Z}VOs{H`zOmfGr4oHV3=EztTq$#}Z~{SsNjY(0Ki?ji@_#C3IDoqIdpqjYf+-{o{- zl8d`LHsiHx4Y(f;CzJ2lCn;RAl3Dx2k~XcBpPBGe>m#xhXv*p8`RO*}el)!7jE~s7 z2ZiIARt}u1%o46urx+bYZORDEIeepGzI}Idw3DFG#4gucb}_oJQepagms5S65XOAjWauXSm8{&Y_9 z%@TgqQ19_^3W9v|$nPo^8n{5TxfZ%%AX8y^o0NfQOXRX8RDjA}lXPOPKxL8$MtM5q zjknRhnUC((1>q_C9b43_F1TF zQ$@kw*dvC3A^(#v$ctgYDt10d{{H)stT+R?ht_v>Y%>WYxu}&X8+9jf!4fY5J>q-1 zzcEA`z%3J=1=`A*@);?vK@Ya6<(Us?qQC4NpLsNno%Sc zyqPKD8YkAsLtyM81fxafh$|0thf#YZH>r=oXP=;|F!5LDs_hbaNg{sGHA%rt4iCk$ z>?J!g$`@nQOuxZ;wi%Nz2Ropn*r}Ru4_U#&1+2&jG52tNcna2{5-yi2dwwDIvm5Tp z+xN0@$tt&e$~<ST_MC=5KQ`-o)W_}UCy%qCH+QL*CovtwGJ8c?vMea=xQ$x^ z(|i4mXft5;GmeF>6Kt%$X0A8%Z5Si^->NiUAASy)PX2TtdMD`I9z~5T+gKPuUIFmF zSirol#v457Cxf#urjvVmQr+LCnE}5uK}CIhtuVmMUOIOI{3>?nFHIO=Sb?RUq>#u0 zo+DJNm3+b-V?dvAtP^J{_7uXAuaf;1xk~vo!gnlGB0kx4{s^{B%khZ26lgB{LyNWhO8I zH+uBuO)|$Nr54ZHfv#$7(wa|u??rr$O|B>>VTm4SP{+%u8n)Q@6d`A7x){0U>z;g2 zq*uFJOk7B$an1H?pj4o{Vceef6y3aRZChFsZDq>4O*5ETGaea7<4*z{uqqxQ~;q7u*o)Z6oCVYRz2bpl&8C zHnY^!M7AE;M$Mf9Yk3912n7!87rO%D0yXM%3GpHKgN7 zqy9G5i^tCK8g;51ePBj&dsx=S01gTDA6NJpsjVb_~AdAVx3M%46x6H3eM=U@b6UD$DU%_0htYrYiT8#Mf$7eqvvj# zAEBI=W!2KFYrMOLOF%8?beAXj?-YVUA?r|zD z{ft`%AtplgllFdYAEY~1Bu>h*vE%fn7w#CXDE!8@s z>|ik2rE+3`fog=jXX&7O6t0>B)YCk~(PSZQ6V|UC`V$6{K%#D3%+^6#jWHr|2Q&^@ zdi5SqgSjHT=O@gv9KyHPaW>3c#)7S~t#eBU-9?nv>4I$$Hok-@JfURKssHs|+mzNODy zdaP3Dc&o%kCQi_uVjQ%Q4BcO19a4^5bHo`F?Uk%+uLBVQU(i}eGG||MT-K3jJ&mD+ zjU;;pzixe9!Azd))98~J-$;cs&c`v}^twjT=G#2)RmWvG|jenvRUsy$Zjjb+HdKAarGOGI!neWs)g;+u(K8(NX^sU@7k&c5u<8 z5u^S!)4?XqPCB=NzpPok-7&2}T{-w3-8>18%$c^@=-Kp7VC?BcMlSP#0g1Rbn+Ll% ziQW9N2>h4i=)}5Y6Gz7d6kB2rVz2i0P7}}8ZqdHdqFEd$&fd-_p9@VJHQ@yy-k!JW zma0p9<`7qztt&4hYH%zrAzCh1tTNWDtE{cFGI%VUOOmqpUx0jWYJEMGP>piT9XK>_ z4ifn)Eqw8%SUuO{HuJVIBHgHA@bEs%ZJ-|V>`E|ohrM>^_*iG+nWtO5Y9F#DatN$a zs-!-65}|xL4GR#eF@z9<0xy1(VvpV*6B+5Qzyw#94=nH9I(Zgz+BT!q))65KtQVTB z;n=a{>4(56K+SE*5yzq%)ZwaTuRnev+IMB$H6U{Eu!#6Skq4J~D5dyE3#frr1c^jF zCnLW^?L4pHkhHEFvh3%V{^*&-hCX$SirN%C7wyZ%bN(kI&fexrpd!`+#y5pMQ>#9t zrV#9=8Rj83p$@J2zSWWYID<6RpEgspTrlN%sTl~uZUU455PaQiKxRM*9IeUY?uO*N zWjO~dk@G{5jJ~!wq5$?2*a}jl^|OVmECJ;8J16$1M|jbF zsqHfO{9KmK};Tr%=HevubrAd->E*!H8z}4p~QRKa+s;bD7wCA)Vq?NoVOv z9vi>(Rw0$(=y>&2_6WynO}@%vb=59pQL-py4Hi(Y)yt%MB>dH%;>kh!ZJ{f#uwo&O z+fuWcQ8!Fjj7BA~4s`mx-hK4Q=PR5FN9hIb$*N4yby=5Q<}8E`hl#+7!>KeG6z6oh z;X!Dp|CyzOuh`J&JUT68!=TR{tH*WZ$%uyCUo-jT~EYZ?Hm_qMafQIFc`G>H$>E7Pj!E{yZcsk>WAZiy zY38STOl}Di{=N-8j6R&$*BI6nS1e{qL4t4T8$hb*ydhuU3@D)|BBLRz>No;0+=ndDw zeT}W4JX|7M9nv0LrdADz26%JLuop{`!A|$QQgC7FN}=zL5#*&z#haU8cOObfQl3H; zy6LIE>i&zc3UnL6#(Kx*CZQ*8zYmNo$LZOCwfXALEkke_abj0iHS}r#^uF3Rt2!Fw z_G0(@0ErH*htMg&zb4hUg(+dw-RNAnH1cpcUB_BARF}F>Oa4QU&C==HiG{;qC}OV1 z>)#INiuEhD2g`xqc3y(9KZZ-&!rA_oX5k<<6>vMg5of3PV^FZF%v+geBW;Zwzxi~{ zY-WAN;avKbYiLHQX(K|et4jyx(2T-L|44)1l`4Q-Wome&ZZG@t#MCP|cfg%tm5rw( zW)7oSNgV*n&8&fAX5z-_jd%qg9G>ZPeGE>!>%f~f6#ievKjq<)o`bP?Z&kogFT=q{d}CEwd&qDNt&&Lj@{BcW#5WyI0M6`^ z+H-@Al7!qo1?@6dF|SBFrm08qK{&Yc&Rs739A3N+`l)eGINA!la{^=({~0v0^k9s* z;P$3ewmq6beMqz3cj!Z|4u;raFY7%cb*l&k*rO#xk8@vcp+;vM|9jA`Qdw7L9ZBlVjsED*3HBwaOzpQ+QN=}UYTVb2n#&P^8e2?(8n;-NZbVM4CSMd zr+{lo(S8@i{xVIz&Gl6zej`Ng6-A5d6h|25E?h*(mNoJJZZpP$URvf{*@c?S6I3E5 z%nDrFwlZ%nz5CTvdH{(08`m6;Mo1Fd`z^W+a1!>;NZOFNvBC9sv4-GO)yBU<(4~-ki^tAIh{SF`tU#SD z_8y&(pj6Hvb%fYEV6!J927p*dU}_+4{|34dT9^JMwU?T*2clltyQ89nDQ*#Nvlr&*7zKz_G4(sifw=8JIWUetBKK{?Xud7;m9E z4cZQ)Vx*}ZhzzeGRTh>}GPBdkw=vzgyw-bTS1?rmft}2N6RoyR;J?0zJB*7|#HOzG z0N|8b)oeu3+of`e?WXc^OnGwnT}#>f9Y!hD#EZ-(0p;adZ@GoW8AJJp^`vsRC{^T~ zd|mz|yAVNq8BkjxKGh1Bv1zZksz9Nj7f=4b|ADaD0dheF5o+9nn{$iK% z1yKiuLA&z;N+-DNYR2j;4sSR#CD9_e3)sp-tJoD}-zUtO?(SMQb!NUJ@>}=8@9gvH zxWgO?7Wt<$w17Rz#tz*DpA$@sI4J*Ivr8? ziQLlIUDqAj-PkYolfH%JPuB(XVxvymNdS7LH%RMOvVIpO3(wHavC10*k%B2~!cw8QoHH zKiQL#`-ng32XUz8YFAR>VBxSMd~%P(`zS+&pLNFgXAy*&Prq%CgLCN8bIX0Y>6U@m z7QIK0N|F8^isf)t+ff^3ZfU-4s}HZK)WR>2j4FbY^#AIPGZPS`@nwlF3Fy#6Y|SN* zkfrg}#uS3+&&3$$D#JaXoSVX}oOmFKEB*FB*HH_K$g^6B82$NE%x)*f-BZvL+AxEi|6KI5IB34 zDrtGP`5yB@C?pjzFd+?Mx8R%Iomva4q@RXIFyO| zW5v=TW!2VFD<6}!dR_Hhy5Q|!U8+U`#9afj2QFP2uQ)bAjA?`4L^y_U`g*m0f8}ke z?V|oNzDj3iv}d4rL;M+c)S9zg9Q2RXX7wt@JMo5LqFQ@iMz-(c#k)$X^x+Y0u3y5$ z%i2BgvfVi(j<2w!)$T6$U71KW5~B)}T!EDE@aTCuB!t$Llvy=_#q1smI-0gQwGKiiZmq|R6XmIVWw|I& zu*SK4$UN?LBlAo77yWhZ22REPD42V*!@k16G3tx%FDAoK^q|GmY+TvodjE7#vr&>Q z5kCKWAkh?_X4i@qMVf9C(QpoWU-GXYn`kHow#tuWF3H-1Mlt3oic|zM-ROA)DU%NX zeI`R>Rsz}bz#cSa4f9u{Y#l4KBT2y#JAH_-dr!YsHYHH4NM)Hlcov&9IB@**ouy3X zghPV2?%nT#lJ5~918QHHz72)W03CCewX_N=OZ`MH9M6*I<;Ytb5&b?Nrn7URolPk% zd_DJFo=C%I!7o$B`>XqOD>xij@)k^e$^XPhW@8zP;hMW5CS2|3Q{t~IGSR+V!s$}f z^TT2H1S^KaA(tHTgo70XjYcIcorlPM`;q<)C)=1AIm5D8K;JI*EW|M8@VlbY|8)Er zTzDQar>N`xcbe1X;cW>xILSqST~n+j)bUG!38$7P9L_}u3ayM9|Dl|9J>uGBP)yN@ zV0V+)DL|eLF6Jh>S0-~Pf;aAkJp$zWK9>JaQ&h?AIoh?LOd{=Oq+_@aHQY8E!Yrak pc9&|oH3io+|391*39o2;RY{sID_wl>e_kLc$*If!{%98RzW`Fj$SeQ= literal 8608 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 literal 0 HcmV?d00001 diff --git a/lib/examples/car_cluster/car_cluster.py b/lib/examples/car_cluster/car_cluster.py index 576f1af64..2868a828b 100644 --- a/lib/examples/car_cluster/car_cluster.py +++ b/lib/examples/car_cluster/car_cluster.py @@ -4,18 +4,23 @@ """ car_cluster ==================================================== -Flagship LVGL automobile instrument cluster. +An electric car's instrument cluster, 800x480, in LVGL. -Landscape 1024×512 cluster with Material accents, nested chrome, four side -gauges, dual steering-wheel style menu rails, and eight live center screens. +Two neon dials, speed in mph on the left and motor power in kW on the right, +with the odometer and the miles per kWh under them; the clock, the date and +the outside temperature along the top; the turn indicators; a lit lane +running to the horizon with the car's marker at its foot; and P R N D S +along the bottom. -Controls: - Left/Right — switch focus group (left rail / center / right rail) - Up/Down — previous/next control within the active group - Enter — activate focused control +The car drives itself round a 90 second loop (see ``vehicle.py``). Keys: -Vehicle speed, gear, RPM, and oil pressure follow an automatic drive cycle -(speed ≈ 10× gear). Fuel and coolant stay fixed. + Left / Right hold that turn indicator on; press again to cancel + Enter pause and resume the drive + +The dials are art drawn once on a canvas (a colour sweep made of many short +arc segments, a tick ring, a hairline) with three cheap moving parts over it +(see ``ring.py``). Everything is sized from the screen, so it also fits a +smaller landscape display, though it is drawn for 800x480. """ import sys @@ -24,7 +29,7 @@ _file = __file__.replace("\\", "/") _PKG = _file.rsplit("/", 1)[0] _parts = _file.split("/") -# src/ is the parent of examples/ (package lives in examples/car_cluster/). +# lib/ is the parent of examples/ (the package lives in examples/car_cluster/). if "examples" in _parts: _idx = _parts.index("examples") _src = "/".join(_parts[:_idx]) if _idx else "." @@ -37,31 +42,25 @@ if _PKG not in sys.path: sys.path.insert(0, _PKG) -import input_map # noqa: E402 — capture hook must run before display_driver - -input_map.capture_virtual_devices() +from boarddev import env_bool, env_get, env_set # noqa: E402 -from boarddev import env_bool, env_get, env_set - -env_set("PYDEVICES_WIDTH", "1024") -env_set("PYDEVICES_HEIGHT", "512") +env_set("PYDEVICES_WIDTH", "800") +env_set("PYDEVICES_HEIGHT", "480") if env_get("PYDEVICES_SCALE") is None: env_set("PYDEVICES_SCALE", "1") import display_driver # noqa: E402 — wires LVGL into the shared app import lvgl as lv # noqa: E402 -from board_config import display_drv # noqa: E402 -from display_driver import app # noqa: E402 +from display_driver import app # noqa: E402, F401 -import focus_nav # noqa: E402 -import input_map # noqa: E402 -import layout # noqa: E402 +from cluster import Cluster # noqa: E402 from vehicle import Vehicle # noqa: E402 _ui = None _vehicle = None _last_ms = None _timer = None +_keys = None def _now_ms(): @@ -75,50 +74,82 @@ def _dt_s(now): global _last_ms if _last_ms is None: _last_ms = now - return 0.05 + return 0.04 try: dt = time.ticks_diff(now, _last_ms) / 1000.0 except AttributeError: dt = (now - _last_ms) / 1000.0 _last_ms = now if dt < 0: - dt = 0.05 + dt = 0.04 if dt > 0.25: dt = 0.25 return dt -# Design / interactive freeze: set CAR_CLUSTER_FREEZE=1 (or truthy) to hold -# gauges/screens at static values so layout can be tweaked from the REPL. +def _clock_text(): + try: + t = time.localtime() + hour = t[3] % 12 or 12 + return "%d:%02d %s" % (hour, t[4], "PM" if t[3] >= 12 else "AM") + except Exception: + return "9:59 PM" + + +# Set CAR_CLUSTER_FREEZE=1 to hold the dials still for layout work at the REPL. _FREEZE = env_bool("CAR_CLUSTER_FREEZE", False) def _on_tick(timer): - global _ui, _vehicle - if _ui is None or _vehicle is None: - return - if _FREEZE: + if _ui is None or _vehicle is None or _FREEZE: return - dt = _dt_s(_now_ms()) - _vehicle.tick(dt) + _vehicle.tick(_dt_s(_now_ms())) + _ui.set_clock(_clock_text()) _ui.update() def freeze(static=True): - """Hold or resume vehicle/gauge animation (interactive design helper).""" + """Hold or resume the drive (a design helper for the REPL).""" global _FREEZE _FREEZE = bool(static) if _FREEZE and _ui is not None and _vehicle is not None: - # Snap once to a readable static scene. - _vehicle.rpm = 3200 - _vehicle.speed_mph = 48 - _vehicle.fuel_frac = 0.62 - _vehicle.coolant_f = 195 - _vehicle.oil_psi = 42 + _vehicle.speed_mph = 51 + _vehicle.power_kw = 138 + _vehicle.gear = "N" _ui.update() return _FREEZE +def _on_key(e): + key = e.get_key() + if key == lv.KEY.LEFT: + _vehicle.toggle_signal("left") + elif key == lv.KEY.RIGHT: + _vehicle.toggle_signal("right") + elif key == lv.KEY.ENTER: + _vehicle.running = not _vehicle.running + + +def _listen_for_keys(scr): + """A focused, invisible object in the default group receives the keypad.""" + global _keys + group = lv.group_get_default() + if group is None: + group = lv.group_create() + group.set_default() + _keys = lv.obj(scr) + _keys.set_size(1, 1) + _keys.set_pos(0, 0) + _keys.set_style_bg_opa(lv.OPA.TRANSP, 0) + _keys.set_style_border_width(0, 0) + _keys.set_style_outline_width(0, lv.STATE.FOCUSED) + _keys.set_style_outline_width(0, lv.STATE.FOCUS_KEY) + _keys.remove_flag(lv.obj.FLAG.SCROLLABLE) + group.add_obj(_keys) + lv.group_focus_obj(_keys) + _keys.add_event_cb(_on_key, lv.EVENT.KEY, None) + + def build_ui(): global _ui, _vehicle, _timer, _last_ms @@ -127,24 +158,12 @@ def build_ui(): inst.disable() try: _vehicle = Vehicle() - nav = focus_nav.FocusNav() - - bridge = input_map.InputBridge(app, _vehicle, focus_nav=nav) - bridge.install() - - _ui = layout.ClusterUI(_vehicle, focus_nav=nav) - # Groups are populated by layout; start focus on the left rail. - nav.set_active(focus_nav.FocusNav.LEFT) + scr = lv.screen_active() + _ui = Cluster(_vehicle, scr) + _ui.set_clock(_clock_text()) + _listen_for_keys(scr) _last_ms = _now_ms() _timer = lv.timer_create(_on_tick, 40, None) - - # Apply deferred rail→tabview changes outside LVGL task_handler. - def _drain_rails(_t): - if _ui is not None and _ui.rails is not None: - _ui.rails.drain_pending() - - app.on_tick(_drain_rails, period=30) - _ui.rails.drain_pending() if _FREEZE: freeze(True) finally: diff --git a/lib/examples/car_cluster/chrome.py b/lib/examples/car_cluster/chrome.py deleted file mode 100644 index 3bf7b662c..000000000 --- a/lib/examples/car_cluster/chrome.py +++ /dev/null @@ -1,231 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Nested 3D chrome bezels and focus styles for the cluster shell.""" - -import lvgl as lv - -import theme - -# Bezel shells registered for shininess / scheme restyle. -_chrome_shells = [] - - -def _circle_radius(): - return getattr(lv, "RADIUS_CIRCLE", 0x7FFF) - - -def _no_scroll(obj): - if hasattr(obj, "remove_flag"): - obj.remove_flag(lv.obj.FLAG.SCROLLABLE) - elif hasattr(obj, "clear_flag"): - obj.clear_flag(lv.obj.FLAG.SCROLLABLE) - - -def style_root(obj): - theme.style_bg(obj, theme.bg(), radius=0) - - -def style_panel(obj, radius=8): - theme.style_bg(obj, theme.panel(), radius=radius, border_w=1, border_color=theme.chrome_mid()) - - -def make_focus_style(): - """Accent ring for STATE.FOCUSED (rail / menu controls).""" - style = lv.style_t() - style.init() - style.set_outline_width(2) - style.set_outline_pad(2) - style.set_outline_color(theme.accent()) - style.set_outline_opa(lv.OPA.COVER) - style.set_border_width(2) - style.set_border_color(theme.accent_lite()) - style.set_border_opa(lv.OPA.COVER) - return theme.retain_style(style) - - -def _add_specular_strip(parent, radius, pad=2): - """Thin bright top edge to sell polished metal.""" - strip = lv.obj(parent) - _no_scroll(strip) - strip.set_size(lv.pct(100), max(3, pad + 1)) - strip.align(lv.ALIGN.TOP_MID, 0, 0) - theme.style_chrome( - strip, - radius=max(2, radius - 2), - border_w=0, - highlight=True, - vertical=True, - ) - try: - strip.set_style_bg_opa(lv.OPA._70, 0) - except Exception: - strip.set_style_bg_opa(180, 0) - return strip - - -def make_bezel(parent, x, y, w, h, depth=3, radius=10, pad=3): - """Stacked frames for a plastic/chrome edge. Returns (outer, content).""" - outer = lv.obj(parent) - outer.set_pos(x, y) - outer.set_size(w, h) - _no_scroll(outer) - # Outer shell: specular→dark vertical metal. - theme.style_chrome( - outer, - radius=radius, - border_w=2, - border_color=theme.chrome_hi(), - highlight=True, - vertical=True, - ) - _add_specular_strip(outer, radius, pad=pad) - - cur = outer - inner_r = radius - metal_layers = [] - for i in range(depth): - child = lv.obj(cur) - _no_scroll(child) - cur.set_style_pad_all(pad if i == 0 else max(1, pad - 1), 0) - child.set_size(lv.pct(100), lv.pct(100)) - child.align(lv.ALIGN.CENTER, 0, 0) - inner_r = max(2, inner_r - 2) - if i == depth - 1: - theme.style_bg( - child, theme.face(), radius=inner_r, border_w=1, border_color=theme.chrome_lo() - ) - elif i % 2 == 0: - # Bright band - theme.style_chrome( - child, - radius=inner_r, - border_w=1, - border_color=theme.chrome_mid(), - highlight=(i == 0), - vertical=True, - ) - metal_layers.append(child) - else: - # Dark recess with horizontal sheen for contrast. - theme.style_chrome( - child, - radius=inner_r, - border_w=1, - border_color=theme.chrome_specular(), - highlight=False, - vertical=False, - ) - metal_layers.append(child) - cur = child - - content = cur - # No content padding — callers position with set_pos against the full inner area. - content.set_style_pad_all(0, 0) - _chrome_shells.append({"kind": "bezel", "outer": outer, "layers": metal_layers}) - return outer, content - - -def make_center_bezel(parent, x, y, w, h): - """Thicker, shinier bezel for the center instrument column.""" - return make_bezel(parent, x, y, w, h, depth=4, radius=14, pad=4) - - -def make_gauge_ring(parent, size): - """Circular chrome ring + dark face for a gauge. Returns (ring, face).""" - rad = _circle_radius() - ring = lv.obj(parent) - ring.set_size(size, size) - _no_scroll(ring) - # Outer metal: bright top → dark bottom. - theme.style_chrome( - ring, - radius=rad, - border_w=2, - border_color=theme.chrome_specular(), - highlight=True, - vertical=True, - ) - - # Mid ring with opposite sheen (dark→bright) for a machined edge. - mid_inset = max(3, size // 36) - mid = lv.obj(ring) - mid.set_size(size - 2 * mid_inset, size - 2 * mid_inset) - mid.center() - _no_scroll(mid) - theme.style_chrome( - mid, - radius=rad, - border_w=1, - border_color=theme.chrome_hi(), - highlight=False, - vertical=True, - ) - # Flip mid gradient visually by swapping ends via local props after style. - mid.set_style_bg_color(theme.chrome_lo(), 0) - mid.set_style_bg_grad_color(theme.chrome_specular(), 0) - - inset = max(5, size // 22) - face = lv.obj(ring) - face.set_size(size - 2 * inset, size - 2 * inset) - face.center() - _no_scroll(face) - theme.style_bg(face, theme.face(), radius=rad, border_w=1, border_color=theme.chrome_lo()) - _chrome_shells.append({"kind": "gauge", "ring": ring, "mid": mid, "face": face}) - return ring, face - - -def apply_theme(): - """Refresh chrome gradients after shininess / brightness / scheme changes.""" - for entry in _chrome_shells: - kind = entry.get("kind") - if kind == "bezel": - outer = entry.get("outer") - if outer is not None: - theme.apply_chrome_local(outer, highlight=True, border_color=theme.chrome_hi()) - for i, layer in enumerate(entry.get("layers") or ()): - theme.apply_chrome_local( - layer, - highlight=(i == 0), - vertical=(i % 2 == 0), - border_color=theme.chrome_mid() if i % 2 == 0 else theme.chrome_specular(), - ) - elif kind == "gauge": - ring = entry.get("ring") - mid = entry.get("mid") - face = entry.get("face") - if ring is not None: - theme.apply_chrome_local(ring, highlight=True, border_color=theme.chrome_specular()) - if mid is not None: - mid.set_style_bg_color(theme.chrome_lo(), 0) - mid.set_style_bg_grad_color(theme.chrome_specular(), 0) - mid.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) - mid.set_style_border_color(theme.chrome_hi(), 0) - if face is not None: - face.set_style_bg_color(theme.face(), 0) - face.set_style_border_color(theme.chrome_lo(), 0) - - -def style_rail_button(btn, selected=False, *, initial=False): - """Update rail button colors without stacking new styles. - - Calling ``theme.style_bg`` / ``add_style`` on every FOCUSED select accumulates - style nodes and hard-locks LVGL under arrow-key storms (nesting=1). Mutate - local style properties instead; use ``initial=True`` once at construction. - """ - bg = theme.panel_raised() if not selected else theme.accent_dim() - border = theme.accent() if selected else theme.chrome_mid() - if initial: - theme.style_bg(btn, bg, radius=8, border_w=1, border_color=border) - focus = make_focus_style() - btn.add_style(focus, lv.STATE.FOCUSED) - btn.set_style_bg_color(bg, 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_border_width(1, 0) - btn.set_style_border_color(border, 0) - btn.set_style_radius(8, 0) - btn.set_style_pad_all(2, 0) - btn.set_style_pad_row(2, 0) - btn.set_style_outline_width(2, lv.STATE.FOCUSED) - btn.set_style_outline_pad(2, lv.STATE.FOCUSED) - btn.set_style_outline_color(theme.accent(), lv.STATE.FOCUSED) - btn.set_style_outline_opa(lv.OPA.COVER, lv.STATE.FOCUSED) - btn.set_style_border_color(theme.accent_lite(), lv.STATE.FOCUSED) diff --git a/lib/examples/car_cluster/cluster.py b/lib/examples/car_cluster/cluster.py new file mode 100644 index 000000000..21e600c8f --- /dev/null +++ b/lib/examples/car_cluster/cluster.py @@ -0,0 +1,303 @@ +# SPDX-License-Identifier: MIT +"""The cluster's screen: status line, indicators, two dials, the road, PRNDS.""" + +import lvgl as lv + +from ring import Ring, _color, plain, scaled_font + +_CIRCLE = getattr(lv, "RADIUS_CIRCLE", 0x7FFF) + +BG = 0x05080F +TRACK = 0x0E1E33 +CYAN = 0x3DF0FF +GREEN = 0x3FE878 +DIM = 0x5E6E8C +TEXT = 0xE6ECF7 + +SPEED_STOPS = ((0.0, 0x35F2C8), (0.45, 0x3DE0FF), (1.0, 0x2E7BFF)) +POWER_STOPS = ((0.0, 0x3DE0FF), (0.5, 0x4E8DFF), (1.0, 0xA05CFF)) + +GEARS = ("P", "R", "N", "D", "S") + + +def _hood(parent, w, y, h, curves): + """Faint lines curving like a dash hood, on a strip canvas ``w`` by ``h``. + + Each curve is ``(crest_y, drop, opa, width)``: highest at the centre, and + ``drop`` pixels lower at the edges. Drawn as short straight pieces, which + is far cheaper than an arc the size of several screens. + """ + strip = lv.canvas(parent) + strip.set_size(w, h) + strip.set_pos(0, y) + plain(strip) + buf = lv.draw_buf_create(w, h, lv.COLOR_FORMAT.ARGB8888, 0) + strip.set_draw_buf(buf) + strip.fill_bg(_color(0x000000), lv.OPA.TRANSP) + layer = lv.layer_t() + strip.init_layer(layer) + line = lv.draw_line_dsc_t() + line.init() + line.color = _color(CYAN) + pieces = 32 + half = w / 2.0 + for crest, drop, opa, width in curves: + line.opa = opa + line.width = width + prev = None + for i in range(pieces + 1): + x = w * i / pieces + cy = crest - y + drop * ((x - half) / half) ** 2 + if prev is not None: + line.p1 = lv.point_precise_t({"x": prev[0], "y": prev[1]}) + line.p2 = lv.point_precise_t({"x": x, "y": cy}) + lv.draw_line(layer, line) + prev = (x, cy) + strip.finish_layer(layer) + return strip, buf + + +class Road: + """The centre: a lit lane that narrows to the horizon, and the car's marker.""" + + def __init__(self, parent, x, y, w, h): + self.obj = lv.obj(parent) + self.obj.set_pos(x, y) + self.obj.set_size(w, h) + plain(self.obj) + + # Lane edges, converging upward, drawn once on a small canvas. + self.canvas = lv.canvas(self.obj) + self.canvas.set_size(w, h) + self.canvas.center() + plain(self.canvas) + self._buf = lv.draw_buf_create(w, h, lv.COLOR_FORMAT.ARGB8888, 0) + self.canvas.set_draw_buf(self._buf) + self.canvas.fill_bg(_color(0x000000), lv.OPA.TRANSP) + layer = lv.layer_t() + self.canvas.init_layer(layer) + line = lv.draw_line_dsc_t() + line.init() + line.width = 2 + line.round_start = True + line.round_end = True + top = h // 6 + vx = w // 2 + for sx, ex in ((w * 5 // 100, vx - w // 9), (w * 95 // 100, vx + w // 9)): + # Each edge is a few straight pieces that fade toward the horizon. + pieces = 5 + for i in range(pieces): + t0 = i / pieces + t1 = (i + 1) / pieces + line.color = _color(CYAN) + line.opa = int(30 + 110 * (1 - t0)) + line.p1 = lv.point_precise_t({"x": sx + (ex - sx) * t0, "y": h - (h - top) * t0}) + line.p2 = lv.point_precise_t({"x": sx + (ex - sx) * t1, "y": h - (h - top) * t1}) + lv.draw_line(layer, line) + self.canvas.finish_layer(layer) + + # The beam: a soft column of light from the marker up the lane. + beam_h = h * 58 // 100 + beam_w = max(10, w // 9) + self.beam = lv.obj(self.obj) + self.beam.set_size(beam_w, beam_h) + self.beam.align(lv.ALIGN.TOP_MID, 0, h * 18 // 100) + plain(self.beam) + self.beam.set_style_radius(beam_w // 2, 0) + self.beam.set_style_bg_color(_color(CYAN), 0) + self.beam.set_style_bg_grad_color(_color(CYAN), 0) + self.beam.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) + self.beam.set_style_bg_opa(0, 0) + self.beam.set_style_bg_grad_opa(200, 0) + if hasattr(self.beam, "set_style_blur_radius"): + self.beam.set_style_blur_radius(max(3, beam_w // 3), 0) + + core_w = max(3, beam_w // 4) + self.core = lv.obj(self.obj) + self.core.set_size(core_w, beam_h) + self.core.align(lv.ALIGN.TOP_MID, 0, h * 18 // 100) + plain(self.core) + self.core.set_style_radius(core_w // 2, 0) + self.core.set_style_bg_color(_color(0xDDFBFF), 0) + self.core.set_style_bg_grad_color(_color(0xDDFBFF), 0) + self.core.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) + self.core.set_style_bg_opa(0, 0) + self.core.set_style_bg_grad_opa(255, 0) + + # The marker: a glowing ring with the car's green dot in it. + my = h * 18 // 100 + beam_h + ring = max(24, w // 3) + self.halo = lv.obj(self.obj) + self.halo.set_size(min(w - 4, ring * 5 // 2), ring * 2 // 5) + self.halo.align(lv.ALIGN.TOP_MID, 0, my + ring * 3 // 5) + plain(self.halo) + self.halo.set_style_radius(_CIRCLE, 0) + self.halo.set_style_bg_color(_color(CYAN), 0) + self.halo.set_style_bg_opa(90, 0) + if hasattr(self.halo, "set_style_blur_radius"): + self.halo.set_style_blur_radius(ring // 3, 0) + + self.ring = lv.obj(self.obj) + self.ring.set_size(ring, ring) + self.ring.align(lv.ALIGN.TOP_MID, 0, my) + plain(self.ring) + self.ring.set_style_radius(_CIRCLE, 0) + self.ring.set_style_border_width(max(2, ring // 14), 0) + self.ring.set_style_border_color(_color(CYAN), 0) + self.ring.set_style_border_opa(230, 0) + self.ring.set_style_shadow_color(_color(CYAN), 0) + self.ring.set_style_shadow_width(ring, 0) + self.ring.set_style_shadow_opa(160, 0) + + dot = max(8, ring // 3) + self.dot = lv.obj(self.ring) + self.dot.set_size(dot, dot) + self.dot.center() + plain(self.dot) + self.dot.set_style_radius(_CIRCLE, 0) + self.dot.set_style_bg_color(_color(GREEN), 0) + self.dot.set_style_bg_opa(lv.OPA.COVER, 0) + self.dot.set_style_shadow_color(_color(GREEN), 0) + self.dot.set_style_shadow_width(dot, 0) + self.dot.set_style_shadow_opa(200, 0) + + +class Cluster: + def __init__(self, vehicle, scr=None): + self.vehicle = vehicle + scr = scr or lv.screen_active() + self.scr = scr + W = scr.get_width() + H = scr.get_height() + self.W, self.H = W, H + self._blink = None + self._gear = None + self._clock = None + + scr.set_style_bg_color(_color(BG), 0) + scr.set_style_bg_opa(lv.OPA.COVER, 0) + scr.set_style_pad_all(0, 0) + scr.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # A lift of blue at the top of the dash, fading to black at the feet. + wash = lv.obj(scr) + wash.set_size(W, H) + wash.set_pos(0, 0) + plain(wash) + wash.set_style_radius(0, 0) + wash.set_style_bg_color(_color(0x0C1736), 0) + wash.set_style_bg_grad_color(_color(BG), 0) + wash.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) + wash.set_style_bg_opa(lv.OPA.COVER, 0) + wash.set_style_bg_main_stop(0, 0) + wash.set_style_bg_grad_stop(170, 0) + + # The dash's own curves above and below the dials. + drop = H * 9 // 100 + self._hoods = ( + _hood(scr, W, H * 15 // 100, drop + H * 8 // 100, ((H * 17 // 100, drop, 46, 2), (H * 21 // 100, drop, 22, 1))), + _hood(scr, W, H * 82 // 100, drop + H * 4 // 100, ((H * 84 // 100, drop, 36, 2),)), + ) + + # Status line: date, clock, outside temperature. + self.date = self._text(scr, "9/30", lv.font_montserrat_16, DIM) + self.clock = self._text(scr, "9:59 PM", lv.font_montserrat_24, TEXT) + self.temp = self._text(scr, "54~61\u00b0F", lv.font_montserrat_16, DIM) + self.clock.align(lv.ALIGN.TOP_MID, 0, H * 5 // 100) + self.date.align_to(self.clock, lv.ALIGN.OUT_LEFT_MID, -W * 5 // 100, 0) + self.temp.align_to(self.clock, lv.ALIGN.OUT_RIGHT_MID, W * 5 // 100, 0) + + # Turn indicators. + self.left = self._text(scr, "<<<", lv.font_montserrat_24, CYAN) + self.right = self._text(scr, ">>>", lv.font_montserrat_24, CYAN) + self.left.align(lv.ALIGN.TOP_MID, -W * 13 // 100, H * 18 // 100) + self.right.align(lv.ALIGN.TOP_MID, W * 13 // 100, H * 18 // 100) + for lamp in (self.left, self.right): + lamp.set_style_text_letter_space(-3, 0) + + # The dials. + size = min(H * 62 // 100, W * 34 // 100) + cy = H * 50 // 100 + self.speed = Ring( + scr, + size, + SPEED_STOPS, + 120, + track=TRACK, + bg=BG, + unit="mph", + caption="13488 mi", + ) + self.speed.set_pos(W * 27 // 100 - size // 2, cy - size // 2) + self.power = Ring( + scr, + size, + POWER_STOPS, + 200, + track=TRACK, + bg=BG, + unit="kW", + caption="3.7 mi/kWh", + ) + self.power.set_pos(W * 73 // 100 - size // 2, cy - size // 2) + + # The road between them. + road_w = W * 18 // 100 + road_h = size * 92 // 100 + self.road = Road(scr, W // 2 - road_w // 2, cy - road_h // 2 - H // 50, road_w, road_h) + + # P R N D S along the bottom. + self.gears = {} + self.gear_box = lv.obj(scr) + box = max(22, H // 15) + self.gear_box.set_size(box, box) + plain(self.gear_box) + self.gear_box.set_style_radius(max(3, box // 7), 0) + self.gear_box.set_style_border_width(2, 0) + self.gear_box.set_style_border_color(_color(GREEN), 0) + self.gear_box.set_style_border_opa(200, 0) + self.gear_box.set_style_shadow_color(_color(GREEN), 0) + self.gear_box.set_style_shadow_width(box // 2, 0) + self.gear_box.set_style_shadow_opa(90, 0) + pitch = max(30, W * 55 // 1000) + for i, g in enumerate(GEARS): + lbl = self._text(scr, g, lv.font_montserrat_24, DIM) + lbl.set_style_text_letter_space(0, 0) + lbl.align(lv.ALIGN.BOTTOM_MID, (i - 2) * pitch, -H * 4 // 100) + self.gears[g] = lbl + + self.update() + + def _text(self, parent, text, font, color): + lbl = lv.label(parent) + lbl.set_text(text) + lbl.set_style_text_font(font, 0) + lbl.set_style_text_color(_color(color), 0) + lbl.set_style_text_letter_space(1, 0) + return lbl + + def set_clock(self, text): + if text != self._clock: + self._clock = text + self.clock.set_text(text) + + def update(self): + v = self.vehicle + self.speed.set_value(v.speed_mph) + self.power.set_value(abs(v.power_kw)) + self.speed.set_caption("%d mi" % int(v.odometer_mi)) + self.power.set_caption("%.1f mi/kWh" % v.mi_per_kwh) + self.power.unit.set_text("kW regen" if v.power_kw < -0.5 else "kW") + + on = v.blink_on() + state = (v.left and on, v.right and on) + if state != self._blink: + self._blink = state + self.left.set_style_text_opa(255 if state[0] else 70, 0) + self.right.set_style_text_opa(255 if state[1] else 70, 0) + + if v.gear != self._gear: + self._gear = v.gear + for g, lbl in self.gears.items(): + lbl.set_style_text_color(_color(GREEN if g == v.gear else DIM), 0) + self.gear_box.align_to(self.gears[v.gear], lv.ALIGN.CENTER, 0, 0) diff --git a/lib/examples/car_cluster/focus_nav.py b/lib/examples/car_cluster/focus_nav.py deleted file mode 100644 index 8041171bc..000000000 --- a/lib/examples/car_cluster/focus_nav.py +++ /dev/null @@ -1,171 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Multi-group focus navigation for left rail / center / right rail.""" - -import lvgl as lv - - -def _set_wrap(group, en=True): - try: - group.set_wrap(en) - except Exception: - pass - - -def _focus_first(group): - if group is None: - return - try: - n = group.get_obj_count() - except Exception: - n = 0 - if n <= 0: - return - try: - obj = group.get_obj_by_index(0) - if obj is not None: - lv.group_focus_obj(obj) - return - except Exception: - pass - try: - group.focus_next() - except Exception: - try: - lv.group_focus_next(group) - except Exception: - pass - - -class FocusNav: - """Three LVGL groups: left rail, center content, right rail. - - LEFT/RIGHT — switch active group (and point keypad indev at it) - UP/DOWN — previous/next within the active group - ENTER — activate focused control (via KEY_ENTER to the indev path) - """ - - LEFT = 0 - CENTER = 1 - RIGHT = 2 - - def __init__(self): - self.left = lv.group_create() - self.center = lv.group_create() - self.right = lv.group_create() - for g in (self.left, self.center, self.right): - _set_wrap(g, True) - self._groups = (self.left, self.center, self.right) - self._active = self.CENTER - self._keypad_indev = None - - # Keep default group empty so display_driver doesn't dump everything here. - default = lv.group_get_default() - if default is None: - default = self.center - default.set_default() - self._default = default - - def bind_keypad_indev(self, indev): - self._keypad_indev = indev - self._apply_indev_group() - - def active_group(self): - return self._groups[self._active] - - def _apply_indev_group(self): - g = self.active_group() - if self._keypad_indev is not None: - try: - self._keypad_indev.set_group(g) - except Exception: - pass - try: - g.set_default() - except Exception: - pass - - def set_active(self, index): - index = int(index) % 3 - if index == self._active: - self._apply_indev_group() - return - prev = self.active_group() - self._defocus_group(prev) - self._active = index - self._apply_indev_group() - g = self.active_group() - focused = None - try: - focused = g.get_focused() - except Exception: - try: - focused = lv.group_get_focused(g) - except Exception: - focused = None - if focused is None: - _focus_first(g) - else: - try: - lv.group_focus_obj(focused) - except Exception: - pass - - def focus_next(self): - g = self.active_group() - try: - g.focus_next() - except Exception: - try: - lv.group_focus_next(g) - except Exception: - pass - - def focus_prev(self): - g = self.active_group() - try: - g.focus_prev() - except Exception: - try: - lv.group_focus_prev(g) - except Exception: - pass - - def _defocus_group(self, group): - focused = None - try: - focused = group.get_focused() - except Exception: - try: - focused = lv.group_get_focused(group) - except Exception: - focused = None - if focused is None: - return - try: - focused.remove_state(lv.STATE.FOCUSED) - except Exception: - pass - try: - focused.remove_state(lv.STATE.FOCUS_KEY) - except Exception: - pass - - def handle_nav_key(self, lv_key): - """Handle remapped lv.KEY value. Return True if consumed. - - UP/DOWN — previous/next member in the active group - LEFT/RIGHT — switch active group (left / center / right) - """ - if lv_key == lv.KEY.UP: - self.focus_prev() - return True - if lv_key == lv.KEY.DOWN: - self.focus_next() - return True - if lv_key == lv.KEY.LEFT: - self.set_active(self._active - 1) - return True - if lv_key == lv.KEY.RIGHT: - self.set_active(self._active + 1) - return True - return False diff --git a/lib/examples/car_cluster/gauges.py b/lib/examples/car_cluster/gauges.py deleted file mode 100644 index bbfb120fb..000000000 --- a/lib/examples/car_cluster/gauges.py +++ /dev/null @@ -1,743 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Coherent analog / digital gauges for the instrument cluster.""" - -import lvgl as lv - -import chrome -import lv_util -import theme - -_styles = [] - - -def _retain(style): - _styles.append(style) - theme.retain_style(style) - return style - - -def _no_scroll(obj): - if hasattr(obj, "remove_flag"): - obj.remove_flag(lv.obj.FLAG.SCROLLABLE) - elif hasattr(obj, "clear_flag"): - obj.clear_flag(lv.obj.FLAG.SCROLLABLE) - - -def _overflow_visible(obj): - if hasattr(obj, "add_flag"): - try: - obj.add_flag(lv.obj.FLAG.OVERFLOW_VISIBLE) - except Exception: - pass - - -def _style_scale(scale_obj, *, tick_w=2, minor_len=10, major_len=16): - # Background arc under ticks - st = lv.style_t() - st.init() - st.set_arc_color(theme.chrome_lo()) - st.set_arc_width(4) - st.set_arc_opa(lv.OPA.COVER) - scale_obj.add_style(st, lv.PART.MAIN) - _retain(st) - - # Major ticks (LV_PART_INDICATOR) - st_maj = lv.style_t() - st_maj.init() - st_maj.set_line_color(theme.tick()) - st_maj.set_line_opa(lv.OPA.COVER) - st_maj.set_line_width(tick_w + 1) - st_maj.set_line_rounded(True) - st_maj.set_length(major_len) - st_maj.set_text_color(theme.text_dim()) - scale_obj.add_style(st_maj, lv.PART.INDICATOR) - _retain(st_maj) - - # Minor ticks (LV_PART_ITEMS) - st_min = lv.style_t() - st_min.init() - st_min.set_line_color(theme.chrome_mid()) - st_min.set_line_opa(lv.OPA.COVER) - st_min.set_line_width(tick_w) - st_min.set_line_rounded(True) - st_min.set_length(minor_len) - scale_obj.add_style(st_min, lv.PART.ITEMS) - _retain(st_min) - - # Local styles as a belt-and-suspenders (some ports read these first). - try: - scale_obj.set_style_length(major_len, lv.PART.INDICATOR) - scale_obj.set_style_line_width(tick_w + 1, lv.PART.INDICATOR) - scale_obj.set_style_line_color(theme.tick(), lv.PART.INDICATOR) - scale_obj.set_style_line_opa(lv.OPA.COVER, lv.PART.INDICATOR) - scale_obj.set_style_length(minor_len, lv.PART.ITEMS) - scale_obj.set_style_line_width(tick_w, lv.PART.ITEMS) - scale_obj.set_style_line_color(theme.chrome_mid(), lv.PART.ITEMS) - scale_obj.set_style_line_opa(lv.OPA.COVER, lv.PART.ITEMS) - except Exception: - pass - - -def _add_zone_section(scale_obj, vmin, vmax, color_fn): - sec = scale_obj.add_section() - scale_obj.set_section_range(sec, int(vmin), int(vmax)) - st = lv.style_t() - st.init() - c = color_fn() - st.set_arc_color(c) - st.set_arc_width(8) - st.set_line_color(c) - for setter in ( - lambda: scale_obj.section_set_style(sec, lv.PART.INDICATOR, st), - lambda: scale_obj.set_section_style_indicator(sec, st), - lambda: scale_obj.section_set_style(sec, lv.PART.ITEMS, st), - lambda: scale_obj.set_section_style_items(sec, st), - ): - try: - setter() - break - except Exception: - continue - _retain(st) - return sec - - -def _hub(parent, size): - rad = getattr(lv, "RADIUS_CIRCLE", 0x7FFF) - hub = lv.obj(parent) - hub.set_size(size, size) - hub.center() - _no_scroll(hub) - theme.style_bg(hub, theme.chrome_hi(), radius=rad, border_w=2, border_color=theme.accent_dim()) - inner = lv.obj(hub) - inner.set_size(max(6, size // 2), max(6, size // 2)) - inner.center() - _no_scroll(inner) - theme.style_bg(inner, theme.accent(), radius=rad, border_w=1, border_color=theme.accent_lite()) - dot = lv.obj(hub) - dot.set_size(max(3, size // 6), max(3, size // 6)) - dot.center() - _no_scroll(dot) - theme.style_bg(dot, theme.secondary(), radius=rad) - return hub - - -def _cap_label(parent, text, align, x_ofs=0, y_ofs=0): - lbl = lv.label(parent) - lbl.set_text(text) - lbl.set_style_text_color(theme.text_dim(), 0) - theme.apply_font(lbl, theme.pick_font(max(100, parent.get_width() // 4), parent)) - lbl.align(align, x_ofs, y_ofs) - return lbl - - -def _scale_2x(lbl): - w = max(1, lbl.get_width()) - h = max(1, lbl.get_height()) - lbl.set_style_transform_pivot_x(w // 2, 0) - lbl.set_style_transform_pivot_y(h // 2, 0) - try: - lbl.set_style_transform_scale(512, 0) - except Exception: - lbl.set_style_transform_scale_x(512, 0) - lbl.set_style_transform_scale_y(512, 0) - - -def _cap_mid(g, *, x_extra=0, y=0, bottom=False): - """Mid-align min/max caps (design session); optional inset / vertical offset.""" - if g is None: - return - x_ofs = max(8, g.size // 10) + x_extra - align_l = lv.ALIGN.BOTTOM_LEFT if bottom else lv.ALIGN.LEFT_MID - align_r = lv.ALIGN.BOTTOM_RIGHT if bottom else lv.ALIGN.RIGHT_MID - y_ofs = -g.size // 6 if bottom else y - for cap, align, x in ( - (getattr(g, "cap_min_lbl", None), align_l, x_ofs), - (getattr(g, "cap_max_lbl", None), align_r, -x_ofs), - ): - if cap is None: - continue - cap.set_style_translate_x(0, 0) - cap.set_style_translate_y(0, 0) - try: - cap.set_style_transform_scale(256, 0) - except Exception: - cap.set_style_transform_scale_x(256, 0) - cap.set_style_transform_scale_y(256, 0) - cap.align(align, x, y_ofs) - _scale_2x(cap) - - -class AnalogGauge: - def __init__( - self, - parent, - size, - vmin, - vmax, - *, - angle_range=270, - rotation=135, - ticks=41, - major_every=5, - label="", - unit="", - redline=None, - cap_min="", - cap_max="", - needle_len=None, - arc_mode=True, - bg_angles=None, - value_fmt=None, - show_value=True, - design_layout=None, - ): - self.vmin = vmin - self.vmax = vmax - self.value = vmin - self.unit = unit - self.label = label - self.size = size - self._value_fmt = value_fmt - self._show_value = show_value - self._design_layout = design_layout - self._needle_len = needle_len if needle_len is not None else int(size * 0.42) - - # Size from the constructor arg — get_width() is often 0 before layout. - ring_inset = max(4, size // 28) - face_sz = size - 2 * ring_inset - - self.ring, self.face = chrome.make_gauge_ring(parent, size) - self.face.set_style_pad_all(0, 0) - _overflow_visible(self.ring) - _overflow_visible(self.face) - for obj in (self.ring, self.face): - try: - obj.set_style_clip_corner(False, 0) - except Exception: - pass - - # Accent ring drawn as a non-clipping child (ticks live on the face). - inset = max(4, size // 28) - inner_sz = max(40, face_sz - 2 * inset) - self.inner_ring = lv.obj(self.face) - self.inner_ring.set_size(inner_sz, inner_sz) - self.inner_ring.center() - self.inner_ring.set_style_pad_all(0, 0) - _no_scroll(self.inner_ring) - _overflow_visible(self.inner_ring) - try: - self.inner_ring.set_style_clip_corner(False, 0) - except Exception: - pass - theme.style_bg( - self.inner_ring, - theme.face(), - radius=getattr(lv, "RADIUS_CIRCLE", 0x7FFF), - border_w=1, - border_color=theme.accent_dim(), - ) - self.inner_ring.set_style_pad_all(0, 0) - # Keep accent ring under the scale so it cannot cover ticks. - try: - self.inner_ring.move_background() - except Exception: - pass - - font_lg = theme.pick_font(size, self.face) - font_sm = theme.pick_font(max(160, size // 2), self.face) - - # Sweep arc UNDER the scale — a later sibling paints over ticks/needles (H-J). - # Named sweep_arc: binding corrupts attribute name ``arc`` on some ports. - self.sweep_arc = None - if arc_mode: - arc_sz = max(40, face_sz - max(22, size // 7)) - self.sweep_arc = lv.arc(self.face) - self.sweep_arc.set_size(arc_sz, arc_sz) - self.sweep_arc.center() - self.sweep_arc.set_style_pad_all(0, 0) - try: - self.sweep_arc.set_style_bg_opa(lv.OPA.TRANSP, 0) - except Exception: - pass - self.sweep_arc.set_range(int(vmin), int(vmax)) - if bg_angles is not None: - self.sweep_arc.set_bg_angles(bg_angles[0], bg_angles[1]) - else: - self.sweep_arc.set_bg_angles(rotation, (rotation + angle_range) % 360) - self.sweep_arc.set_mode(lv.arc.MODE.NORMAL) - st_a = lv.style_t() - st_a.init() - st_a.set_arc_color(theme.chrome_lo()) - st_a.set_arc_width(8) - self.sweep_arc.add_style(st_a, lv.PART.MAIN) - _retain(st_a) - st_i = lv.style_t() - st_i.init() - st_i.set_arc_color(theme.accent()) - st_i.set_arc_width(8) - self.sweep_arc.add_style(st_i, lv.PART.INDICATOR) - _retain(st_i) - self._arc_ind_style = st_i - st_k = lv.style_t() - st_k.init() - st_k.set_bg_opa(lv.OPA.TRANSP) - st_k.set_pad_all(0) - st_k.set_width(0) - st_k.set_height(0) - self.sweep_arc.add_style(st_k, lv.PART.KNOB) - _retain(st_k) - lv_util.hide_clickable(self.sweep_arc) - else: - self._arc_ind_style = None - - self.scale = lv.scale(self.face) - self.scale.set_size(face_sz, face_sz) - self.scale.center() - self.scale.set_style_pad_all(0, 0) - try: - self.scale.set_style_bg_opa(lv.OPA.TRANSP, 0) - except Exception: - pass - _overflow_visible(self.scale) - try: - self.scale.set_style_clip_corner(False, 0) - except Exception: - pass - # INNER: ticks onto the dark face (OUTER sat on chrome / under the arc). - self.scale.set_mode(lv.scale.MODE.ROUND_INNER) - self.scale.set_range(int(vmin), int(vmax)) - self.scale.set_angle_range(int(angle_range)) - self.scale.set_rotation(int(rotation)) - self.scale.set_total_tick_count(int(ticks)) - self.scale.set_major_tick_every(int(major_every)) - self.scale.set_label_show(False) - maj = max(10, size // 16) - mn = max(6, size // 26) - _style_scale(self.scale, tick_w=2, minor_len=mn, major_len=maj) - - if redline is not None: - _add_zone_section(self.scale, redline, vmax, theme.danger) - elif vmax > vmin * 0.7: - warn_at = vmin + (vmax - vmin) * 0.78 - _add_zone_section(self.scale, warn_at, vmax, theme.warn) - - self.needle_shadow = lv.line(self.scale) - st_ns = lv.style_t() - st_ns.init() - st_ns.set_line_color(theme.chrome_lo()) - st_ns.set_line_width(5) - st_ns.set_line_rounded(True) - self.needle_shadow.add_style(st_ns, 0) - _retain(st_ns) - - self.needle = lv.line(self.scale) - st_n = lv.style_t() - st_n.init() - st_n.set_line_color(theme.needle()) - st_n.set_line_width(3) - st_n.set_line_rounded(True) - self.needle.add_style(st_n, 0) - _retain(st_n) - self._needle_style = st_n - - try: - self.scale.move_foreground() - except Exception: - pass - - self.hub = _hub(self.face, max(12, size // 10)) - try: - self.hub.move_foreground() - except Exception: - pass - - if label: - self.title_lbl = lv.label(self.face) - self.title_lbl.set_text(label) - self.title_lbl.set_style_text_color(theme.accent_lite(), 0) - theme.apply_font(self.title_lbl, font_sm) - self.title_lbl.set_style_pad_all(0, 0) - self.title_lbl.align(lv.ALIGN.TOP_MID, 0, max(2, size // 20)) - else: - self.title_lbl = None - - if show_value: - self.value_lbl = lv.label(self.face) - self.value_lbl.set_text("--") - self.value_lbl.set_style_text_color(theme.text(), 0) - theme.apply_font(self.value_lbl, font_lg) - self.value_lbl.set_style_pad_all(0, 0) - self.value_lbl.align(lv.ALIGN.BOTTOM_MID, 0, -max(4, size // 14)) - else: - self.value_lbl = None - - self.cap_min_lbl = ( - _cap_label(self.face, cap_min, lv.ALIGN.BOTTOM_LEFT, max(8, size // 10), -size // 6) - if cap_min - else None - ) - self.cap_max_lbl = ( - _cap_label(self.face, cap_max, lv.ALIGN.BOTTOM_RIGHT, -max(8, size // 10), -size // 6) - if cap_max - else None - ) - - # Labels stay above ticks; keep scale above the arc (hub already raised). - try: - self.scale.move_foreground() - if self.hub is not None: - self.hub.move_foreground() - if self.title_lbl is not None: - self.title_lbl.move_foreground() - if self.value_lbl is not None: - self.value_lbl.move_foreground() - except Exception: - pass - - self.set_value(vmin) - if design_layout: - self.apply_design_layout(design_layout) - - def apply_design_layout(self, kind=None): - """Bake interactive design-session label / cap placement into the gauge.""" - if kind is None: - kind = self._design_layout - if not kind: - return - self._design_layout = kind - - try: - lv.obj.update_layout(self.face) - except Exception: - pass - - for lbl in (self.title_lbl, self.value_lbl, self.cap_min_lbl, self.cap_max_lbl): - if lbl is not None: - _scale_2x(lbl) - - title = self.title_lbl - if title is not None: - h = max(1, title.get_height()) - half = h + h // 2 - if kind in ("rpm", "speed"): - ty = 2 * half - elif kind == "temp": - ty = half + half // 2 - else: # fuel, oil - ty = half - title.set_style_translate_x(0, 0) - title.set_style_translate_y(0, 0) - title.align(lv.ALIGN.CENTER, 0, ty) - _scale_2x(title) - - cap0 = self.cap_min_lbl - cap10 = self.cap_max_lbl - w0 = max(1, cap0.get_width()) if cap0 is not None else 0 - w10 = max(1, cap10.get_width()) if cap10 is not None else 0 - if kind == "rpm": - _cap_mid(self, x_extra=2 * w10 + w0, bottom=True) - elif kind == "speed": - th = max(1, title.get_height()) if title is not None else 16 - ty = 2 * (th + th // 2) - _cap_mid(self, x_extra=w0, y=ty) - elif kind == "oil": - _cap_mid(self, x_extra=w0) - else: - _cap_mid(self) - - if kind in ("oil", "temp", "speed") and self.value_lbl is not None: - h = max(1, self.value_lbl.get_height()) - y = -max(4, self.size // 14) - h // 2 - self.value_lbl.set_style_translate_x(0, 0) - self.value_lbl.set_style_translate_y(0, 0) - self.value_lbl.align(lv.ALIGN.BOTTOM_MID, 0, y) - _scale_2x(self.value_lbl) - - def _format_value(self, v): - if self._value_fmt is not None: - return self._value_fmt(v) - iv = int(round(v)) - if self.unit: - return "%d %s" % (iv, self.unit) - return str(iv) - - def set_value(self, v): - if v < self.vmin: - v = self.vmin - if v > self.vmax: - v = self.vmax - self.value = v - iv = int(round(v)) - for needle, length in ((self.needle_shadow, self._needle_len + 1), (self.needle, self._needle_len)): - try: - self.scale.set_line_needle_value(needle, length, iv) - except Exception: - pass - if self.sweep_arc is not None: - self.sweep_arc.set_value(iv) - if self.value_lbl is None: - return - self.value_lbl.set_text(self._format_value(v)) - if v >= self.vmax * 0.92: - self.value_lbl.set_style_text_color(theme.danger(), 0) - elif v >= self.vmax * 0.75: - self.value_lbl.set_style_text_color(theme.warn(), 0) - else: - self.value_lbl.set_style_text_color(theme.text(), 0) - if self._design_layout: - _scale_2x(self.value_lbl) - - def set_pos(self, x, y): - self.ring.set_pos(x, y) - - def widget(self): - return self.ring - - def apply_theme(self): - """Refresh accent-dependent colors after a scheme change.""" - self.ring.set_style_bg_color(theme.chrome_specular(), 0) - self.ring.set_style_bg_grad_color(theme.chrome_lo(), 0) - self.ring.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) - self.ring.set_style_border_color(theme.chrome_specular(), 0) - self.face.set_style_bg_color(theme.face(), 0) - self.face.set_style_border_color(theme.chrome_lo(), 0) - self.inner_ring.set_style_bg_color(theme.face(), 0) - self.inner_ring.set_style_border_color(theme.accent_dim(), 0) - self.scale.set_style_line_color(theme.tick(), lv.PART.INDICATOR) - self.scale.set_style_line_color(theme.chrome_mid(), lv.PART.ITEMS) - if self._arc_ind_style is not None: - self._arc_ind_style.set_arc_color(theme.accent()) - if getattr(self, "_needle_style", None) is not None: - self._needle_style.set_line_color(theme.needle()) - try: - self.hub.set_style_bg_color(theme.chrome_hi(), 0) - self.hub.set_style_border_color(theme.accent_dim(), 0) - self.hub.get_child(0).set_style_bg_color(theme.accent(), 0) - self.hub.get_child(0).set_style_border_color(theme.accent_lite(), 0) - self.hub.get_child(1).set_style_bg_color(theme.secondary(), 0) - except Exception: - pass - if self.title_lbl is not None: - self.title_lbl.set_style_text_color(theme.accent_lite(), 0) - for lbl in (self.cap_min_lbl, self.cap_max_lbl): - if lbl is not None: - lbl.set_style_text_color(theme.text_dim(), 0) - self.set_value(self.value) - if self._design_layout: - self.apply_design_layout(self._design_layout) - - -class DigitalSpeed: - def __init__(self, parent, w, h): - self.value = 0 - self._box_w = w - self._box_h = h - self._num_scale = 256 - self._num_y_ofs = -h // 12 - self.box = lv.obj(parent) - self.box.set_size(w, h) - _no_scroll(self.box) - theme.style_bg(self.box, theme.face(), radius=12, border_w=2, border_color=theme.accent_dim()) - - font = theme.pick_font(max(w, h), self.box) - font_sm = theme.pick_font(max(180, h // 2), self.box) - - # Full-width + text-align center: stays parent-centered as digits change. - self.num_shadow = lv.label(self.box) - self.num_shadow.set_text("0") - self.num_shadow.set_style_text_color(theme.chrome_lo(), 0) - theme.apply_font(self.num_shadow, font) - self.num_shadow.set_width(w) - self.num_shadow.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) - - self.num = lv.label(self.box) - self.num.set_text("0") - self.num.set_style_text_color(lv.color_hex(0xFFFFFF), 0) - theme.apply_font(self.num, font) - self.num.set_width(w) - self.num.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) - try: - self.num.set_style_text_letter_space(6, 0) - self.num_shadow.set_style_text_letter_space(6, 0) - except Exception: - pass - - self._layout_num() - - self.unit = lv.label(self.box) - self.unit.set_text("MPH") - self.unit.set_style_text_color(theme.secondary(), 0) - theme.apply_font(self.unit, font_sm) - uh = max(1, self.unit.get_height()) - self.unit.align(lv.ALIGN.BOTTOM_MID, 0, -12 - uh) - - def _center_scaled(self, lbl, scale, x_ofs=0, y_ofs=0): - box_w, box_h = self._box_w, self._box_h - lbl.set_style_translate_x(0, 0) - lbl.set_style_translate_y(0, 0) - lbl.align(lv.ALIGN.CENTER, x_ofs, y_ofs) - w = max(1, lbl.get_width()) - h = max(1, lbl.get_height()) - lbl.set_style_transform_pivot_x(w // 2, 0) - lbl.set_style_transform_pivot_y(h // 2, 0) - try: - lbl.set_style_transform_scale_x(scale, 0) - lbl.set_style_transform_scale_y(scale, 0) - except Exception: - lbl.set_style_transform_scale(scale, 0) - cx = lbl.get_x() + w // 2 - cy = lbl.get_y() + h // 2 - lbl.set_style_translate_x(box_w // 2 + x_ofs - cx, 0) - lbl.set_style_translate_y(box_h // 2 + y_ofs - cy, 0) - - def _layout_num(self): - y_ofs = self._num_y_ofs - scale = self._num_scale - self._center_scaled(self.num, scale, 0, y_ofs) - self._center_scaled(self.num_shadow, scale, 4, y_ofs + 4) - - def set_num_scale(self, scale): - """Transform scale for the digital digits (256 = 1×, 2048 = 8×).""" - self._num_scale = int(scale) - self._layout_num() - - def set_value(self, v): - v = int(_clamp_int(v, 0, 199)) - self.value = v - text = "%d" % v - self.num.set_text(text) - self.num_shadow.set_text(text) - if v >= 100: - self.num.set_style_text_color(theme.warn(), 0) - else: - self.num.set_style_text_color(lv.color_hex(0xFFFFFF), 0) - self._layout_num() - - def set_pos(self, x, y): - self.box.set_pos(x, y) - - def widget(self): - return self.box - - def apply_theme(self): - self.box.set_style_border_color(theme.accent_dim(), 0) - self.set_value(self.value) - self.unit.set_style_text_color(theme.secondary(), 0) - - -def _clamp_int(v, lo, hi): - v = int(v) - if v < lo: - return lo - if v > hi: - return hi - return v - - -def make_rpm_gauge(parent, size): - g = AnalogGauge( - parent, - size, - 0, - 10000, - angle_range=270, - rotation=135, - ticks=51, - major_every=5, - label="RPM", - unit="", - redline=6500, - cap_min="0", - cap_max="10", - arc_mode=True, - value_fmt=lambda v: "%.1f" % (v / 1000.0), - design_layout="rpm", - ) - return g - - -def make_fuel_gauge(parent, size): - return AnalogGauge( - parent, - size, - 0, - 100, - angle_range=120, - rotation=210, - ticks=21, - major_every=5, - label="FUEL", - unit="%", - cap_min="E", - cap_max="F", - arc_mode=True, - bg_angles=(210, 330), - show_value=False, - design_layout="fuel", - ) - - -def make_temp_gauge(parent, size): - return AnalogGauge( - parent, - size, - 100, - 260, - angle_range=150, - rotation=195, - ticks=33, - major_every=4, - label="COOLANT", - unit="°F", - redline=212, - cap_min="C", - cap_max="H", - arc_mode=True, - bg_angles=(195, 345), - design_layout="temp", - ) - - -def make_oil_gauge(parent, size): - return AnalogGauge( - parent, - size, - 0, - 80, - angle_range=180, - rotation=180, - ticks=17, - major_every=4, - label="OIL", - unit="psi", - cap_min="0", - cap_max="80", - arc_mode=True, - bg_angles=(180, 360), - design_layout="oil", - ) - - -def make_speed_gauge(parent, size): - return AnalogGauge( - parent, - size, - 0, - 125, - angle_range=270, - rotation=135, - ticks=26, - major_every=5, - label="SPEED", - unit="mph", - redline=100, - cap_min="0", - cap_max="125", - arc_mode=True, - design_layout="speed", - ) - - -def make_digital_speed(parent, w, h): - return DigitalSpeed(parent, w, h) diff --git a/lib/examples/car_cluster/input_map.py b/lib/examples/car_cluster/input_map.py deleted file mode 100644 index 1be7df27d..000000000 --- a/lib/examples/car_cluster/input_map.py +++ /dev/null @@ -1,169 +0,0 @@ -# SPDX-License-Identifier: MIT -"""SDL keycode → LVGL KEY_* mapping + multi-group focus navigation.""" - -import lvgl as lv - -try: - import keys -except ImportError: - keys = None - -try: - import events -except ImportError: - events = None - -# Captured when display_driver expands HOST → VirtualDevices (see capture_virtual_devices). -_keypad_vdev = None -_vehicle = None -_focus_nav = None -_capture_done = False - - -def _key_const(name, default): - if keys is None: - return default - return getattr(keys, name, default) - - -_SDL_UP = _key_const("K_UP", 1073741906) -_SDL_DOWN = _key_const("K_DOWN", 1073741905) -_SDL_LEFT = _key_const("K_LEFT", 1073741904) -_SDL_RIGHT = _key_const("K_RIGHT", 1073741903) -_SDL_RETURN = _key_const("K_RETURN", 13) -_SDL_KP_ENTER = _key_const("K_KP_ENTER", 1073741912) - -_DIGIT_KEYS = {} -for _i in range(10): - _DIGIT_KEYS[_key_const("K_%d" % _i, 48 + _i)] = _i -if keys is not None: - _DIGIT_KEYS[keys.K_KP_0] = 0 - for _i in range(1, 10): - _DIGIT_KEYS[getattr(keys, "K_KP_%d" % _i)] = _i - - -def remap_nav_key(sdl_key): - """Map appdev/SDL key codes to lv.KEY for group navigation.""" - if sdl_key == _SDL_UP: - return lv.KEY.UP - if sdl_key == _SDL_DOWN: - return lv.KEY.DOWN - if sdl_key == _SDL_LEFT: - return lv.KEY.LEFT - if sdl_key == _SDL_RIGHT: - return lv.KEY.RIGHT - if sdl_key in (_SDL_RETURN, _SDL_KP_ENTER): - return lv.KEY.ENTER - return None - - -def digit_from_key(sdl_key): - return _DIGIT_KEYS.get(sdl_key) - - -def capture_virtual_devices(): - """Retained as a no-op for callers predating the local LVGL input bridge.""" - - -def _find_keypad_indev(): - """Return the LVGL keypad indev created by display_driver, if any.""" - global _keypad_vdev - if _keypad_vdev is None: - try: - import display_driver - - for virtual in getattr(display_driver._driver_ref, "virtual_devices", ()): - _keypad_vdev = virtual._vd_keypad - break - except Exception: - pass - if _keypad_vdev is not None: - ud = getattr(_keypad_vdev, "user_data", None) - if ud is not None: - return ud - # Fallback: scan indevs - try: - indev = lv.indev_get_next(None) - while indev is not None: - try: - if indev.get_type() == lv.INDEV_TYPE.KEYPAD: - return indev - except Exception: - pass - try: - indev = lv.indev_get_next(indev) - except Exception: - break - except Exception: - pass - return None - - -def _mapped_keypad_cb(event, indev, data): - """Replacement for display_driver._keypad_cb. - - Arrows → FocusNav (KEYPAD indev only auto-navigates on KEY_NEXT/PREV). - Enter → remapped lv.KEY.ENTER for the active group. - Digits are ignored (vehicle drive profile is automatic). - """ - if event is None or events is None: - return - - nav = _focus_nav - - if event.type == events.KEYDOWN: - if digit_from_key(event.key) is not None: - data.state = lv.INDEV_STATE.RELEASED - data.key = 0 - return - - mapped = remap_nav_key(event.key) - if mapped is not None and mapped != lv.KEY.ENTER and nav is not None: - # Consume arrows ourselves — KEYPAD won't focus_next on UP/DOWN. - nav.handle_nav_key(mapped) - data.state = lv.INDEV_STATE.RELEASED - data.key = 0 - return - - data.state = lv.INDEV_STATE.PRESSED - data.key = mapped if mapped is not None else event.key - - elif event.type == events.KEYUP: - if digit_from_key(event.key) is not None: - data.state = lv.INDEV_STATE.RELEASED - data.key = 0 - return - - mapped = remap_nav_key(event.key) - if mapped is not None and mapped != lv.KEY.ENTER and nav is not None: - data.state = lv.INDEV_STATE.RELEASED - data.key = 0 - return - - data.state = lv.INDEV_STATE.RELEASED - data.key = mapped if mapped is not None else event.key - - -class InputBridge: - """Install SDL→LVGL key mapping and bind FocusNav to the keypad indev.""" - - def __init__(self, app, vehicle, focus_nav=None): - self.app = app - self.vehicle = vehicle - self.focus_nav = focus_nav - - def install(self): - global _vehicle, _focus_nav - _vehicle = self.vehicle - _focus_nav = self.focus_nav - - import display_driver - - _find_keypad_indev() - if _keypad_vdev is not None: - _keypad_vdev.subscribe(_mapped_keypad_cb) - - if self.focus_nav is not None: - indev = _find_keypad_indev() - if indev is not None: - self.focus_nav.bind_keypad_indev(indev) diff --git a/lib/examples/car_cluster/key_probe.py b/lib/examples/car_cluster/key_probe.py deleted file mode 100644 index 49d2bd893..000000000 --- a/lib/examples/car_cluster/key_probe.py +++ /dev/null @@ -1,88 +0,0 @@ -# gallery: skip -# SPDX-License-Identifier: MIT -""" -key_probe.py — print key codes from the host event pump. - -Run from pydevices-examples/lib:: - - python examples/car_cluster/key_probe.py - -Focus the canvas, then press keys. Output shows SDL-style ``event.key``, -``keys`` name when known, suggested ``lv.KEY`` mapping, and digit/throttle -interpretation for the car cluster. - -Press Ctrl+Q (or your platform quit chord) to exit. -""" - -import sys - -_file = __file__.replace("\\", "/") -_PKG = _file.rsplit("/", 1)[0] -_parts = _file.split("/") -if "examples" in _parts: - _idx = _parts.index("examples") - _src = "/".join(_parts[:_idx]) if _idx else "." -else: - _src = "." -if _src and _src not in sys.path: - sys.path.insert(0, _src) -if _PKG not in sys.path: - sys.path.insert(0, _PKG) - -import display_driver # noqa: E402, F401 -from display_driver import app # noqa: E402 -import lvgl as lv # noqa: E402 - -import events # noqa: E402 -import keys # noqa: E402 - -try: - from input_map import digit_from_key, remap_nav_key -except ImportError: - digit_from_key = None - remap_nav_key = None - -_LV_KEY_NAMES = { - lv.KEY.UP: "lv.KEY.UP", - lv.KEY.DOWN: "lv.KEY.DOWN", - lv.KEY.LEFT: "lv.KEY.LEFT", - lv.KEY.RIGHT: "lv.KEY.RIGHT", - lv.KEY.ENTER: "lv.KEY.ENTER", - lv.KEY.ESC: "lv.KEY.ESC", - lv.KEY.NEXT: "lv.KEY.NEXT", - lv.KEY.PREV: "lv.KEY.PREV", -} - - -def _key_name(code): - for name in dir(keys): - if name.startswith("K_") and getattr(keys, name, None) == code: - return name - return "?" - - -def _on_key(event): - if event.type not in (events.KEYDOWN, events.KEYUP): - return - phase = "DOWN" if event.type == events.KEYDOWN else "UP" - code = event.key - name = _key_name(code) - line = "KEY%s code=%s keys.%s" % (phase, code, name) - if digit_from_key is not None: - d = digit_from_key(code) - if d is not None: - gear = 10 if d == 0 else d - line += " digit=%d gear=%d throttle=%.2f" % (d, gear, d / 10.0 if d else 0.05) - if remap_nav_key is not None: - mapped = remap_nav_key(code) - if mapped is not None: - line += " -> %s (%d)" % (_LV_KEY_NAMES.get(mapped, "lv.KEY"), mapped) - print(line) - - -print("key_probe: focus the canvas.") -print("Try: arrow keys, Enter, digits 0-9 (top row and keypad).") -print("Ctrl+Q to quit.\n") - -for et in (events.KEYDOWN, events.KEYUP): - app.on(et, _on_key) diff --git a/lib/examples/car_cluster/layout.py b/lib/examples/car_cluster/layout.py deleted file mode 100644 index 5d06ce93a..000000000 --- a/lib/examples/car_cluster/layout.py +++ /dev/null @@ -1,188 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Root cluster layout: side gauges + center bezel with dual rails + tabview.""" - -import lvgl as lv - -import chrome -import gauges -import rails -import theme -from screens import BUILDERS, TAB_NAMES - - -class ClusterUI: - def __init__(self, vehicle, focus_nav=None): - self.vehicle = vehicle - self.focus_nav = focus_nav - if focus_nav is not None: - self.left_group = focus_nav.left - self.center_group = focus_nav.center - self.right_group = focus_nav.right - else: - g = lv.group_get_default() - if g is None: - g = lv.group_create() - g.set_default() - self.left_group = g - self.center_group = g - self.right_group = g - self.screens = [] - self.gauges = {} - self.rails = None - self.tabview = None - self._build() - - def _build(self): - scr = lv.screen_active() - chrome.style_root(scr) - W = scr.get_width() - H = scr.get_height() - - # Outer perimeter bezel - margin = 6 - outer, shell = chrome.make_bezel(scr, margin, margin, W - 2 * margin, H - 2 * margin, depth=3, radius=12, pad=3) - self.outer = outer - sw = shell.get_width() - sh = shell.get_height() - if sw < 100: - sw = W - 40 - if sh < 100: - sh = H - 40 - - # Center column: slightly narrower than tall, nearly full height - center_h = sh - 8 - center_w = int(center_h * 0.90) # slightly narrower than tall - # Side columns share remaining width - side_w = (sw - center_w - 16) // 2 - if side_w < 120: - # shrink center to keep gauges usable - side_w = max(110, (sw - 16) // 4) - center_w = sw - 2 * side_w - 16 - cx = side_w + 8 - cy = 4 - - # Left stack: RPM (large) + Fuel (smaller) - left = lv.obj(shell) - left.set_pos(4, 4) - left.set_size(side_w, sh - 8) - theme.style_bg(left, theme.bg(), radius=0) - if hasattr(left, "remove_flag"): - left.remove_flag(lv.obj.FLAG.SCROLLABLE) - - gs = theme.gauge_scale() - col_h = sh - 8 - rpm_sz = int(min(side_w - 8, col_h * 0.58) * gs) - fuel_sz = int(min(side_w - 8, col_h * 0.36) * gs) - # Even vertical gaps: top / between / bottom. - left_gap = max(4, (col_h - rpm_sz - fuel_sz) // 3) - self.gauges["rpm"] = gauges.make_rpm_gauge(left, rpm_sz) - self.gauges["rpm"].set_pos((side_w - rpm_sz) // 2, left_gap) - self.gauges["fuel"] = gauges.make_fuel_gauge(left, fuel_sz) - self.gauges["fuel"].set_pos((side_w - fuel_sz) // 2, left_gap + rpm_sz + left_gap) - - # Right stack: Temp + Oil - right = lv.obj(shell) - right.set_pos(sw - side_w - 4, 4) - right.set_size(side_w, sh - 8) - theme.style_bg(right, theme.bg(), radius=0) - if hasattr(right, "remove_flag"): - right.remove_flag(lv.obj.FLAG.SCROLLABLE) - - temp_sz = int(min(side_w - 8, col_h * 0.48) * gs) - oil_sz = int(min(side_w - 8, col_h * 0.40) * gs) - right_gap = max(4, (col_h - temp_sz - oil_sz) // 3) - self.gauges["temp"] = gauges.make_temp_gauge(right, temp_sz) - self.gauges["temp"].set_pos((side_w - temp_sz) // 2, right_gap) - self.gauges["oil"] = gauges.make_oil_gauge(right, oil_sz) - self.gauges["oil"].set_pos((side_w - oil_sz) // 2, right_gap + temp_sz + right_gap) - - # Center chrome - c_outer, c_content = chrome.make_center_bezel(shell, cx, cy, center_w, center_h) - self.center_outer = c_outer - cw = c_content.get_width() - ch = c_content.get_height() - if cw < 80: - cw = center_w - 24 - if ch < 80: - ch = center_h - 24 - - rail_w = max(56, cw // 7) - mid_w = cw - 2 * rail_w - 8 - content_h = ch - 4 # slight inset; gear lives on Speed screen only - - # Tabview with hidden bar — pages fill content area - self.tabview = lv.tabview(c_content) - self.tabview.set_size(mid_w, content_h) - self.tabview.set_pos(rail_w + 4, 0) - bar = self.tabview.get_tab_bar() - bar.add_flag(lv.obj.FLAG.HIDDEN) - try: - self.tabview.set_tab_bar_size(0) - except Exception: - pass - - try: - content = self.tabview.get_content() - page_w = content.get_width() - page_h = content.get_height() - except Exception: - page_w = mid_w - page_h = content_h - if page_w < 40: - page_w = mid_w - if page_h < 40: - page_h = content_h - - pages = [] - for name in TAB_NAMES: - page = self.tabview.add_tab(name) - page.set_size(lv.pct(100), lv.pct(100)) - pages.append(page) - - self.rails = rails.Rails( - c_content, - self.tabview, - self.left_group, - self.right_group, - rail_w, - content_h, - pad=3, - ) - self.rails.place(0, cw - rail_w, 0) - - for i, builder in enumerate(BUILDERS): - self.screens.append( - builder(pages[i], self.vehicle, self.center_group, page_w, page_h) - ) - - self.rails.select(0, anim=False) - self.rails.drain_pending() - - # Seed defaults - self.gauges["fuel"].set_value(int(self.vehicle.fuel_frac * 100)) - self.gauges["temp"].set_value(self.vehicle.coolant_f) - self.gauges["oil"].set_value(self.vehicle.oil_psi) - self.gauges["rpm"].set_value(self.vehicle.rpm) - - theme.on_change(self._on_theme_change) - - def _on_theme_change(self): - chrome.apply_theme() - for g in self.gauges.values(): - if hasattr(g, "apply_theme"): - g.apply_theme() - if self.rails is not None and hasattr(self.rails, "apply_theme"): - self.rails.apply_theme() - for scr in self.screens: - if hasattr(scr, "apply_theme"): - scr.apply_theme() - - def update(self): - v = self.vehicle - self.gauges["rpm"].set_value(v.rpm) - self.gauges["fuel"].set_value(int(v.fuel_frac * 100)) - self.gauges["temp"].set_value(v.coolant_f) - self.gauges["oil"].set_value(v.oil_psi) - for scr in self.screens: - if hasattr(scr, "update"): - scr.update() diff --git a/lib/examples/car_cluster/lv_util.py b/lib/examples/car_cluster/lv_util.py deleted file mode 100644 index e9fb96407..000000000 --- a/lib/examples/car_cluster/lv_util.py +++ /dev/null @@ -1,36 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Tiny LVGL helpers (flag + group API differences across bindings).""" - -import lvgl as lv - - -def add_flag(obj, flag): - obj.add_flag(flag) - - -def remove_flag(obj, flag): - if hasattr(obj, "remove_flag"): - obj.remove_flag(flag) - elif hasattr(obj, "clear_flag"): - obj.clear_flag(flag) - - -def hide(obj): - add_flag(obj, lv.obj.FLAG.HIDDEN) - - -def show(obj): - remove_flag(obj, lv.obj.FLAG.HIDDEN) - - -def hide_clickable(obj): - remove_flag(obj, lv.obj.FLAG.CLICKABLE) - - -def group_add(group, obj): - if group is None: - return - if hasattr(group, "add_obj"): - group.add_obj(obj) - elif hasattr(lv, "group_add_obj"): - lv.group_add_obj(group, obj) diff --git a/lib/examples/car_cluster/rails.py b/lib/examples/car_cluster/rails.py deleted file mode 100644 index 856f33b59..000000000 --- a/lib/examples/car_cluster/rails.py +++ /dev/null @@ -1,166 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Dual 4+4 focusable steering-wheel style menu rails.""" - -import lvgl as lv - -import chrome -import lv_util -import theme - -# Left rail (0-3), right rail (4-7) — indices match tabview pages. -RAIL_ITEMS = ( - # left - ("SPD", "Speed", getattr(lv.SYMBOL, "HOME", "")), - ("TR-A", "Trip A", getattr(lv.SYMBOL, "LOOP", "")), - ("TR-B", "Trip B", getattr(lv.SYMBOL, "LOOP", "")), - ("ENG", "Engine", getattr(lv.SYMBOL, "CHARGE", "")), - # right - ("LGT", "Lights", getattr(lv.SYMBOL, "EYE_OPEN", "")), - ("TIR", "Tires", getattr(lv.SYMBOL, "SETTINGS", "")), - ("AST", "Assist", getattr(lv.SYMBOL, "GPS", "")), - ("THM", "Theme", getattr(lv.SYMBOL, "TINT", "")), -) - - -class Rails: - def __init__(self, parent, tabview, left_group, right_group, rail_w, height, pad=4): - self.tabview = tabview - self.left_group = left_group - self.right_group = right_group - self.buttons = [] - self._icon_lbls = [] - self._text_lbls = [] - self._selected = 0 - self._selecting = False - self._pending_tab = None - self.left = lv.obj(parent) - self.right = lv.obj(parent) - for col in (self.left, self.right): - col.set_size(rail_w, height) - if hasattr(col, "remove_flag"): - col.remove_flag(lv.obj.FLAG.SCROLLABLE) - theme.style_bg(col, theme.panel(), radius=8, border_w=0) - col.set_style_pad_all(pad, 0) - col.set_style_pad_row(pad, 0) - col.set_flex_flow(lv.FLEX_FLOW.COLUMN) - col.set_flex_align(lv.FLEX_ALIGN.SPACE_EVENLY, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) - - font = theme.pick_font(160, parent) - font_icon = theme.pick_font(200, parent) - btn_h = max(48, (height - pad * 5) // 4) - - for i, (short, _long, sym) in enumerate(RAIL_ITEMS): - col = self.left if i < 4 else self.right - group = left_group if i < 4 else right_group - btn = lv.button(col) - btn.set_size(lv.pct(100), btn_h) - btn.set_style_pad_all(2, 0) - btn.set_style_pad_row(2, 0) - chrome.style_rail_button(btn, selected=(i == 0), initial=True) - btn.set_flex_flow(lv.FLEX_FLOW.COLUMN) - btn.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) - - if sym: - icon = lv.label(btn) - icon.set_text(sym) - icon.set_style_text_color(theme.accent_lite(), 0) - theme.apply_font(icon, font_icon) - icon.set_style_pad_all(0, 0) - self._icon_lbls.append(icon) - else: - self._icon_lbls.append(None) - - lbl = lv.label(btn) - lbl.set_text(short) - lbl.set_style_text_color(theme.text(), 0) - theme.apply_font(lbl, font) - lbl.set_style_pad_all(0, 0) - self._text_lbls.append(lbl) - - # Exactly one group per control. - lv_util.group_add(group, btn) - - def _make(idx): - def _cb(e): - self.select(idx) - - return _cb - - btn.add_event_cb(_make(i), lv.EVENT.CLICKED, None) - # Arrow focus switches the center page; set_active is deferred via - # drain_pending() on an app tick (and skipped while lv._nesting!=0). - btn.add_event_cb(_make(i), lv.EVENT.FOCUSED, None) - self.buttons.append(btn) - - def place(self, left_x, right_x, y): - self.left.set_pos(left_x, y) - self.right.set_pos(right_x, y) - - def select(self, index, anim=False, *, force=False): - index = int(index) % len(self.buttons) - # FOCUSED/CLICKED → select must not call tabview.set_active inline: that - # re-enters the LVGL event/focus path inside task_handler and wedges the - # soft timer under key storms. Style here; apply the tab on drain_pending(). - if self._selecting: - return - if not force and index == self._selected: - return - self._selecting = True - try: - nest = getattr(getattr(lv, "_nesting", None), "value", None) - self._selected = index - self._pending_tab = (index, 1 if anim else 0) - # While nested in an LVGL FOCUSED callback, only record pending. - # Restyling / add_style while nesting!=0 hard-locks under key storms. - if nest: - return - self._apply_selection_styles(index) - finally: - self._selecting = False - - def _apply_selection_styles(self, index): - for i, btn in enumerate(self.buttons): - chrome.style_rail_button(btn, selected=(i == index)) - icon = self._icon_lbls[i] - if icon is not None: - icon.set_style_text_color( - theme.text() if i == index else theme.accent_lite(), 0 - ) - - def drain_pending(self): - """Apply a deferred tabview change outside the LVGL event callback stack. - - Called from an app tick. Soft-timer ticks are delivered via - ``micropython.schedule`` and can run while LVGL is mid-render (the VM - resumes inside ``flush_cb``); ``lv._nesting`` != 0 there, and calling - ``set_active`` would re-enter LVGL — the intermittent hard-wedge seen - under Enter-key soak. Defer to the next tick instead. - """ - pending = self._pending_tab - if pending is None: - return - try: - if lv._nesting.value != 0: - return - except AttributeError: - pass - self._pending_tab = None - index, anim_en = pending - # Apply deferred rail restyle outside the FOCUSED callback. - self._apply_selection_styles(index) - try: - if self.tabview.get_tab_act() == index: - return - except Exception: - pass - try: - self.tabview.set_active(index, anim_en) - except TypeError: - self.tabview.set_active(index, False) - - def apply_theme(self): - self.select(self._selected, anim=False, force=True) - - @property - def selected(self): - return self._selected diff --git a/lib/examples/car_cluster/ring.py b/lib/examples/car_cluster/ring.py new file mode 100644 index 000000000..7c5386c06 --- /dev/null +++ b/lib/examples/car_cluster/ring.py @@ -0,0 +1,312 @@ +# SPDX-License-Identifier: MIT +"""A neon ring gauge: the two big dials of the cluster. + +The ring is drawn once, as art, on a canvas: a dim track, the full colour +sweep of the gauge as many short arc segments whose colours slide from one +stop to the next, a tick ring outside it and a faint inner ring. The live +value then needs only cheap moves each frame: + +* a *cover* arc, the colour of the empty track, hides the sweep beyond the + value, so the sweep appears to grow and shrink; +* a wide, blurred arc under the canvas glows along the lit part; +* a bright cap sits on the leading edge. + +Nothing here is an LVGL arc wearing its default look. The arcs are used for +what they are cheap at (an angle that changes every frame) and the canvas for +what the arc widget can't do (a colour gradient along the sweep). +""" + +import math + +import lvgl as lv + +_CIRCLE = getattr(lv, "RADIUS_CIRCLE", 0x7FFF) + + +def _color(rgb): + return lv.color_hex(rgb) + + +def _mix(a, b, t): + """Blend two 0xRRGGBB colours; ``t`` from 0 (a) to 1 (b).""" + r = int(((a >> 16) & 0xFF) * (1 - t) + ((b >> 16) & 0xFF) * t) + g = int(((a >> 8) & 0xFF) * (1 - t) + ((b >> 8) & 0xFF) * t) + bl = int((a & 0xFF) * (1 - t) + (b & 0xFF) * t) + return (r << 16) | (g << 8) | bl + + +def gradient_at(stops, t): + """Colour at ``t`` (0..1) along a list of ``(position, 0xRRGGBB)`` stops.""" + if t <= stops[0][0]: + return stops[0][1] + for i in range(1, len(stops)): + pos, col = stops[i] + if t <= pos: + p0, c0 = stops[i - 1] + span = pos - p0 + return _mix(c0, col, (t - p0) / span if span > 0 else 1.0) + return stops[-1][1] + + +def plain(obj): + """An object that is only there to be looked at.""" + obj.set_style_pad_all(0, 0) + obj.set_style_border_width(0, 0) + obj.set_style_outline_width(0, 0) + obj.set_style_bg_opa(lv.OPA.TRANSP, 0) + obj.remove_flag(lv.obj.FLAG.CLICKABLE) + obj.remove_flag(lv.obj.FLAG.SCROLLABLE) + + +def scaled_font(label, target_px): + """The largest built-in Montserrat at or below ``target_px``, scaled up to it.""" + chosen = None + for points in (48, 40, 32, 24, 16, 14): + font = getattr(lv, "font_montserrat_%d" % points, None) + if font is not None: + chosen = (font, points) + if points <= target_px: + break + if chosen is None: + return + font, points = chosen + label.set_style_text_font(font, 0) + scale = max(128, min(768, round(256 * target_px / points))) + if scale != 256: + label.set_style_transform_scale(scale, 0) + + +class Ring: + """One dial. ``stops`` colours the sweep, ``vmax`` is the full-scale value.""" + + def __init__( + self, + parent, + size, + stops, + vmax, + *, + track=0x0E1E33, + bg=0x05080F, + glow=None, + start=135, + sweep=270, + unit="", + caption="", + ticks=54, + major_every=6, + ): + self.size = size + self.stops = stops + self.vmax = float(vmax) + self.start = start + self.sweep = sweep + self.value = 0.0 + self._shown = None + self._glow_rgb = glow if glow is not None else stops[-1][1] + + self.width = max(10, size // 15) # the sweep's thickness + self.radius = size // 2 - max(12, size // 11) # centre line of the sweep + cx = cy = size // 2 + self.cx, self.cy = cx, cy + + self.obj = lv.obj(parent) + self.obj.set_size(size, size) + plain(self.obj) + + # Glow first, so everything else draws over it. + self.glow = lv.arc(self.obj) + self.glow.set_size(size, size) + self.glow.center() + self.glow.set_bg_angles(start, start + sweep) + self.glow.set_range(0, 1000) + self.glow.set_value(0) + self.glow.remove_style(None, lv.PART.KNOB) + plain(self.glow) + pad = size // 2 - self.radius - self.width // 2 + self.glow.set_style_pad_all(pad, 0) + self.glow.set_style_arc_opa(lv.OPA.TRANSP, lv.PART.MAIN) + self.glow.set_style_arc_width(self.width + 16, lv.PART.INDICATOR) + self.glow.set_style_arc_color(_color(self._glow_rgb), lv.PART.INDICATOR) + self.glow.set_style_arc_opa(150, lv.PART.INDICATOR) + self.glow.set_style_arc_rounded(True, lv.PART.INDICATOR) + if hasattr(self.glow, "set_style_blur_radius"): + self.glow.set_style_blur_radius(max(6, size // 22), 0) + + # The art: track, colour sweep, ticks, inner ring. + self.canvas = lv.canvas(self.obj) + self.canvas.set_size(size, size) + self.canvas.center() + plain(self.canvas) + self._buf = lv.draw_buf_create(size, size, lv.COLOR_FORMAT.ARGB8888, 0) + self.canvas.set_draw_buf(self._buf) + self.canvas.fill_bg(_color(0x000000), lv.OPA.TRANSP) + self._paint(track, ticks, major_every) + + # The cover: the empty part of the track, painted over the sweep. + self.cover = lv.arc(self.obj) + self.cover.set_size(size, size) + self.cover.center() + self.cover.set_bg_angles(start, start + sweep) + self.cover.set_range(0, 1000) + self.cover.set_value(0) + self.cover.remove_style(None, lv.PART.KNOB) + plain(self.cover) + self.cover.set_style_pad_all(pad, 0) + self.cover.set_style_arc_width(self.width + 2, lv.PART.MAIN) + self.cover.set_style_arc_color(_color(track), lv.PART.MAIN) + self.cover.set_style_arc_opa(lv.OPA.COVER, lv.PART.MAIN) + self.cover.set_style_arc_rounded(False, lv.PART.MAIN) + self.cover.set_style_arc_opa(lv.OPA.TRANSP, lv.PART.INDICATOR) + + # The dial face inside the sweep. + face = size - 2 * (size // 2 - self.radius) - self.width - max(10, size // 14) + self.face = lv.obj(self.obj) + self.face.set_size(face, face) + self.face.center() + plain(self.face) + self.face.set_style_radius(_CIRCLE, 0) + self.face.set_style_bg_color(_color(0x0B1530), 0) + self.face.set_style_bg_grad_color(_color(bg), 0) + self.face.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) + self.face.set_style_bg_opa(lv.OPA.COVER, 0) + self.face.set_style_border_width(1, 0) + self.face.set_style_border_color(_color(self.stops[0][1]), 0) + self.face.set_style_border_opa(60, 0) + + # The cap on the leading edge of the sweep. + cap = self.width + 6 + self.cap = lv.obj(self.obj) + self.cap.set_size(cap, cap) + plain(self.cap) + self.cap.set_style_radius(_CIRCLE, 0) + self.cap.set_style_bg_color(_color(0xFFFFFF), 0) + self.cap.set_style_bg_opa(lv.OPA.COVER, 0) + self.cap.set_style_shadow_color(_color(self._glow_rgb), 0) + self.cap.set_style_shadow_width(cap * 2, 0) + self.cap.set_style_shadow_spread(2, 0) + self.cap.set_style_shadow_opa(220, 0) + + # The reading. + self.unit = lv.label(self.face) + self.unit.set_text(unit) + self.unit.set_style_text_color(_color(0x7F93B5), 0) + self.unit.set_style_text_font(lv.font_montserrat_14, 0) + self.unit.align(lv.ALIGN.CENTER, 0, -face * 36 // 100) + + px = max(24, face * 44 // 100) + self.number = lv.label(self.face) + self.number.set_text("0") + self.number.set_width(face) + self.number.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) + self.number.set_style_text_color(_color(0xFFFFFF), 0) + self.number.set_style_text_letter_space(2, 0) + scaled_font(self.number, px) + self.number.set_style_transform_pivot_x(face // 2, 0) + self.number.set_style_transform_pivot_y(px // 2, 0) + self.number.align(lv.ALIGN.CENTER, 0, 0) + + self.caption = lv.label(self.obj) + self.caption.set_text(caption) + self.caption.set_style_text_color(_color(0xB8C4DC), 0) + self.caption.set_style_text_font(lv.font_montserrat_16, 0) + self.caption.align(lv.ALIGN.BOTTOM_MID, 0, -max(2, size // 40)) + + self.set_value(0) + + # -- art ---------------------------------------------------------------- + + def _paint(self, track, ticks, major_every): + layer = lv.layer_t() + self.canvas.init_layer(layer) + cx, cy, r = self.cx, self.cy, self.radius + + arc = lv.draw_arc_dsc_t() + arc.init() + arc.center = lv.point_t({"x": cx, "y": cy}) + arc.rounded = False + + # Dim track under everything. + arc.color = _color(track) + arc.width = self.width + 2 + arc.radius = r + arc.width // 2 + arc.opa = lv.OPA.COVER + arc.start_angle = self.start + arc.end_angle = self.start + self.sweep + lv.draw_arc(layer, arc) + + # The colour sweep: short segments, each a step along the gradient. + # Segments overlap by a hair so the joins don't show. + steps = max(24, self.sweep // 3) + arc.width = self.width + arc.radius = r + self.width // 2 + for i in range(steps): + t0 = i / steps + t1 = (i + 1) / steps + arc.color = _color(gradient_at(self.stops, (t0 + t1) / 2)) + arc.start_angle = self.start + self.sweep * t0 + arc.end_angle = self.start + self.sweep * t1 + 0.6 + lv.draw_arc(layer, arc) + + # A hairline on the inside edge of the sweep. + arc.color = _color(0xFFFFFF) + arc.width = 1 + arc.radius = r - self.width // 2 - 1 + arc.opa = 60 + arc.start_angle = self.start + arc.end_angle = self.start + self.sweep + lv.draw_arc(layer, arc) + + # Tick ring outside the sweep. + line = lv.draw_line_dsc_t() + line.init() + line.round_start = True + line.round_end = True + r_out = self.size // 2 - 2 + for i in range(ticks + 1): + t = i / ticks + a = math.radians(self.start + self.sweep * t) + major = i % major_every == 0 + length = max(8, self.size // 24) if major else max(4, self.size // 48) + line.width = 3 if major else 2 + line.color = _color(gradient_at(self.stops, t)) + line.opa = 230 if major else 110 + line.p1 = lv.point_precise_t({"x": cx + math.cos(a) * r_out, "y": cy + math.sin(a) * r_out}) + line.p2 = lv.point_precise_t( + {"x": cx + math.cos(a) * (r_out - length), "y": cy + math.sin(a) * (r_out - length)} + ) + lv.draw_line(layer, line) + + self.canvas.finish_layer(layer) + + # -- live --------------------------------------------------------------- + + def set_value(self, v): + v = max(0.0, min(self.vmax, float(v))) + self.value = v + frac = v / self.vmax + a_val = self.start + self.sweep * frac + if frac >= 0.999: + self.cover.add_flag(lv.obj.FLAG.HIDDEN) + else: + self.cover.remove_flag(lv.obj.FLAG.HIDDEN) + self.cover.set_bg_angles(a_val, self.start + self.sweep) + self.glow.set_value(int(1000 * frac)) + self.glow.set_style_arc_color(_color(gradient_at(self.stops, frac)), lv.PART.INDICATOR) + a = math.radians(a_val) + cap = self.cap.get_width() or (self.width + 6) + self.cap.set_pos( + int(self.cx + math.cos(a) * self.radius - cap / 2), + int(self.cy + math.sin(a) * self.radius - cap / 2), + ) + self.cap.set_style_shadow_color(_color(gradient_at(self.stops, frac)), 0) + shown = int(v + 0.5) + if shown != self._shown: + self._shown = shown + self.number.set_text("%d" % shown) + + def set_caption(self, text): + self.caption.set_text(text) + + def set_pos(self, x, y): + self.obj.set_pos(x, y) diff --git a/lib/examples/car_cluster/screens/__init__.py b/lib/examples/car_cluster/screens/__init__.py deleted file mode 100644 index 40e94d047..000000000 --- a/lib/examples/car_cluster/screens/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Center tab pages for the instrument cluster.""" - -from .assist import build as build_assist -from .engine import build as build_engine -from .lights import build as build_lights -from .speed import build as build_speed -from .theme_screen import build as build_theme -from .tires import build as build_tires -from .trip_a import build as build_trip_a -from .trip_b import build as build_trip_b - -BUILDERS = ( - build_speed, - build_trip_a, - build_trip_b, - build_engine, - build_lights, - build_tires, - build_assist, - build_theme, -) - -TAB_NAMES = ( - "Speed", - "Trip A", - "Trip B", - "Engine", - "Lights", - "Tires", - "Assist", - "Theme", -) diff --git a/lib/examples/car_cluster/screens/_common.py b/lib/examples/car_cluster/screens/_common.py deleted file mode 100644 index 21acd83b4..000000000 --- a/lib/examples/car_cluster/screens/_common.py +++ /dev/null @@ -1,283 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Shared helpers for center tab pages.""" - -import lvgl as lv - -import lv_util -import theme - -_TRACK_H = 8 # slider / bar track thickness; knob matches this - - -def no_scroll(obj): - if hasattr(obj, "remove_flag"): - obj.remove_flag(lv.obj.FLAG.SCROLLABLE) - elif hasattr(obj, "clear_flag"): - obj.clear_flag(lv.obj.FLAG.SCROLLABLE) - - -def zero_pad(obj): - obj.set_style_pad_all(0, 0) - obj.set_style_pad_row(0, 0) - obj.set_style_pad_column(0, 0) - obj.set_style_margin_all(0, 0) - - -def content_size(page, w, h): - """Usable page area; ``w``/``h`` come from layout (tabview content).""" - if w > 40 and h > 40: - return int(w), int(h) - pw = page.get_width() - ph = page.get_height() - if pw > 40 and ph > 40: - return pw, ph - return 360, 420 - - -def prep_page(page, w=0, h=0): - no_scroll(page) - page.set_size(lv.pct(100), lv.pct(100)) - theme.style_bg(page, theme.face(), radius=0) - zero_pad(page) - return page - - -def section_label(parent, text, y, *, accent=False): - lbl = lv.label(parent) - lbl.set_text(text) - lbl.set_style_text_color(theme.accent_lite() if accent else theme.text_dim(), 0) - theme.apply_font(lbl, theme.pick_font(220, parent)) - zero_pad(lbl) - lbl.set_pos(4, y) - return lbl - - -def scale_2x(lbl): - """2× transform about the label center.""" - w = max(1, lbl.get_width()) - h = max(1, lbl.get_height()) - lbl.set_style_transform_pivot_x(w // 2, 0) - lbl.set_style_transform_pivot_y(h // 2, 0) - try: - lbl.set_style_transform_scale(512, 0) - except Exception: - lbl.set_style_transform_scale_x(512, 0) - lbl.set_style_transform_scale_y(512, 0) - - -def scale_2x_edge(lbl, *, left=True): - """2× transform pivoting on the left or right edge.""" - w = max(1, lbl.get_width()) - h = max(1, lbl.get_height()) - lbl.set_style_transform_pivot_x(0 if left else w, 0) - lbl.set_style_transform_pivot_y(h // 2, 0) - try: - lbl.set_style_transform_scale(512, 0) - except Exception: - lbl.set_style_transform_scale_x(512, 0) - lbl.set_style_transform_scale_y(512, 0) - - -def kv_row(parent, y, key, value_text, width): - k = lv.label(parent) - k.set_text(key) - k.set_style_text_color(theme.text_dim(), 0) - theme.apply_font(k, theme.pick_font(200, parent)) - zero_pad(k) - k.set_pos(8, y) - - v = lv.label(parent) - v.set_text(value_text) - v.set_style_text_color(theme.text(), 0) - theme.apply_font(v, theme.pick_font(220, parent)) - zero_pad(v) - v.set_pos(max(width // 2, 140), y) - return k, v - - -def apply_kv_edge_scale(keys, vals, page_w, *, pad=14, dy=None, page=None): - """Design-session layout: 2× edge-pivoted keys/values + pad/dy nudges.""" - if not keys: - return - if page is None: - page = keys[0].get_parent() - try: - lv.obj.update_layout(page) - except Exception: - pass - label_h = max(1, keys[0].get_height()) - if dy is None: - dy = label_h - for lbl in keys: - scale_2x_edge(lbl, left=True) - lbl.set_style_translate_x(pad - 8, 0) - lbl.set_style_translate_y(dy, 0) - for lbl in vals: - w = max(1, lbl.get_width()) - scale_2x_edge(lbl, left=False) - lbl.set_style_translate_x(page_w - pad - w - 152, 0) - lbl.set_style_translate_y(dy, 0) - - -def spread_rows(count, top, bottom, height): - """Even row Y positions between ``top`` and ``height - bottom``.""" - usable = max(40, height - top - bottom) - if count <= 1: - return [top] - step = usable // count - return [top + i * step for i in range(count)] - - -def make_button(parent, text, w, h, group=None): - btn = lv.button(parent) - btn.set_size(w, h) - zero_pad(btn) - theme.style_bg(btn, theme.panel_raised(), radius=8, border_w=1, border_color=theme.accent_dim()) - zero_pad(btn) - focus = lv.style_t() - focus.init() - focus.set_outline_width(2) - focus.set_outline_color(theme.accent()) - focus.set_outline_opa(lv.OPA.COVER) - btn.add_style(focus, lv.STATE.FOCUSED) - theme.retain_style(focus) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_color(theme.text(), 0) - theme.apply_font(lbl, theme.pick_font(180, parent)) - zero_pad(lbl) - lbl.center() - if group is not None: - lv_util.group_add(group, btn) - return btn - - -def apply_button_theme(btn): - btn.set_style_bg_color(theme.panel_raised(), 0) - btn.set_style_border_color(theme.accent_dim(), 0) - btn.set_style_outline_color(theme.accent(), lv.STATE.FOCUSED) - try: - btn.get_child(0).set_style_text_color(theme.text(), 0) - except Exception: - pass - - -def _style_switch(sw): - st = lv.style_t() - st.init() - st.set_bg_color(theme.accent()) - st.set_bg_opa(lv.OPA.COVER) - sw.add_style(st, lv.PART.INDICATOR | lv.STATE.CHECKED) - theme.retain_style(st) - st2 = lv.style_t() - st2.init() - st2.set_bg_color(theme.chrome_mid()) - st2.set_bg_opa(lv.OPA.COVER) - sw.add_style(st2, lv.PART.MAIN) - theme.retain_style(st2) - # Keep the thumb compact - st_k = lv.style_t() - st_k.init() - st_k.set_bg_color(theme.text()) - st_k.set_bg_opa(lv.OPA.COVER) - st_k.set_pad_all(0) - st_k.set_width(_TRACK_H) - st_k.set_height(_TRACK_H) - sw.add_style(st_k, lv.PART.KNOB) - theme.retain_style(st_k) - - -def make_switch(parent, group=None): - sw = lv.switch(parent) - zero_pad(sw) - _style_switch(sw) - if group is not None: - lv_util.group_add(group, sw) - return sw - - -def apply_switch_theme(sw): - sw.set_style_bg_color(theme.chrome_mid(), lv.PART.MAIN) - sw.set_style_bg_color(theme.accent(), lv.PART.INDICATOR | lv.STATE.CHECKED) - sw.set_style_bg_color(theme.text(), lv.PART.KNOB) - - -def _style_slider(sl): - # Track - st_main = lv.style_t() - st_main.init() - st_main.set_bg_color(theme.chrome_lo()) - st_main.set_bg_opa(lv.OPA.COVER) - st_main.set_radius(_TRACK_H // 2) - st_main.set_height(_TRACK_H) - st_main.set_pad_all(0) - sl.add_style(st_main, lv.PART.MAIN) - theme.retain_style(st_main) - - # Filled portion - st_ind = lv.style_t() - st_ind.init() - st_ind.set_bg_color(theme.accent()) - st_ind.set_bg_opa(lv.OPA.COVER) - st_ind.set_radius(_TRACK_H // 2) - sl.add_style(st_ind, lv.PART.INDICATOR) - theme.retain_style(st_ind) - - # Knob same size as track thickness - st_knob = lv.style_t() - st_knob.init() - st_knob.set_bg_color(theme.accent_lite()) - st_knob.set_bg_opa(lv.OPA.COVER) - st_knob.set_radius(_TRACK_H // 2) - st_knob.set_pad_all(0) - st_knob.set_width(_TRACK_H) - st_knob.set_height(_TRACK_H) - st_knob.set_border_width(0) - st_knob.set_outline_width(0) - sl.add_style(st_knob, lv.PART.KNOB) - theme.retain_style(st_knob) - - -def make_slider(parent, w, group=None): - sl = lv.slider(parent) - sl.set_width(w) - sl.set_height(_TRACK_H) - sl.set_range(0, 100) - zero_pad(sl) - _style_slider(sl) - if group is not None: - lv_util.group_add(group, sl) - return sl - - -def apply_slider_theme(sl): - sl.set_style_bg_color(theme.chrome_lo(), lv.PART.MAIN) - sl.set_style_bg_color(theme.accent(), lv.PART.INDICATOR) - sl.set_style_bg_color(theme.accent_lite(), lv.PART.KNOB) - - -def make_bar(parent, w, h): - bar = lv.bar(parent) - bar.set_size(w, h) - bar.set_range(0, 100) - zero_pad(bar) - st = lv.style_t() - st.init() - st.set_bg_color(theme.chrome_lo()) - st.set_bg_opa(lv.OPA.COVER) - st.set_radius(4) - bar.add_style(st, lv.PART.MAIN) - theme.retain_style(st) - st2 = lv.style_t() - st2.init() - st2.set_bg_color(theme.accent()) - st2.set_bg_opa(lv.OPA.COVER) - st2.set_radius(4) - bar.add_style(st2, lv.PART.INDICATOR) - theme.retain_style(st2) - return bar - - -def apply_bar_theme(bar): - bar.set_style_bg_color(theme.chrome_lo(), lv.PART.MAIN) - bar.set_style_bg_color(theme.accent(), lv.PART.INDICATOR) diff --git a/lib/examples/car_cluster/screens/assist.py b/lib/examples/car_cluster/screens/assist.py deleted file mode 100644 index 766f7d8b2..000000000 --- a/lib/examples/car_cluster/screens/assist.py +++ /dev/null @@ -1,127 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Driver assist / ADAS status.""" - -import lvgl as lv - -import theme -from screens._common import ( - apply_bar_theme, - apply_button_theme, - apply_switch_theme, - content_size, - make_bar, - make_button, - make_switch, - prep_page, - section_label, -) - - -class AssistScreen: - def __init__(self, page, vehicle, group, w=0, h=0): - self.vehicle = vehicle - w, h = content_size(prep_page(page, w, h), w, h) - font = theme.pick_font(200, page) - - # 10 evenly spaced content bands (design session): 3 switches, ACC, - # SET buttons, PARKING header, 4 proximity rows. - n = 10 - y0, y_last = 36, h - 40 - step = (y_last - y0) / (n - 1) - anchors = [int(round(y0 + i * step)) for i in range(n)] - - hdr = section_label(page, "DRIVER ASSIST", 4) - hdr.align(lv.ALIGN.TOP_MID, 0, 4) - - self.switches = {} - for i, (key, label) in enumerate( - ( - ("acc", "Adaptive cruise"), - ("lane_keep", "Lane keep"), - ("blind_spot", "Blind spot"), - ) - ): - y = anchors[i] - lbl = lv.label(page) - lbl.set_text(label) - lbl.set_style_text_color(theme.text(), 0) - theme.apply_font(lbl, font) - lbl.set_pos(8, y + 8) - sw = make_switch(page, group) - sw.set_pos(w - 72, y + 4) - if vehicle.assist.get(key): - sw.add_state(lv.STATE.CHECKED) - - def _make(k, switch): - def _cb(e): - vehicle.assist[k] = bool(switch.has_state(lv.STATE.CHECKED)) - - return _cb - - sw.add_event_cb(_make(key, sw), lv.EVENT.VALUE_CHANGED, None) - self.switches[key] = sw - - self.acc_lbl = lv.label(page) - self.acc_lbl.set_style_text_color(theme.accent_lite(), 0) - theme.apply_font(self.acc_lbl, font) - self.acc_lbl.set_pos(8, anchors[3] + 4) - - minus = make_button(page, "− SET", 88, 36, group) - plus = make_button(page, "+ SET", 88, 36, group) - self.buttons = (minus, plus) - minus.set_pos(8, anchors[4]) - plus.set_pos(104, anchors[4]) - - def _dec(e): - vehicle.assist["acc_set"] = max(25, int(vehicle.assist["acc_set"]) - 5) - - def _inc(e): - vehicle.assist["acc_set"] = min(85, int(vehicle.assist["acc_set"]) + 5) - - minus.add_event_cb(_dec, lv.EVENT.CLICKED, None) - plus.add_event_cb(_inc, lv.EVENT.CLICKED, None) - - park_hdr = section_label(page, "PARKING SENSORS", anchors[5]) - park_hdr.align(lv.ALIGN.TOP_MID, 0, anchors[5]) - - self.bars = {} - for i, (key, label) in enumerate( - ( - ("park_fl", "FL"), - ("park_fr", "FR"), - ("park_rl", "RL"), - ("park_rr", "RR"), - ) - ): - y = anchors[6 + i] - lbl = lv.label(page) - lbl.set_text(label) - lbl.set_style_text_color(theme.text(), 0) - theme.apply_font(lbl, font) - lbl.set_pos(8, y + 2) - bar = make_bar(page, w - 52, 16) - bar.set_pos(44, y) - self.bars[key] = bar - - self.update() - - def update(self): - a = self.vehicle.assist - on = "ON" if a.get("acc") else "OFF" - self.acc_lbl.set_text("ACC set %d mph [%s]" % (int(a.get("acc_set", 0)), on)) - for key, bar in self.bars.items(): - prox = float(a.get(key, 0.5)) - bar.set_value(int((1.0 - prox) * 100), 0) - - def apply_theme(self): - self.acc_lbl.set_style_text_color(theme.accent_lite(), 0) - for sw in self.switches.values(): - apply_switch_theme(sw) - for btn in self.buttons: - apply_button_theme(btn) - for bar in self.bars.values(): - apply_bar_theme(bar) - - -def build(page, vehicle, group, w=0, h=0): - return AssistScreen(page, vehicle, group, w, h) diff --git a/lib/examples/car_cluster/screens/engine.py b/lib/examples/car_cluster/screens/engine.py deleted file mode 100644 index b94ad8281..000000000 --- a/lib/examples/car_cluster/screens/engine.py +++ /dev/null @@ -1,86 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Engine information screen.""" - -import lvgl as lv - -import theme -from screens._common import ( - apply_bar_theme, - apply_kv_edge_scale, - content_size, - kv_row, - make_bar, - prep_page, - spread_rows, -) - - -class EngineScreen: - def __init__(self, page, vehicle, group, w=0, h=0): - self.vehicle = vehicle - w, h = content_size(prep_page(page, w, h), w, h) - self._page_w = w - self._page = page - - keys = ( - "Coolant", - "Oil temp", - "Oil pres.", - "Battery", - "Intake air", - "Hours", - "RPM", - ) - ys = spread_rows(len(keys), top=8, bottom=56, height=h) - self.keys = [] - self.vals = [] - for lab, y in zip(keys, ys): - k, v = kv_row(page, y, lab, "--", w) - self.keys.append(k) - self.vals.append(v) - - # Design session: 2× edge scale + extra vertical nudge (label_h + 16). - try: - lv.obj.update_layout(page) - except Exception: - pass - label_h = max(1, self.keys[0].get_height()) - apply_kv_edge_scale(self.keys, self.vals, w, dy=label_h + 16, page=page) - - # No ENGINE LOAD header — keep the load bar only. - load_y = h - 44 - self.load_bar = make_bar(page, w - 24, 18) - self.load_bar.set_pos(8, load_y) - self.update() - - def update(self): - v = self.vehicle - texts = ( - "%.1f °F" % v.coolant_f, - "%.0f °F" % v.oil_temp_f, - "%.1f psi" % v.oil_psi, - "%.1f V" % v.battery_v, - "%.0f °F" % v.iat_f, - "%.1f h" % v.engine_hours, - "%d" % int(v.rpm), - ) - for lbl, text in zip(self.vals, texts): - lbl.set_text(text) - try: - lv.obj.update_layout(self._page) - except Exception: - pass - label_h = max(1, self.keys[0].get_height()) - apply_kv_edge_scale(self.keys, self.vals, self._page_w, dy=label_h + 16, page=self._page) - if v.coolant_f >= 212: - self.vals[0].set_style_text_color(theme.danger(), 0) - else: - self.vals[0].set_style_text_color(theme.text(), 0) - self.load_bar.set_value(int(v.throttle * 100), 0) - - def apply_theme(self): - apply_bar_theme(self.load_bar) - - -def build(page, vehicle, group, w=0, h=0): - return EngineScreen(page, vehicle, group, w, h) diff --git a/lib/examples/car_cluster/screens/lights.py b/lib/examples/car_cluster/screens/lights.py deleted file mode 100644 index eea2d25d4..000000000 --- a/lib/examples/car_cluster/screens/lights.py +++ /dev/null @@ -1,100 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Exterior / cabin light controls.""" - -import lvgl as lv - -import theme -from screens._common import apply_switch_theme, content_size, make_switch, prep_page, section_label - - -class LightsScreen: - def __init__(self, page, vehicle, group, w=0, h=0): - self.vehicle = vehicle - w, h = content_size(prep_page(page, w, h), w, h) - font = theme.pick_font(200, page) - - # Even spacing of 9 rows (brightness row anchors left unused). - n = 9 - y0, y_last = 32, min(436, h - 48) - step = (y_last - y0) / (n - 1) - anchors = [int(round(y0 + i * step)) for i in range(n)] - - ext = section_label(page, "EXTERIOR", 4) - ext.align(lv.ALIGN.TOP_MID, -4, 4) - - self.switches = {} - ext_rows = ( - ("autodim", "Auto dim"), - ("delayed_off", "Delayed off"), - ("drl", "Daytime running"), - ("fog", "Fog lamps"), - ("high_beam", "High beam"), - ) - for i, (key, label) in enumerate(ext_rows): - y = anchors[i] - lbl = lv.label(page) - lbl.set_text(label) - lbl.set_style_text_color(theme.text(), 0) - theme.apply_font(lbl, font) - lbl.set_pos(8, y + 8) - sw = make_switch(page, group) - sw.set_pos(w - 72, y + 4) - if vehicle.lights.get(key): - sw.add_state(lv.STATE.CHECKED) - else: - sw.remove_state(lv.STATE.CHECKED) - - def _make(k, switch): - def _cb(e): - on = switch.has_state(lv.STATE.CHECKED) - vehicle.lights[k] = bool(on) - if k == "autodim" and on: - theme.set_brightness(0.75) - elif k == "cabin" and on: - theme.set_brightness(1.0) - - return _cb - - sw.add_event_cb(_make(key, sw), lv.EVENT.VALUE_CHANGED, None) - self.switches[key] = sw - - cab = section_label(page, "CABIN", anchors[5]) - cab.align(lv.ALIGN.TOP_MID, 0, anchors[5]) - - y = anchors[6] - lbl = lv.label(page) - lbl.set_text("Cabin lights") - lbl.set_style_text_color(theme.text(), 0) - theme.apply_font(lbl, font) - lbl.set_pos(8, y + 8) - sw = make_switch(page, group) - sw.set_pos(w - 72, y + 4) - if vehicle.lights.get("cabin"): - sw.add_state(lv.STATE.CHECKED) - - def _cabin_cb(e, s=sw): - vehicle.lights["cabin"] = bool(s.has_state(lv.STATE.CHECKED)) - if vehicle.lights["cabin"]: - theme.set_brightness(1.0) - - sw.add_event_cb(_cabin_cb, lv.EVENT.VALUE_CHANGED, None) - self.switches["cabin"] = sw - - self.status = lv.label(page) - self.status.set_style_text_color(theme.accent_lite(), 0) - theme.apply_font(self.status, font) - self.status.align(lv.ALIGN.BOTTOM_LEFT, 8, -6) - self.update() - - def update(self): - lit = [k for k, on in self.vehicle.lights.items() if on and k != "brightness"] - self.status.set_text("Active: " + (", ".join(lit) if lit else "none")) - - def apply_theme(self): - for sw in self.switches.values(): - apply_switch_theme(sw) - self.status.set_style_text_color(theme.accent_lite(), 0) - - -def build(page, vehicle, group, w=0, h=0): - return LightsScreen(page, vehicle, group, w, h) diff --git a/lib/examples/car_cluster/screens/speed.py b/lib/examples/car_cluster/screens/speed.py deleted file mode 100644 index 0d3abc022..000000000 --- a/lib/examples/car_cluster/screens/speed.py +++ /dev/null @@ -1,164 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Default center screen: large speedometer + odometer.""" - -import lvgl as lv - -import gauges -import lv_util -import theme -from screens._common import apply_button_theme, content_size, make_button, prep_page - - -def _scale_label(lbl, scale): - w = max(1, lbl.get_width()) - h = max(1, lbl.get_height()) - lbl.set_style_transform_pivot_x(w // 2, 0) - lbl.set_style_transform_pivot_y(h // 2, 0) - try: - lbl.set_style_transform_scale(scale, 0) - except Exception: - lbl.set_style_transform_scale_x(scale, 0) - lbl.set_style_transform_scale_y(scale, 0) - - -class SpeedScreen: - def __init__(self, page, vehicle, group, w=0, h=0): - self.vehicle = vehicle - self.page = page - w, h = content_size(prep_page(page, w, h), w, h) - - pad = 8 - body_h = h - 72 - body_w = w - 2 * pad - - self.digital = gauges.make_digital_speed(page, body_w, int(body_h * 0.78)) - self.digital.set_pos(pad, 8) - - gauge_sz = min(body_w - 16, int(body_h * 0.78)) - self.analog = gauges.make_speed_gauge(page, gauge_sz) - self.analog.set_pos((w - gauge_sz) // 2, 8) - lv_util.hide(self.analog.widget()) - - dig = self.digital.widget() - self.digital.set_num_scale(2048) - self.digital.num.set_style_text_color(lv.color_hex(0xFFFFFF), 0) - uh = max(1, self.digital.unit.get_height()) - self.digital.unit.set_style_translate_x(0, 0) - self.digital.unit.set_style_translate_y(0, 0) - self.digital.unit.align(lv.ALIGN.BOTTOM_MID, 0, -12 - uh) - _scale_label(self.digital.unit, 768) - - self.odo = lv.label(page) - self.odo.set_style_text_color(theme.text(), 0) - theme.apply_font(self.odo, theme.pick_font(220, page)) - self.odo.set_style_pad_all(0, 0) - self.odo.align(lv.ALIGN.BOTTOM_MID, 0, -10) - - # Same parent as odo (the speed page). Positioned later via TOP_LEFT — - # sticky BOTTOM_* align + translate was pushing it off-screen. - self.gear = lv.label(page) - self.gear.set_style_text_color(theme.accent_lite(), 0) - theme.apply_font(self.gear, theme.pick_font(240, page)) - self.gear.set_style_pad_all(0, 0) - self.gear.set_text("GEAR P") - - self.flags = lv.label(page) - self.flags.set_style_text_color(theme.warn(), 0) - theme.apply_font(self.flags, theme.pick_font(180, page)) - self.flags.set_style_pad_all(0, 0) - self.flags.align(lv.ALIGN.BOTTOM_MID, 0, -10) - - self.toggle = make_button(page, "DIG / GAUGE", min(140, w // 3), 36, group) - self.toggle.align_to(dig, lv.ALIGN.OUT_BOTTOM_MID, 0, max(1, self.toggle.get_height()) // 2) - - def _toggle(e): - vehicle.toggle_speedo_mode() - self._apply_mode() - - self.toggle.add_event_cb(_toggle, lv.EVENT.CLICKED, None) - lv_util.add_flag(self.digital.widget(), lv.obj.FLAG.CLICKABLE) - self.digital.widget().add_event_cb(_toggle, lv.EVENT.CLICKED, None) - self._apply_mode() - self.update() - self._layout_odo_gear() - - def _layout_odo_gear(self): - page = self.page - self.odo.set_style_translate_x(0, 0) - self.odo.set_style_translate_y(0, 0) - self.odo.align(lv.ALIGN.BOTTOM_MID, 0, -10) - _scale_label(self.odo, 512) - self.odo.set_style_translate_x(0, 0) - - # Keep gear on the same parent as odo, place with TOP_LEFT (not sticky BOTTOM). - if self.gear.get_parent() is not self.odo.get_parent(): - try: - self.gear.set_parent(self.odo.get_parent()) - except Exception: - pass - self.gear.set_style_translate_x(0, 0) - self.gear.set_style_translate_y(0, 0) - try: - lv.obj.update_layout(page) - except Exception: - pass - gw = max(1, self.gear.get_width()) - gh = max(1, self.gear.get_height()) - btn_bot = self.toggle.get_y() + self.toggle.get_height() - gap_mid = (btn_bot + self.odo.get_y()) // 2 - ty = gap_mid - gh // 2 + int(1.5 * gh) + 8 - 24 - ty = max(btn_bot + 2, min(ty, self.odo.get_y() - gh - 2)) - tx = (page.get_width() - gw) // 2 - self.gear.align(lv.ALIGN.TOP_LEFT, tx, ty) - try: - lv.obj.update_layout(page) - except Exception: - pass - _scale_label(self.gear, 512) - try: - self.gear.move_foreground() - except Exception: - pass - - def _apply_mode(self): - digital = self.vehicle.speedo_mode == "digital" - if digital: - lv_util.show(self.digital.widget()) - lv_util.hide(self.analog.widget()) - else: - lv_util.hide(self.digital.widget()) - lv_util.show(self.analog.widget()) - - def update(self): - v = self.vehicle - mph = int(round(v.speed_mph)) - self._apply_mode() - if v.speedo_mode == "digital": - self.digital.set_value(mph) - else: - self.analog.set_value(min(125, mph)) - self.odo.set_text("%.1f mi" % float(v.odo_miles)) - self.gear.set_text("GEAR %s" % v.gear_label()) - self._layout_odo_gear() - bits = [] - if v.lights.get("high_beam"): - bits.append("HB") - if v.lights.get("turn_left"): - bits.append("<") - if v.lights.get("turn_right"): - bits.append(">") - if v.redline: - bits.append("REDLINE") - self.flags.set_text(" ".join(bits) if bits else "") - - def apply_theme(self): - self.digital.apply_theme() - self.analog.apply_theme() - self.gear.set_style_text_color(theme.accent_lite(), 0) - self.odo.set_style_text_color(theme.text(), 0) - self.flags.set_style_text_color(theme.warn(), 0) - apply_button_theme(self.toggle) - - -def build(page, vehicle, group, w=0, h=0): - return SpeedScreen(page, vehicle, group, w, h) diff --git a/lib/examples/car_cluster/screens/theme_screen.py b/lib/examples/car_cluster/screens/theme_screen.py deleted file mode 100644 index 2bc9b2a9f..000000000 --- a/lib/examples/car_cluster/screens/theme_screen.py +++ /dev/null @@ -1,150 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Color scheme, chrome shininess, and brightness.""" - -import lvgl as lv - -import theme -from screens._common import ( - apply_button_theme, - apply_slider_theme, - content_size, - make_button, - make_slider, - prep_page, - section_label, - zero_pad, -) - - -def _scale_2x(lbl): - w = max(1, lbl.get_width()) - h = max(1, lbl.get_height()) - lbl.set_style_transform_pivot_x(w // 2, 0) - lbl.set_style_transform_pivot_y(h // 2, 0) - try: - lbl.set_style_transform_scale(512, 0) - except Exception: - lbl.set_style_transform_scale_x(512, 0) - lbl.set_style_transform_scale_y(512, 0) - - -def _center_label(lbl, y): - """Horizontally center a label at ``y`` with no translate/x offset.""" - parent = lbl.get_parent() - try: - lv.obj.update_layout(parent) - except Exception: - pass - lbl.set_style_translate_x(0, 0) - lbl.set_style_translate_y(0, 0) - # Clear any sticky align/pos (section_label starts at x=4). - pw = max(1, parent.get_width()) - lw = max(1, lbl.get_width()) - lbl.set_pos((pw - lw) // 2, y) - _scale_2x(lbl) - # Re-measure after scale pivot setup; keep layout center (scale is visual). - lw = max(1, lbl.get_width()) - lbl.set_pos((pw - lw) // 2, y) - lbl.set_style_translate_x(0, 0) - - -class ThemeScreen: - def __init__(self, page, vehicle, group, w=0, h=0): - self.vehicle = vehicle - w, h = content_size(prep_page(page, w, h), w, h) - self._sliders = [] - self._label_ys = [] - self._buttons = [] - - # 6 evenly spaced bands: scheme name, PREV/NEXT, chrome header+slider, - # brightness header+slider. - n = 6 - y0, y_last = 36, h - 40 - step = (y_last - y0) / (n - 1) - anchors = [int(round(y0 + i * step)) for i in range(n)] - hdr = section_label(page, "COLOR SCHEME", 12, accent=True) - _center_label(hdr, 12) - self._labels = [hdr] - self._label_ys = [12] - - self.scheme_lbl = lv.label(page) - self.scheme_lbl.set_style_text_color(theme.accent_lite(), 0) - theme.apply_font(self.scheme_lbl, theme.pick_font(240, page)) - zero_pad(self.scheme_lbl) - self._scheme_y = anchors[0] + 8 - _center_label(self.scheme_lbl, self._scheme_y) - self._labels.append(self.scheme_lbl) - self._label_ys.append(self._scheme_y) - - prev_b = make_button(page, "PREV", 96, 38, group) - next_b = make_button(page, "NEXT", 96, 38, group) - self._buttons.extend((prev_b, next_b)) - btn_w, btn_gap = 96, 8 - x0 = (w - (btn_w * 2 + btn_gap)) // 2 - prev_b.set_pos(x0, anchors[1]) - next_b.set_pos(x0 + btn_w + btn_gap, anchors[1]) - - def _prev(e): - theme.set_scheme(theme.scheme_index() - 1) - - def _next(e): - theme.set_scheme(theme.scheme_index() + 1) - - prev_b.add_event_cb(_prev, lv.EVENT.CLICKED, None) - next_b.add_event_cb(_next, lv.EVENT.CLICKED, None) - - controls = ( - ("Chrome shininess", theme.shininess, theme.set_shininess), - ("Brightness", theme.brightness, theme.set_brightness), - ) - for i, (label, getter, setter) in enumerate(controls): - ay_hdr = anchors[2 + i * 2] - ay_sl = anchors[3 + i * 2] - sec = section_label(page, label.upper(), ay_hdr) - # Nudge headers down 2× their height so they sit closer to their sliders. - try: - lv.obj.update_layout(page) - except Exception: - pass - hy = ay_hdr + 2 * max(1, sec.get_height()) - _center_label(sec, hy) - self._labels.append(sec) - self._label_ys.append(hy) - sl = make_slider(page, w - 24, group) - sl.set_pos(8, ay_sl) - sl.set_range(0, 100) - sl.set_value(int(getter() * 100), 0) - self._sliders.append(sl) - - def _bind(slider, setter_fn): - def _cb(e): - setter_fn(slider.get_value() / 100.0) - - return _cb - - sl.add_event_cb(_bind(sl, setter), lv.EVENT.VALUE_CHANGED, None) - - self.apply_theme() - - def apply_theme(self): - self.scheme_lbl.set_text( - "%s (%d/%d)" % (theme.scheme_name(), theme.scheme_index() + 1, theme.scheme_count()) - ) - for lbl, y in zip(self._labels, self._label_ys): - lbl.set_style_text_color(theme.accent_lite(), 0) - _center_label(lbl, y) - for btn in self._buttons: - apply_button_theme(btn) - for sl in self._sliders: - apply_slider_theme(sl) - - def update(self): - # Scheme label only — avoid restyling all sliders every vehicle tick. - self.scheme_lbl.set_text( - "%s (%d/%d)" % (theme.scheme_name(), theme.scheme_index() + 1, theme.scheme_count()) - ) - _center_label(self.scheme_lbl, self._scheme_y) - - -def build(page, vehicle, group, w=0, h=0): - return ThemeScreen(page, vehicle, group, w, h) diff --git a/lib/examples/car_cluster/screens/tires.py b/lib/examples/car_cluster/screens/tires.py deleted file mode 100644 index f7a0e73dd..000000000 --- a/lib/examples/car_cluster/screens/tires.py +++ /dev/null @@ -1,161 +0,0 @@ -# SPDX-License-Identifier: MIT -"""TPMS tire pressure / temperature.""" - -import lvgl as lv - -import theme -from screens._common import content_size, prep_page, zero_pad - - -def _scale_2x(lbl): - w = max(1, lbl.get_width()) - h = max(1, lbl.get_height()) - lbl.set_style_transform_pivot_x(w // 2, 0) - lbl.set_style_transform_pivot_y(h // 2, 0) - try: - lbl.set_style_transform_scale(512, 0) - except Exception: - lbl.set_style_transform_scale_x(512, 0) - lbl.set_style_transform_scale_y(512, 0) - - -def _scale_2x_bottom_right(lbl): - """2× scale with BR pivot so growth stays inside the card.""" - w = max(1, lbl.get_width()) - h = max(1, lbl.get_height()) - lbl.set_style_transform_pivot_x(w, 0) - lbl.set_style_transform_pivot_y(h, 0) - try: - lbl.set_style_transform_scale(512, 0) - except Exception: - lbl.set_style_transform_scale_x(512, 0) - lbl.set_style_transform_scale_y(512, 0) - - -def _scale_2x_top_left(lbl): - """2× scale with TL pivot — opposite of bottom-right temps.""" - lbl.set_style_transform_pivot_x(0, 0) - lbl.set_style_transform_pivot_y(0, 0) - try: - lbl.set_style_transform_scale(512, 0) - except Exception: - lbl.set_style_transform_scale_x(512, 0) - lbl.set_style_transform_scale_y(512, 0) - - -class TiresScreen: - def __init__(self, page, vehicle, group, w=0, h=0): - self.vehicle = vehicle - w, h = content_size(prep_page(page, w, h), w, h) - - self.cards = [] - font = theme.pick_font(220, page) - font_sm = theme.pick_font(180, page) - gap = 8 - spare_h = 44 - cw = (w - gap * 3) // 2 - ch = (h - gap * 3 - spare_h) // 2 - positions = ( - (gap, gap), - (gap + cw + gap, gap), - (gap, gap + ch + gap), - (gap + cw + gap, gap + ch + gap), - ) - for i, tire in enumerate(vehicle.tires[:4]): - x, y = positions[i] - card = lv.obj(page) - card.set_size(cw, ch) - card.set_pos(x, y) - zero_pad(card) - theme.style_bg( - card, theme.panel_raised(), radius=10, border_w=1, border_color=theme.accent_dim() - ) - zero_pad(card) - try: - card.add_flag(lv.obj.FLAG.OVERFLOW_VISIBLE) - except Exception: - pass - name = lv.label(card) - name.set_text(tire["name"]) - name.set_style_text_color(theme.accent_lite(), 0) - theme.apply_font(name, font_sm) - zero_pad(name) - name.align(lv.ALIGN.TOP_LEFT, 8, 8) - psi = lv.label(card) - psi.set_text("") - psi.set_style_text_color(theme.text(), 0) - theme.apply_font(psi, font) - zero_pad(psi) - psi.align(lv.ALIGN.CENTER, 0, -4) - temp = lv.label(card) - temp.set_text("") - temp.set_style_text_color(theme.text_dim(), 0) - theme.apply_font(temp, font_sm) - zero_pad(temp) - # Bottom-right of card, inset a little up and left. - temp.align(lv.ALIGN.BOTTOM_RIGHT, -8, -8) - self.cards.append((card, name, psi, temp)) - - # Spare: single compact row — no large centered psi that looks like a stray - spare = vehicle.tires[4] - card = lv.obj(page) - card.set_size(w - 2 * gap, spare_h) - card.set_pos(gap, h - spare_h - gap) - zero_pad(card) - theme.style_bg(card, theme.panel(), radius=8, border_w=1, border_color=theme.chrome_mid()) - zero_pad(card) - - name = lv.label(card) - name.set_text(spare["name"]) - name.set_style_text_color(theme.text_dim(), 0) - theme.apply_font(name, font_sm) - zero_pad(name) - name.align(lv.ALIGN.LEFT_MID, 12, 0) - - psi = lv.label(card) - psi.set_text("") - psi.set_style_text_color(theme.text(), 0) - theme.apply_font(psi, font_sm) - zero_pad(psi) - psi.align(lv.ALIGN.CENTER, 0, 0) - - temp = lv.label(card) - temp.set_text("") - temp.set_style_text_color(theme.text_dim(), 0) - theme.apply_font(temp, font_sm) - zero_pad(temp) - temp.align(lv.ALIGN.RIGHT_MID, -12, 0) - self.cards.append((card, name, psi, temp)) - self.update() - - def update(self): - for i, (tire, entry) in enumerate(zip(self.vehicle.tires, self.cards)): - _card, name_lbl, psi_lbl, temp_lbl = entry - psi = tire["psi"] - psi_lbl.set_text("%.1f psi" % psi) - temp_lbl.set_text("%.0f °F" % tire["temp_f"]) - if psi < 30 or psi > 42: - psi_lbl.set_style_text_color(theme.danger(), 0) - elif psi < 32 or psi > 38: - psi_lbl.set_style_text_color(theme.warn(), 0) - else: - psi_lbl.set_style_text_color(theme.ok(), 0) - # Corner cards: keep all three lines at 2× after text changes. - if i < 4: - name_lbl.align(lv.ALIGN.TOP_LEFT, 8, 8) - temp_lbl.align(lv.ALIGN.BOTTOM_RIGHT, -8, -8) - _scale_2x_top_left(name_lbl) - _scale_2x(psi_lbl) - _scale_2x_bottom_right(temp_lbl) - - def apply_theme(self): - for i, (card, name_lbl, psi_lbl, temp_lbl) in enumerate(self.cards): - card.set_style_bg_color(theme.panel_raised() if i < 4 else theme.panel(), 0) - card.set_style_border_color(theme.accent_dim() if i < 4 else theme.chrome_mid(), 0) - name_lbl.set_style_text_color(theme.accent_lite() if i < 4 else theme.text_dim(), 0) - temp_lbl.set_style_text_color(theme.text_dim(), 0) - self.update() - - -def build(page, vehicle, group, w=0, h=0): - return TiresScreen(page, vehicle, group, w, h) diff --git a/lib/examples/car_cluster/screens/trip_a.py b/lib/examples/car_cluster/screens/trip_a.py deleted file mode 100644 index bfbfcbc62..000000000 --- a/lib/examples/car_cluster/screens/trip_a.py +++ /dev/null @@ -1,84 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Trip computer A.""" - -import lvgl as lv - -import theme -from screens._common import ( - apply_button_theme, - apply_kv_edge_scale, - content_size, - kv_row, - make_button, - prep_page, - scale_2x, - spread_rows, -) - - -class TripScreen: - which = "a" - title = "TRIP A" - - def __init__(self, page, vehicle, group, w=0, h=0): - self.vehicle = vehicle - w, h = content_size(prep_page(page, w, h), w, h) - - labels = ("Distance", "Avg speed", "Avg MPG", "Elapsed", "Max speed") - ys = spread_rows(len(labels), top=12, bottom=52, height=h) - self.keys = [] - self.rows = [] - for lab, y in zip(labels, ys): - k, v = kv_row(page, y, lab, "--", w) - self.keys.append(k) - self.rows.append(v) - - # Design session: 2× edge-pivoted rows + pad/dy nudges; RESET 2×. - apply_kv_edge_scale(self.keys, self.rows, w, page=page) - self.reset = make_button(page, "RESET TRIP", min(180, w - 24), 40, group) - self.reset.align(lv.ALIGN.BOTTOM_MID, 0, -8) - try: - lv.obj.update_layout(self.reset) - lbl = self.reset.get_child(0) - lbl.align(lv.ALIGN.CENTER, 0, 0) - scale_2x(lbl) - # Re-center after transform so the scaled label sits mid H+V. - lbl.align(lv.ALIGN.CENTER, 0, 0) - lbl.set_style_translate_x(0, 0) - lbl.set_style_translate_y(0, 0) - except Exception: - pass - - which = self.which - - def _reset(e): - vehicle.reset_trip(which) - - self.reset.add_event_cb(_reset, lv.EVENT.CLICKED, None) - self._page = page - self._page_w = w - self.update() - - def update(self): - v = self.vehicle - trip = v.trip_a if self.which == "a" else v.trip_b - mins = int(trip["time_s"] // 60) - secs = int(trip["time_s"] % 60) - vals = ( - "%.1f mi" % trip["distance"], - "%.1f mph" % v.trip_avg_speed(self.which), - "%.1f" % v.trip_mpg(self.which), - "%d:%02d" % (mins, secs), - "%.0f mph" % trip["max_speed"], - ) - for lbl, text in zip(self.rows, vals): - lbl.set_text(text) - # Re-apply right-edge pivots after value width changes. - apply_kv_edge_scale(self.keys, self.rows, self._page_w, page=self._page) - - def apply_theme(self): - apply_button_theme(self.reset) - - -def build(page, vehicle, group, w=0, h=0): - return TripScreen(page, vehicle, group, w, h) diff --git a/lib/examples/car_cluster/screens/trip_b.py b/lib/examples/car_cluster/screens/trip_b.py deleted file mode 100644 index f71ce464f..000000000 --- a/lib/examples/car_cluster/screens/trip_b.py +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Trip computer B.""" - -from screens.trip_a import TripScreen - - -class TripBScreen(TripScreen): - which = "b" - title = "TRIP B" - - -def build(page, vehicle, group, w=0, h=0): - return TripBScreen(page, vehicle, group, w, h) diff --git a/lib/examples/car_cluster/theme.py b/lib/examples/car_cluster/theme.py deleted file mode 100644 index a91633c71..000000000 --- a/lib/examples/car_cluster/theme.py +++ /dev/null @@ -1,320 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Material accent schemes, chrome shininess, and shared style retention.""" - -import lvgl as lv - -_styles = [] -_listeners = [] - -# Named schemes: Material palette accents for the cluster. -SCHEMES = ( - {"name": "Cyan", "accent": lv.PALETTE.CYAN, "secondary": lv.PALETTE.BLUE_GREY}, - {"name": "Amber", "accent": lv.PALETTE.AMBER, "secondary": lv.PALETTE.ORANGE}, - {"name": "Indigo", "accent": lv.PALETTE.INDIGO, "secondary": lv.PALETTE.DEEP_PURPLE}, - {"name": "Teal", "accent": lv.PALETTE.TEAL, "secondary": lv.PALETTE.GREEN}, - {"name": "Deep Orange", "accent": lv.PALETTE.DEEP_ORANGE, "secondary": lv.PALETTE.RED}, - {"name": "Light Blue", "accent": lv.PALETTE.LIGHT_BLUE, "secondary": lv.PALETTE.BLUE}, -) - -_scheme_index = 5 # Light Blue -_shininess = 0.5 -_gauge_scale = 1.0 -_brightness = 0.92 - - -def retain_style(style): - _styles.append(style) - return style - - -def on_change(cb): - if cb not in _listeners: - _listeners.append(cb) - - -def notify(): - for cb in _listeners: - try: - cb() - except Exception: - pass - - -def scheme_name(): - return SCHEMES[_scheme_index]["name"] - - -def scheme_count(): - return len(SCHEMES) - - -def set_scheme(index): - global _scheme_index - n = len(SCHEMES) - _scheme_index = int(index) % n - notify() - - -def set_shininess(value): - global _shininess - if value < 0.0: - value = 0.0 - if value > 1.0: - value = 1.0 - _shininess = value - notify() - - -def set_gauge_scale(value): - global _gauge_scale - if value < 0.85: - value = 0.85 - if value > 1.15: - value = 1.15 - _gauge_scale = value - notify() - - -def set_brightness(value): - global _brightness - if value < 0.3: - value = 0.3 - if value > 1.0: - value = 1.0 - _brightness = value - notify() - - -def shininess(): - return _shininess - - -def gauge_scale(): - return _gauge_scale - - -def brightness(): - return _brightness - - -def scheme_index(): - return _scheme_index - - -def _hex(rgb): - return lv.color_hex(rgb & 0xFFFFFF) - - -def _blend_toward(rgb, target, amount): - r = (rgb >> 16) & 0xFF - g = (rgb >> 8) & 0xFF - b = rgb & 0xFF - tr = (target >> 16) & 0xFF - tg = (target >> 8) & 0xFF - tb = target & 0xFF - r = int(r + (tr - r) * amount) - g = int(g + (tg - g) * amount) - b = int(b + (tb - b) * amount) - return (r << 16) | (g << 8) | b - - -def _dim_rgb(rgb, factor): - r = int(((rgb >> 16) & 0xFF) * factor) - g = int(((rgb >> 8) & 0xFF) * factor) - b = int((rgb & 0xFF) * factor) - if r > 255: - r = 255 - if g > 255: - g = 255 - if b > 255: - b = 255 - return (r << 16) | (g << 8) | b - - -def _accent_palette(): - return SCHEMES[_scheme_index]["accent"] - - -def _secondary_palette(): - return SCHEMES[_scheme_index]["secondary"] - - -def secondary(): - return lv.palette_main(_secondary_palette()) - - -def secondary_dim(): - return lv.palette_darken(_secondary_palette(), 1) - - -def bg(): - # Slightly lifted from near-black so chrome/bezels read against it. - return _hex(_dim_rgb(0x14161C, _brightness)) - - -def panel(): - return _hex(_dim_rgb(0x1A1E28, _brightness)) - - -def panel_raised(): - return _hex(_dim_rgb(0x222833, _brightness)) - - -def chrome_hi(): - # Bright rim — shininess pushes toward polished silver. - base = _blend_toward(0x4A5160, 0xE8EEF5, _shininess * 0.95) - return _hex(_dim_rgb(base, _brightness)) - - -def chrome_mid(): - base = _blend_toward(0x2A303C, 0xA8B2C0, _shininess * 0.85) - return _hex(_dim_rgb(base, _brightness)) - - -def chrome_lo(): - base = _blend_toward(0x0C0E12, 0x3A4250, _shininess * 0.55) - return _hex(_dim_rgb(base, _brightness)) - - -def chrome_specular(): - """Near-white highlight for metallic top edges.""" - base = _blend_toward(0x6A7384, 0xFFFFFF, min(1.0, _shininess * 1.05)) - return _hex(_dim_rgb(base, _brightness)) - - -def accent(): - return lv.palette_main(_accent_palette()) - - -def accent_dim(): - return lv.palette_darken(_accent_palette(), 2) - - -def accent_lite(): - return lv.palette_lighten(_accent_palette(), 1) - - -def danger(): - return lv.palette_main(lv.PALETTE.RED) - - -def warn(): - return lv.palette_main(lv.PALETTE.AMBER) - - -def ok(): - return lv.palette_main(lv.PALETTE.GREEN) - - -def text(): - return _hex(_dim_rgb(0xF2F4F8, _brightness)) - - -def text_dim(): - return _hex(_dim_rgb(0x8A93A3, _brightness)) - - -def tick(): - return _hex(_dim_rgb(0xB8C0CC, _brightness)) - - -def needle(): - return accent_lite() - - -def face(): - return _hex(_dim_rgb(0x0E1016, _brightness)) - - -def pick_font(unit, ref_obj=None): - if unit >= 520: - candidates = (28, 24, 16, 14) - elif unit >= 360: - candidates = (24, 16, 14) - elif unit >= 200: - candidates = (18, 16, 14) - else: - candidates = (16, 14, 24) - for size in candidates: - font = getattr(lv, "font_montserrat_" + str(size), None) - if font is not None: - return font - if ref_obj is not None: - for getter in ("theme_get_font_large", "theme_get_font_normal", "theme_get_font_small"): - fn = getattr(lv, getter, None) - if fn is None: - continue - try: - font = fn(ref_obj) - if font is not None: - return font - except Exception: - pass - fn = getattr(lv, "font_get_default", None) - if fn is not None: - try: - return fn() - except Exception: - pass - return None - - -def apply_font(obj, font): - if font is not None: - obj.set_style_text_font(font, 0) - - -def style_bg(obj, color, radius=0, border_w=0, border_color=None): - style = lv.style_t() - style.init() - style.set_bg_color(color) - style.set_bg_opa(lv.OPA.COVER) - style.set_radius(radius) - style.set_border_width(border_w) - if border_color is not None and border_w: - style.set_border_color(border_color) - style.set_border_opa(lv.OPA.COVER) - style.set_pad_all(0) - obj.add_style(style, 0) - return retain_style(style) - - -def style_chrome( - obj, - *, - radius=0, - border_w=1, - border_color=None, - vertical=True, - highlight=False, -): - """Metallic fill: bright→dark gradient, optional specular top.""" - style = lv.style_t() - style.init() - hi = chrome_specular() if highlight else chrome_hi() - lo = chrome_lo() - style.set_bg_color(hi) - style.set_bg_grad_color(lo) - style.set_bg_grad_dir(lv.GRAD_DIR.VER if vertical else lv.GRAD_DIR.HOR) - style.set_bg_opa(lv.OPA.COVER) - style.set_radius(radius) - style.set_border_width(border_w) - if border_w: - style.set_border_color(border_color if border_color is not None else chrome_mid()) - style.set_border_opa(lv.OPA.COVER) - style.set_pad_all(0) - obj.add_style(style, 0) - return retain_style(style) - - -def apply_chrome_local(obj, *, highlight=False, vertical=True, border_color=None): - """Update chrome colors in place (theme / shininess changes).""" - hi = chrome_specular() if highlight else chrome_hi() - obj.set_style_bg_color(hi, 0) - obj.set_style_bg_grad_color(chrome_lo(), 0) - obj.set_style_bg_grad_dir(lv.GRAD_DIR.VER if vertical else lv.GRAD_DIR.HOR, 0) - obj.set_style_bg_opa(lv.OPA.COVER, 0) - if border_color is not None: - obj.set_style_border_color(border_color, 0) - else: - obj.set_style_border_color(chrome_mid(), 0) diff --git a/lib/examples/car_cluster/vehicle.py b/lib/examples/car_cluster/vehicle.py index 8eee753e8..a9d3277ff 100644 --- a/lib/examples/car_cluster/vehicle.py +++ b/lib/examples/car_cluster/vehicle.py @@ -1,236 +1,132 @@ # SPDX-License-Identifier: MIT -"""Believable vehicle state for the instrument cluster demo. - -Drive state is automatic: a looping profile varies gear, speed, RPM, and oil -pressure over time. Digit keys are not used. Speed tracks roughly 10× gear. +"""An electric car for the cluster to show. + +The car drives itself round a 90 second loop: out of a parking space in +reverse, into town, a fast stretch, an exit with the indicator on, back to a +stop. Speed follows the loop's targets with believable acceleration, motor +power comes from the physics of pushing the car along, and regenerative +braking shows as negative power. The odometer and the efficiency figure +follow from those. + +``tick(dt)`` advances the car by ``dt`` seconds. Everything the cluster +draws is an attribute, in the units an American car shows: ``speed_mph``, +``power_kw``, ``gear``, ``odometer_mi``, ``mi_per_kwh``, ``left``, ``right``. +The physics underneath is metric. """ import math -_IDLE_RPM = 1000 -_MAX_ACCEL_RPM = 7000 -_REDLINE = 6500 -_CYCLE_S = 72.0 +_CYCLE_S = 90.0 -# Piecewise drive profile: (end_time_s, gear, throttle). -# Speed target is ~gear × 10 mph. Throttle drives RPM and accel feel. +# (end of the segment in seconds, gear, target speed in mph, left, right) _PROFILE = ( - (4.0, 0, 0.0), # park / idle - (8.0, 1, 0.05), # creep - (16.0, 3, 0.75), # pull away - (24.0, 5, 0.55), # build to ~50 - (34.0, 6, 0.35), # cruise ~60 - (42.0, 8, 1.0), # hard accel → ~7k RPM - (50.0, 7, 0.40), # settle ~70 - (60.0, 3, 0.0), # coast / brake - (66.0, 1, 0.0), # idle in gear - (72.0, 0, 0.0), # park + (3.0, "P", 0, 0, 0), + (7.0, "R", 4, 0, 0), + (9.0, "N", 0, 0, 0), + (17.0, "D", 28, 1, 0), + (28.0, "D", 37, 0, 0), + (38.0, "S", 75, 0, 0), + (48.0, "S", 86, 0, 0), + (58.0, "D", 50, 0, 1), + (70.0, "D", 19, 0, 0), + (78.0, "D", 0, 0, 0), + (84.0, "P", 0, 0, 0), + (90.0, "P", 0, 0, 0), ) +_MPH = 2.236936 # m/s to mph +_KM_PER_MI = 1.609344 -def _clamp(v, lo, hi): - if v < lo: - return lo - if v > hi: - return hi - return v +_MASS_KG = 1850.0 +_DRAG = 0.5 * 1.2 * 0.62 # half air density times drag area +_ROLLING = 0.011 * _MASS_KG * 9.81 +_ACCEL = 2.6 # m/s^2, flat out +_BRAKE = 4.2 +_MAX_KW = 200.0 +_MAX_REGEN_KW = 70.0 -def _lerp(a, b, t): - return a + (b - a) * t +def _clamp(v, lo, hi): + return lo if v < lo else hi if v > hi else v -def _profile_at(t): - """Return (gear, throttle) for absolute time ``t`` (seconds).""" +def _segment(t): u = t % _CYCLE_S - prev_t, prev_g, prev_thr = 0.0, 0, 0.0 - for end_t, gear, thr in _PROFILE: - if u <= end_t: - span = end_t - prev_t - if span <= 1e-6: - return gear, thr - f = (u - prev_t) / span - # Hold gear as integer steps; blend throttle smoothly. - g = gear if f >= 0.35 else prev_g - if prev_g == 0 and gear > 0 and f < 0.15: - g = 0 - return int(g), _lerp(prev_thr, thr, f) - prev_t, prev_g, prev_thr = end_t, gear, thr - return 0, 0.0 + for end, gear, target, left, right in _PROFILE: + if u <= end: + return gear, target, left, right + return "P", 0, 0, 0 class Vehicle: def __init__(self): - self.gear = 0 # 0 = Park, 1..10 - self.throttle = 0.0 + self.t = 0.0 self.speed_mph = 0.0 - self.rpm = float(_IDLE_RPM) - self.fuel_frac = 0.625 # fixed ~5/8 tank - self.coolant_f = 210.6 # fixed - self.oil_psi = 22.0 - self.oil_temp_f = 205.0 - self.battery_v = 14.2 - self.iat_f = 95.0 - self.engine_hours = 128.4 - self.odo_miles = 48217.6 - self.speedo_mode = "digital" # or "analog" - self.trip_a = self._new_trip() - self.trip_b = self._new_trip() - self.lights = { - "autodim": True, - "delayed_off": True, - "drl": True, - "fog": False, - "cabin": False, - "brightness": 0.85, - "high_beam": False, - "turn_left": False, - "turn_right": False, - } - self.tires = [ - {"name": "FL", "psi": 34.0, "temp_f": 86.0}, - {"name": "FR", "psi": 34.5, "temp_f": 87.0}, - {"name": "RL", "psi": 33.0, "temp_f": 90.0}, - {"name": "RR", "psi": 33.5, "temp_f": 89.0}, - {"name": "Spare", "psi": 40.0, "temp_f": 72.0}, - ] - self.assist = { - "acc": True, - "acc_set": 65, - "lane_keep": True, - "blind_spot": True, - "park_fl": 0.15, - "park_fr": 0.18, - "park_rl": 0.55, - "park_rr": 0.52, - } - self.redline = False - self._t = 0.0 - self._accel = 0.0 # mph/s, for oil response - - def _new_trip(self): - return { - "distance": 0.0, - "time_s": 0.0, - "fuel_used": 0.0, - "max_speed": 0.0, - } - - def gear_label(self): - if self.gear <= 0: - return "P" - return str(int(self.gear)) - - def set_gear(self, g): - self.gear = int(_clamp(g, 0, 10)) - - def set_throttle(self, t): - self.throttle = _clamp(float(t), 0.0, 1.0) - - def toggle_speedo_mode(self): - self.speedo_mode = "analog" if self.speedo_mode == "digital" else "digital" - - def reset_trip(self, which): - if which == "a": - self.trip_a = self._new_trip() - elif which == "b": - self.trip_b = self._new_trip() - - def trip_avg_speed(self, which): - trip = self.trip_a if which == "a" else self.trip_b - if trip["time_s"] < 1.0: - return 0.0 - return trip["distance"] / (trip["time_s"] / 3600.0) - - def trip_mpg(self, which): - trip = self.trip_a if which == "a" else self.trip_b - if trip["fuel_used"] < 0.01: - return 0.0 - return trip["distance"] / trip["fuel_used"] - - def tick(self, dt_s): - if dt_s <= 0: + self.power_kw = 0.0 + self.gear = "P" + self.odometer_mi = 13488.0 + self.mi_per_kwh = 3.7 + self.left = False + self.right = False + self.running = True + # A press of the left or right arrow holds that indicator on until + # the next press; the drive loop's own signals are ignored meanwhile. + self.manual_signal = None + + def toggle_signal(self, side): + self.manual_signal = None if self.manual_signal == side else side + + def tick(self, dt): + if not self.running or dt <= 0: return - if dt_s > 0.2: - dt_s = 0.2 - - self._t += dt_s - gear_tgt, thr_tgt = _profile_at(self._t) - self.gear = int(gear_tgt) - self.throttle = _lerp(self.throttle, thr_tgt, min(1.0, dt_s * 3.0)) - - # Speed tracks roughly 10 × gear. - if self.gear <= 0: - target_speed = 0.0 - else: - target_speed = float(self.gear * 10) - - prev_speed = self.speed_mph - # Accelerate a bit faster than we decelerate so pulls feel eager. - speed_rate = 2.8 if target_speed > self.speed_mph else 1.8 - self.speed_mph = _lerp(self.speed_mph, target_speed, min(1.0, dt_s * speed_rate)) - if self.speed_mph < 0.2: - self.speed_mph = 0.0 - self._accel = (self.speed_mph - prev_speed) / dt_s - - # RPM: 1k idle → 7k at strongest acceleration. - idle = self.gear <= 0 or (self.speed_mph < 1.5 and self.throttle < 0.08) - if idle: - target_rpm = float(_IDLE_RPM) + self.t += dt + gear, target, left, right = _segment(self.t) + self.gear = gear + if self.manual_signal is None: + self.left = bool(left) + self.right = bool(right) else: - accel_n = _clamp(self._accel / 10.0, 0.0, 1.0) - # Hard throttle and rising speed push toward redline; cruise is calmer. - demand = _clamp(max(self.throttle, accel_n * 0.9), 0.0, 1.0) - if self.throttle < 0.12 and self._accel <= 0.5: - demand = _clamp(0.12 + self.speed_mph / 120.0, 0.12, 0.35) - target_rpm = _IDLE_RPM + (_MAX_ACCEL_RPM - _IDLE_RPM) * demand - - self.rpm = _lerp(self.rpm, target_rpm, min(1.0, dt_s * 4.5)) - self.redline = self.rpm >= _REDLINE - - # Oil pressure: 15–30 idle, 35–65 driving; rises on accel, falls on decel. - if idle: - # Mild idle wander with a quiet sine so the needle isn't frozen. - wobble = 0.5 + 0.5 * math.sin(self._t * 0.7) - target_oil = 18.0 + 10.0 * wobble # ~18..28 within 15–30 + self.left = self.manual_signal == "left" + self.right = self.manual_signal == "right" + + v = self.speed_mph / _MPH + want = target / _MPH + if want > v: + # Pull harder from a standstill, ease off near the target. + a = _ACCEL * _clamp((want - v) / 8.0 + 0.25, 0.3, 1.0) + v = min(want, v + a * dt) + elif want < v: + a = _BRAKE * _clamp((v - want) / 10.0 + 0.2, 0.25, 1.0) + v = max(want, v - a * dt) + a = -a else: - drive = _clamp(self.speed_mph / 80.0, 0.0, 1.0) - target_oil = 40.0 + 15.0 * drive # ~40..55 baseline while moving - accel_n = _clamp(self._accel / 10.0, -1.0, 1.0) - target_oil += accel_n * 12.0 - target_oil = _clamp(target_oil, 35.0, 65.0) - oil_rate = 3.5 if abs(self._accel) > 1.0 else 2.0 - self.oil_psi = _lerp(self.oil_psi, target_oil, min(1.0, dt_s * oil_rate)) - if idle: - self.oil_psi = _clamp(self.oil_psi, 15.0, 30.0) + a = 0.0 + if v < 0.05: + v = 0.0 + + # Power to move the car: inertia plus drag plus rolling resistance. + force = _MASS_KG * a + _DRAG * v * v + _ROLLING * (1.0 if v > 0 else 0.0) + kw = force * v / 1000.0 + if kw < 0: + kw = max(-_MAX_REGEN_KW, kw * 0.7) else: - self.oil_psi = _clamp(self.oil_psi, 35.0, 65.0) - - # Fuel + coolant stay fixed. Ancillary temps still breathe a little. - load = _clamp(self.throttle * 0.7 + (self.rpm / 10000.0) * 0.3, 0.0, 1.0) - self.oil_temp_f = _lerp(self.oil_temp_f, 190.0 + load * 30.0, min(1.0, dt_s * 0.1)) - self.iat_f = _lerp(self.iat_f, 85.0 + load * 25.0, min(1.0, dt_s * 0.08)) - self.battery_v = _lerp( - self.battery_v, 13.8 + (0.6 if self.rpm > 500 else 0.0), min(1.0, dt_s) - ) - - dist = self.speed_mph * (dt_s / 3600.0) - self.odo_miles += dist - if self.rpm > 100: - self.engine_hours += dt_s / 3600.0 - - # Trip fuel_used for MPG only — does not drain the fuel gauge. - burn = (0.000005 + self.throttle * 0.00004 + self.speed_mph * 0.00000015) * dt_s - for trip in (self.trip_a, self.trip_b): - if self.speed_mph > 0.5: - trip["distance"] += dist - trip["time_s"] += dt_s - trip["fuel_used"] += burn * 12.0 - if self.speed_mph > trip["max_speed"]: - trip["max_speed"] = self.speed_mph - - for tire in self.tires: - if tire["name"] == "Spare": - continue - target_t = 75.0 + self.speed_mph * 0.25 - tire["temp_f"] = _lerp(tire["temp_f"], target_t, min(1.0, dt_s * 0.05)) + kw = min(_MAX_KW, kw / 0.9) + if v == 0.0: + kw = 0.0 + # The motor can't change its mind instantly. + self.power_kw += (kw - self.power_kw) * _clamp(dt * 6.0, 0.0, 1.0) + + self.speed_mph = v * _MPH + self.odometer_mi += v * dt / 1000.0 / _KM_PER_MI + # Efficiency: miles per kWh at the motor's draw right now, averaged + # over the last half minute or so, so a hard pull shows but doesn't + # own the number, and regen brings it back up. + if self.speed_mph > 6.0: + wh_per_mi = _clamp(self.power_kw * 1000.0 / self.speed_mph, -500.0, 1500.0) + now = 1000.0 / wh_per_mi if wh_per_mi > 100.0 else 9.9 + self.mi_per_kwh += (now - self.mi_per_kwh) * _clamp(dt / 20.0, 0.0, 1.0) + self.mi_per_kwh = _clamp(self.mi_per_kwh, 1.2, 6.5) + + def blink_on(self): + """Indicator lamps blink at 90 per minute, on for the first half.""" + return (self.t * 1.5) % 1.0 < 0.5 diff --git a/packages/car_cluster.json b/packages/car_cluster.json index c8096cf97..8ec78c6af 100644 --- a/packages/car_cluster.json +++ b/packages/car_cluster.json @@ -9,84 +9,16 @@ "../lib/examples/car_cluster/car_cluster.py" ], [ - "car_cluster/chrome.py", - "../lib/examples/car_cluster/chrome.py" + "car_cluster/cluster.py", + "../lib/examples/car_cluster/cluster.py" ], [ - "car_cluster/focus_nav.py", - "../lib/examples/car_cluster/focus_nav.py" - ], - [ - "car_cluster/gauges.py", - "../lib/examples/car_cluster/gauges.py" - ], - [ - "car_cluster/input_map.py", - "../lib/examples/car_cluster/input_map.py" - ], - [ - "car_cluster/key_probe.py", - "../lib/examples/car_cluster/key_probe.py" - ], - [ - "car_cluster/layout.py", - "../lib/examples/car_cluster/layout.py" - ], - [ - "car_cluster/lv_util.py", - "../lib/examples/car_cluster/lv_util.py" - ], - [ - "car_cluster/rails.py", - "../lib/examples/car_cluster/rails.py" - ], - [ - "car_cluster/theme.py", - "../lib/examples/car_cluster/theme.py" + "car_cluster/ring.py", + "../lib/examples/car_cluster/ring.py" ], [ "car_cluster/vehicle.py", "../lib/examples/car_cluster/vehicle.py" - ], - [ - "car_cluster/screens/__init__.py", - "../lib/examples/car_cluster/screens/__init__.py" - ], - [ - "car_cluster/screens/_common.py", - "../lib/examples/car_cluster/screens/_common.py" - ], - [ - "car_cluster/screens/assist.py", - "../lib/examples/car_cluster/screens/assist.py" - ], - [ - "car_cluster/screens/engine.py", - "../lib/examples/car_cluster/screens/engine.py" - ], - [ - "car_cluster/screens/lights.py", - "../lib/examples/car_cluster/screens/lights.py" - ], - [ - "car_cluster/screens/speed.py", - "../lib/examples/car_cluster/screens/speed.py" - ], - [ - "car_cluster/screens/theme_screen.py", - "../lib/examples/car_cluster/screens/theme_screen.py" - ], - [ - "car_cluster/screens/tires.py", - "../lib/examples/car_cluster/screens/tires.py" - ], - [ - "car_cluster/screens/trip_a.py", - "../lib/examples/car_cluster/screens/trip_a.py" - ], - [ - "car_cluster/screens/trip_b.py", - "../lib/examples/car_cluster/screens/trip_b.py" ] ], "version": "0.0.1" diff --git a/packages/examples.json b/packages/examples.json index c28b773eb..45280ea07 100644 --- a/packages/examples.json +++ b/packages/examples.json @@ -333,85 +333,17 @@ "github:PyDevices/pydevices-examples/lib/examples/car_cluster/car_cluster.py" ], [ - "car_cluster/chrome.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/chrome.py" + "car_cluster/cluster.py", + "github:PyDevices/pydevices-examples/lib/examples/car_cluster/cluster.py" ], [ - "car_cluster/focus_nav.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/focus_nav.py" - ], - [ - "car_cluster/gauges.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/gauges.py" - ], - [ - "car_cluster/input_map.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/input_map.py" - ], - [ - "car_cluster/key_probe.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/key_probe.py" - ], - [ - "car_cluster/layout.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/layout.py" - ], - [ - "car_cluster/lv_util.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/lv_util.py" - ], - [ - "car_cluster/rails.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/rails.py" - ], - [ - "car_cluster/theme.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/theme.py" + "car_cluster/ring.py", + "github:PyDevices/pydevices-examples/lib/examples/car_cluster/ring.py" ], [ "car_cluster/vehicle.py", "github:PyDevices/pydevices-examples/lib/examples/car_cluster/vehicle.py" ], - [ - "car_cluster/screens/__init__.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/__init__.py" - ], - [ - "car_cluster/screens/_common.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/_common.py" - ], - [ - "car_cluster/screens/assist.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/assist.py" - ], - [ - "car_cluster/screens/engine.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/engine.py" - ], - [ - "car_cluster/screens/lights.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/lights.py" - ], - [ - "car_cluster/screens/speed.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/speed.py" - ], - [ - "car_cluster/screens/theme_screen.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/theme_screen.py" - ], - [ - "car_cluster/screens/tires.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/tires.py" - ], - [ - "car_cluster/screens/trip_a.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/trip_a.py" - ], - [ - "car_cluster/screens/trip_b.py", - "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/trip_b.py" - ], [ "cast/__init__.py", "github:PyDevices/pydevices-examples/lib/examples/cast/__init__.py" diff --git a/pydevices-examples.toml b/pydevices-examples.toml index 7cd84db2b..50be1fc6e 100644 --- a/pydevices-examples.toml +++ b/pydevices-examples.toml @@ -38,25 +38,8 @@ "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/calc_widgets.py" = "/examples/calc_widgets.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/__init__.py" = "/examples/car_cluster/__init__.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/car_cluster.py" = "/examples/car_cluster/car_cluster.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/chrome.py" = "/examples/car_cluster/chrome.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/focus_nav.py" = "/examples/car_cluster/focus_nav.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/gauges.py" = "/examples/car_cluster/gauges.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/input_map.py" = "/examples/car_cluster/input_map.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/key_probe.py" = "/examples/car_cluster/key_probe.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/layout.py" = "/examples/car_cluster/layout.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/lv_util.py" = "/examples/car_cluster/lv_util.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/rails.py" = "/examples/car_cluster/rails.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/__init__.py" = "/examples/car_cluster/screens/__init__.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/_common.py" = "/examples/car_cluster/screens/_common.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/assist.py" = "/examples/car_cluster/screens/assist.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/engine.py" = "/examples/car_cluster/screens/engine.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/lights.py" = "/examples/car_cluster/screens/lights.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/speed.py" = "/examples/car_cluster/screens/speed.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/theme_screen.py" = "/examples/car_cluster/screens/theme_screen.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/tires.py" = "/examples/car_cluster/screens/tires.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/trip_a.py" = "/examples/car_cluster/screens/trip_a.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/trip_b.py" = "/examples/car_cluster/screens/trip_b.py" -"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/theme.py" = "/examples/car_cluster/theme.py" +"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/cluster.py" = "/examples/car_cluster/cluster.py" +"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/ring.py" = "/examples/car_cluster/ring.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/vehicle.py" = "/examples/car_cluster/vehicle.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/cast/__init__.py" = "/examples/cast/__init__.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/cast/_common.py" = "/examples/cast/_common.py" diff --git a/tests/test_gallery_frame.py b/tests/test_gallery_frame.py index 03eb349d4..7bd7b7c77 100644 --- a/tests/test_gallery_frame.py +++ b/tests/test_gallery_frame.py @@ -204,8 +204,8 @@ def test_interpreter_loaders_set_browser_defaults_without_importing_board(self): def test_car_cluster_forces_its_browser_resolution(self): source = _read(ROOT / "lib" / "examples" / "car_cluster" / "car_cluster.py") - assert 'env_set("PYDEVICES_WIDTH", "1024")' in source - assert 'env_set("PYDEVICES_HEIGHT", "512")' in source + assert 'env_set("PYDEVICES_WIDTH", "800")' in source + assert 'env_set("PYDEVICES_HEIGHT", "480")' in source assert 'if env_get("PYDEVICES_WIDTH")' not in source assert 'if env_get("PYDEVICES_HEIGHT")' not in source